Compare commits
120 commits
1b52474f3d
...
558d029bc5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
558d029bc5 | ||
|
|
7b1dad3287 | ||
|
|
f30df6fd59 | ||
|
|
2f84a2747c | ||
|
|
9b59e85ad2 | ||
|
|
9fb3b7de08 | ||
|
|
2969813144 | ||
|
|
a3f26e7719 | ||
|
|
1d6c27f94c | ||
|
|
e9a8b57196 | ||
|
|
8fa833aa58 | ||
|
|
1a57cc6603 | ||
|
|
38c3a88de6 | ||
|
|
192d3e4786 | ||
|
|
c623c69e95 | ||
|
|
dd01bbfd0c | ||
|
|
634ad447e7 | ||
|
|
2087b61ea2 | ||
|
|
6b63a4da65 | ||
|
|
958d25e340 | ||
|
|
c031592765 | ||
|
|
71e49c1a7b | ||
|
|
574ae08f6d | ||
|
|
5522c437dc | ||
|
|
ef5d8a3319 | ||
|
|
73fe8a455a | ||
|
|
6f663799e1 | ||
|
|
19cf8fd7c6 | ||
|
|
b269a344b8 | ||
|
|
d43183641e | ||
|
|
09ca7b1380 | ||
|
|
40a65c740d | ||
|
|
a9fb6cdcba | ||
|
|
a342719cc9 | ||
|
|
16e929962c | ||
|
|
03b276b332 | ||
|
|
4a8eb94bc5 | ||
|
|
e7a35c2345 | ||
|
|
1a3fd92854 | ||
|
|
9d79cf157e | ||
|
|
e7502b8ac7 | ||
|
|
4a70e1bfce | ||
|
|
5054dac90b | ||
|
|
e7b2627d7e | ||
|
|
0ebddc9918 | ||
|
|
996482fc19 | ||
|
|
56bc98b2e6 | ||
|
|
0f2d327d9a | ||
|
|
db6b787349 | ||
|
|
f52f10d137 | ||
|
|
c64831c642 | ||
|
|
2a1c351584 | ||
|
|
746271ca44 | ||
|
|
a599ce385c | ||
|
|
bd2e6b50e7 | ||
|
|
70a3f01393 | ||
|
|
e5c2a9b764 | ||
|
|
bb312e51f4 | ||
|
|
d68546d102 | ||
|
|
42f24f1a90 | ||
|
|
e0892f3fc7 | ||
|
|
7797e947bb | ||
|
|
81cfbd53c3 | ||
|
|
b0f8c569a7 | ||
|
|
0ce88c27b8 | ||
|
|
7d230320af | ||
|
|
b48a6da32f | ||
|
|
099d02d3c3 | ||
|
|
a9170258eb | ||
|
|
0c6bedaa7c | ||
|
|
655a7ecfcd | ||
|
|
afc5830969 | ||
|
|
bcc5d50a54 | ||
|
|
e594327137 | ||
|
|
bc634382c0 | ||
|
|
d7e00840de | ||
|
|
ba9ae0593c | ||
|
|
f8fd64e799 | ||
|
|
ec91c24e36 | ||
|
|
7f7936a06f | ||
|
|
5bde389eb0 | ||
|
|
9977df03b5 | ||
|
|
b3873f5b8e | ||
|
|
5ff7b1b321 | ||
|
|
f12e528e05 | ||
|
|
75c9029cc2 | ||
|
|
97e344a99b | ||
|
|
3d30ddb688 | ||
|
|
919f3887db | ||
|
|
208333c111 | ||
|
|
a01b520840 | ||
|
|
81efbe943b | ||
|
|
7ce6d87208 | ||
|
|
8e0f551794 | ||
|
|
bb23205d22 | ||
|
|
5128520de5 | ||
|
|
19e607eea5 | ||
|
|
e0717c4a73 | ||
|
|
04826c9124 | ||
|
|
10e1f24168 | ||
|
|
9ab03c4ea4 | ||
|
|
981d6e3ebb | ||
|
|
8c69616200 | ||
|
|
ae3fabe6a6 | ||
|
|
b813f6869a | ||
|
|
c176323a01 | ||
|
|
cf596fb9e5 | ||
|
|
98386f5b18 | ||
|
|
4240b098fa | ||
|
|
b8a8e95f55 | ||
|
|
ae5d2d2b74 | ||
|
|
b8b23ea596 | ||
|
|
b369644f39 | ||
|
|
d63d27ca23 | ||
|
|
693fd22eb4 | ||
|
|
71728ff1c6 | ||
|
|
33716c4e3e | ||
|
|
27949053b9 | ||
|
|
6126950796 | ||
|
|
3598716753 |
188 changed files with 37740 additions and 5339 deletions
436
.forgejo/workflows/release.yml
Normal file
436
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
# 文派统一插件发布 CI Workflow
|
||||
# 触发:push tag v*
|
||||
# 运行环境:forgejo-ci-php:latest (Alpine + php-cli + git + rsync + zip + node)
|
||||
# 基于 ci-workflows/wp-release.yml 2026-02-18 版本
|
||||
name: Release Plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
PLUGIN_SLUG: ${{ github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Verify tools
|
||||
shell: bash
|
||||
run: |
|
||||
php -v | head -1 || true
|
||||
git --version
|
||||
rsync --version | head -1 || true
|
||||
zip --version | head -2 || true
|
||||
jq --version
|
||||
curl --version | head -1 || true
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
VERSION="${TAG#v}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Tag: $TAG, Version: $VERSION"
|
||||
|
||||
- name: Detect main plugin file
|
||||
id: detect
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MAIN_FILE=$(grep -rl "Plugin Name:" *.php 2>/dev/null | head -1)
|
||||
if [ -z "$MAIN_FILE" ]; then
|
||||
echo "::error::No main plugin file found"
|
||||
exit 1
|
||||
fi
|
||||
echo "main_file=$MAIN_FILE" >> "$GITHUB_OUTPUT"
|
||||
echo "Main file: $MAIN_FILE"
|
||||
|
||||
- name: Validate version consistency
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
MAIN_FILE="${{ steps.detect.outputs.main_file }}"
|
||||
HEADER_VERSION=$(grep -i "Version:" "$MAIN_FILE" | grep -v "Requires" | grep -v "Tested" | head -1 | sed "s/.*Version:[[:space:]]*//" | sed "s/[[:space:]]*$//" | tr -d "\r")
|
||||
echo "Extracted header version: [$HEADER_VERSION]"
|
||||
if [ "$HEADER_VERSION" != "$VERSION" ]; then
|
||||
echo "::error::Version mismatch: tag=$VERSION, header=$HEADER_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
if [ -f "readme.txt" ]; then
|
||||
STABLE_TAG=$(grep -i "^Stable tag:" readme.txt | head -1 | sed "s/.*Stable tag:[[:space:]]*//" | sed "s/[[:space:]]*$//" | tr -d "\r")
|
||||
if [ -n "$STABLE_TAG" ] && [ "$STABLE_TAG" != "$VERSION" ]; then
|
||||
echo "::error::Stable tag mismatch: tag=$VERSION, readme=$STABLE_TAG"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "Version consistency check passed: $VERSION"
|
||||
|
||||
- name: Generate Changelog
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
PREV_TAG=$(git tag --sort=-version:refname | grep -E '^v' | grep -v "^${TAG}$" | head -1)
|
||||
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
echo "Changelog: ${PREV_TAG}..${TAG}"
|
||||
COMMITS=$(git log "${PREV_TAG}..${TAG}" --no-merges --pretty=format:"%s (%h)" 2>/dev/null || true)
|
||||
else
|
||||
echo "Changelog: first release, using all commits"
|
||||
COMMITS=$(git log --no-merges --pretty=format:"%s (%h)" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -z "$COMMITS" ]; then
|
||||
: > /tmp/changelog.md
|
||||
echo "No commits found for changelog"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FEAT="" FIX="" OTHER=""
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
feat:*|feat\(*) FEAT="${FEAT}\n- ${line}" ;;
|
||||
fix:*|fix\(*) FIX="${FIX}\n- ${line}" ;;
|
||||
*) OTHER="${OTHER}\n- ${line}" ;;
|
||||
esac
|
||||
done <<< "$COMMITS"
|
||||
|
||||
CHANGELOG=""
|
||||
HAS_CATEGORIES=false
|
||||
if [ -n "$FEAT" ]; then
|
||||
CHANGELOG="${CHANGELOG}#### New Features${FEAT}\n\n"
|
||||
HAS_CATEGORIES=true
|
||||
fi
|
||||
if [ -n "$FIX" ]; then
|
||||
CHANGELOG="${CHANGELOG}#### Bug Fixes${FIX}\n\n"
|
||||
HAS_CATEGORIES=true
|
||||
fi
|
||||
if [ -n "$OTHER" ]; then
|
||||
if [ "$HAS_CATEGORIES" = true ]; then
|
||||
CHANGELOG="${CHANGELOG}#### Other Changes${OTHER}\n\n"
|
||||
else
|
||||
CHANGELOG="${OTHER#\\n}\n\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '%b' "$CHANGELOG" > /tmp/changelog.md
|
||||
echo "Changelog generated ($(echo "$COMMITS" | wc -l) commits)"
|
||||
|
||||
- name: PHP Lint
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ERRORS=0
|
||||
while IFS= read -r file; do
|
||||
if ! php -l "$file" > /dev/null 2>&1; then
|
||||
php -l "$file"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*")
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "::error::PHP lint found $ERRORS error(s)"
|
||||
exit 1
|
||||
fi
|
||||
echo "PHP lint passed"
|
||||
|
||||
- name: Build ZIP
|
||||
id: build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
SLUG="${{ env.PLUGIN_SLUG }}"
|
||||
ZIP_NAME="${SLUG}-${VERSION}.zip"
|
||||
BUILD_DIR="/tmp/build/${SLUG}"
|
||||
RELEASE_DIR="/tmp/release"
|
||||
|
||||
rm -rf "$BUILD_DIR" "$RELEASE_DIR"
|
||||
mkdir -p "$BUILD_DIR" "$RELEASE_DIR"
|
||||
|
||||
rsync -a \
|
||||
--exclude=".git" \
|
||||
--exclude=".github" \
|
||||
--exclude=".forgejo" \
|
||||
--exclude=".gitignore" \
|
||||
--exclude=".gitattributes" \
|
||||
--exclude=".editorconfig" \
|
||||
--exclude=".env*" \
|
||||
--exclude=".agent" \
|
||||
--exclude=".vscode" \
|
||||
--exclude="node_modules" \
|
||||
--exclude="vendor" \
|
||||
--exclude="tests" \
|
||||
--exclude="docs" \
|
||||
--exclude="lib" \
|
||||
--exclude="phpunit.xml*" \
|
||||
--exclude="phpcs.xml*" \
|
||||
--exclude="phpstan.neon*" \
|
||||
--exclude="composer.json" \
|
||||
--exclude="composer.lock" \
|
||||
--exclude="package.json" \
|
||||
--exclude="package-lock.json" \
|
||||
--exclude="Gruntfile.js" \
|
||||
--exclude="webpack.config.js" \
|
||||
--exclude="*.md" \
|
||||
--exclude="LICENSE" \
|
||||
--exclude="Makefile" \
|
||||
./ "$BUILD_DIR/"
|
||||
|
||||
(
|
||||
cd /tmp/build
|
||||
zip -qr "${RELEASE_DIR}/${ZIP_NAME}" "${SLUG}/"
|
||||
)
|
||||
|
||||
echo "zip_path=${RELEASE_DIR}/${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_dir=${RELEASE_DIR}" >> "$GITHUB_OUTPUT"
|
||||
echo "Built: ${ZIP_NAME} ($(du -h "${RELEASE_DIR}/${ZIP_NAME}" | cut -f1))"
|
||||
|
||||
- name: Calculate SHA-256
|
||||
id: checksum
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ZIP_PATH="${{ steps.build.outputs.zip_path }}"
|
||||
ZIP_NAME="${{ steps.build.outputs.zip_name }}"
|
||||
RELEASE_DIR="${{ steps.build.outputs.release_dir }}"
|
||||
SHA256=$(sha256sum "$ZIP_PATH" | cut -d" " -f1)
|
||||
echo "$SHA256 $ZIP_NAME" > "${RELEASE_DIR}/${ZIP_NAME}.sha256"
|
||||
echo "sha256=$SHA256" >> "$GITHUB_OUTPUT"
|
||||
echo "SHA-256: $SHA256"
|
||||
ls -la "$RELEASE_DIR"
|
||||
|
||||
- name: Create or Update Release & Upload Assets
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
AUTH_TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN:-}}"
|
||||
if [ -z "$AUTH_TOKEN" ]; then
|
||||
echo "::error::Missing auth token: set RELEASE_TOKEN or use default GITHUB_TOKEN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
SLUG="${{ env.PLUGIN_SLUG }}"
|
||||
RELEASE_DIR="${{ steps.build.outputs.release_dir }}"
|
||||
ZIP_NAME="${{ steps.build.outputs.zip_name }}"
|
||||
SHA256="${{ steps.checksum.outputs.sha256 }}"
|
||||
API_URL="${GITHUB_SERVER_URL%/}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
AUTH_HEADER="Authorization: token ${AUTH_TOKEN}"
|
||||
|
||||
{
|
||||
echo "## ${SLUG} ${TAG}"
|
||||
echo ""
|
||||
CHANGELOG_FILE="/tmp/changelog.md"
|
||||
if [ -f "$CHANGELOG_FILE" ] && [ -s "$CHANGELOG_FILE" ]; then
|
||||
echo "### What's Changed"
|
||||
echo ""
|
||||
cat "$CHANGELOG_FILE"
|
||||
fi
|
||||
echo "### Checksums"
|
||||
echo ""
|
||||
echo "| File | SHA-256 |"
|
||||
echo "|------|---------|"
|
||||
echo "| ${ZIP_NAME} | ${SHA256} |"
|
||||
} > /tmp/release-notes.md
|
||||
RELEASE_NOTES=$(cat /tmp/release-notes.md)
|
||||
|
||||
echo ">>> Resolving release ${TAG}"
|
||||
STATUS=$(curl -sS -o /tmp/release.json -w "%{http_code}" \
|
||||
-H "$AUTH_HEADER" \
|
||||
"${API_URL}/releases/tags/${TAG}")
|
||||
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
RELEASE_ID=$(jq -r '.id' /tmp/release.json)
|
||||
echo "Release exists (id=${RELEASE_ID}), patching metadata"
|
||||
curl -sS -f -X PATCH \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg name "$TAG" --arg body "$RELEASE_NOTES" '{name: $name, body: $body, draft: false, prerelease: false}')" \
|
||||
"${API_URL}/releases/${RELEASE_ID}" > /tmp/release.json
|
||||
elif [ "$STATUS" = "404" ]; then
|
||||
echo "Release not found, creating"
|
||||
curl -sS -f -X POST \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg tag "$TAG" --arg name "$TAG" --arg body "$RELEASE_NOTES" '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')" \
|
||||
"${API_URL}/releases" > /tmp/release.json
|
||||
else
|
||||
echo "::error::Failed to query release (HTTP ${STATUS})"
|
||||
cat /tmp/release.json
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_ID=$(jq -r '.id' /tmp/release.json)
|
||||
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
|
||||
echo "::error::Failed to resolve release id"
|
||||
cat /tmp/release.json
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">>> Uploading assets to release ${RELEASE_ID}"
|
||||
for FILE in "${RELEASE_DIR}"/*; do
|
||||
FILENAME=$(basename "$FILE")
|
||||
EXISTING_ASSET_ID=$(jq -r --arg n "$FILENAME" '.assets[]? | select(.name == $n) | .id' /tmp/release.json | head -1)
|
||||
|
||||
if [ -n "$EXISTING_ASSET_ID" ] && [ "$EXISTING_ASSET_ID" != "null" ]; then
|
||||
echo " deleting old asset: ${FILENAME} (id=${EXISTING_ASSET_ID})"
|
||||
curl -sS -f -X DELETE \
|
||||
-H "$AUTH_HEADER" \
|
||||
"${API_URL}/releases/${RELEASE_ID}/assets/${EXISTING_ASSET_ID}" > /dev/null
|
||||
fi
|
||||
|
||||
ENCODED_NAME=$(printf "%s" "$FILENAME" | jq -sRr @uri)
|
||||
echo " uploading: ${FILENAME}"
|
||||
curl -sS -f --retry 3 --retry-delay 2 --retry-all-errors \
|
||||
-X POST \
|
||||
-H "$AUTH_HEADER" \
|
||||
-F "attachment=@${FILE}" \
|
||||
"${API_URL}/releases/${RELEASE_ID}/assets?name=${ENCODED_NAME}" > /dev/null
|
||||
done
|
||||
|
||||
echo ">>> Verifying uploaded assets"
|
||||
curl -sS -f -H "$AUTH_HEADER" "${API_URL}/releases/${RELEASE_ID}" > /tmp/release-final.json
|
||||
for FILE in "${RELEASE_DIR}"/*; do
|
||||
FILENAME=$(basename "$FILE")
|
||||
if ! jq -e --arg n "$FILENAME" '.assets[]? | select(.name == $n)' /tmp/release-final.json > /dev/null; then
|
||||
echo "::error::Missing uploaded asset: ${FILENAME}"
|
||||
jq -r '.assets[]?.name' /tmp/release-final.json
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo ">>> Release ${TAG} published successfully"
|
||||
echo "Release URL: ${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
|
||||
|
||||
- name: Run post-release composer repair on target site (optional)
|
||||
if: ${{ secrets.DEPLOY_HOST != '' && secrets.DEPLOY_SSH_KEY != '' }}
|
||||
shell: bash
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_COMMAND_MODE: ${{ secrets.DEPLOY_COMMAND_MODE }}
|
||||
DEPLOY_SITE_PATH: ${{ secrets.DEPLOY_SITE_PATH }}
|
||||
DEPLOY_WP_USER: ${{ secrets.DEPLOY_WP_USER }}
|
||||
DEPLOY_COMPOSER_SCRIPT: ${{ secrets.DEPLOY_COMPOSER_SCRIPT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
: "${DEPLOY_HOST:?DEPLOY_HOST is required when this step is enabled}"
|
||||
DEPLOY_PORT="${DEPLOY_PORT:-22}"
|
||||
DEPLOY_USER="${DEPLOY_USER:-wpdeploy}"
|
||||
DEPLOY_COMMAND_MODE="${DEPLOY_COMMAND_MODE:-runner}"
|
||||
SLUG="${{ env.PLUGIN_SLUG }}"
|
||||
|
||||
if ! command -v ssh >/dev/null 2>&1; then
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache openssh-client >/dev/null
|
||||
else
|
||||
echo "::error::ssh client not found in runner image"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
install -m 700 -d ~/.ssh
|
||||
KEY_PATH="$HOME/.ssh/deploy_key"
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_PATH"
|
||||
chmod 600 "$KEY_PATH"
|
||||
ssh-keyscan -t rsa,ecdsa,ed25519 -p "$DEPLOY_PORT" -H "$DEPLOY_HOST" >> "$HOME/.ssh/known_hosts" 2>/dev/null || true
|
||||
|
||||
if [[ "$DEPLOY_COMMAND_MODE" == "runner" ]]; then
|
||||
DEPLOY_COMPOSER_SCRIPT="${DEPLOY_COMPOSER_SCRIPT:-/usr/local/sbin/wp-post-release-composer-runner}"
|
||||
ssh -i "$KEY_PATH" -p "$DEPLOY_PORT" -o BatchMode=yes -o StrictHostKeyChecking=yes "${DEPLOY_USER}@${DEPLOY_HOST}" \
|
||||
"sudo '$DEPLOY_COMPOSER_SCRIPT' '$SLUG'"
|
||||
else
|
||||
DEPLOY_SITE_PATH="${DEPLOY_SITE_PATH:-/www/wwwroot/wptea.com}"
|
||||
DEPLOY_WP_USER="${DEPLOY_WP_USER:-www}"
|
||||
DEPLOY_COMPOSER_SCRIPT="${DEPLOY_COMPOSER_SCRIPT:-/opt/wenpai-infra/ops/wp-post-release-composer.sh}"
|
||||
ssh -i "$KEY_PATH" -p "$DEPLOY_PORT" -o BatchMode=yes -o StrictHostKeyChecking=yes "${DEPLOY_USER}@${DEPLOY_HOST}" \
|
||||
"bash '$DEPLOY_COMPOSER_SCRIPT' --site '$DEPLOY_SITE_PATH' --wp-user '$DEPLOY_WP_USER' --plugin '$SLUG' --strict"
|
||||
fi
|
||||
|
||||
- name: Verify update API metadata (optional)
|
||||
if: ${{ secrets.UPDATE_API_BASE != '' }}
|
||||
shell: bash
|
||||
env:
|
||||
UPDATE_API_BASE: ${{ secrets.UPDATE_API_BASE }}
|
||||
VERIFY_FROM_VERSION: ${{ secrets.VERIFY_FROM_VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
API_BASE="${UPDATE_API_BASE%/}"
|
||||
FROM_VERSION="${VERIFY_FROM_VERSION:-0.0.0}"
|
||||
SLUG="${{ env.PLUGIN_SLUG }}"
|
||||
MAIN_FILE="${{ steps.detect.outputs.main_file }}"
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
PLUGIN_FILE="${SLUG}/${MAIN_FILE}"
|
||||
|
||||
echo "Verify update-check: ${PLUGIN_FILE} from ${FROM_VERSION} -> ${VERSION}"
|
||||
REQUEST_JSON=$(jq -cn --arg plugin_file "$PLUGIN_FILE" --arg from "$FROM_VERSION" '{plugins: {($plugin_file): {Version: $from}}}')
|
||||
|
||||
UPDATE_OK=0
|
||||
for attempt in $(seq 1 36); do
|
||||
curl -sS -f -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "$REQUEST_JSON" \
|
||||
"${API_BASE}/api/v1/update-check" > /tmp/update-check.json
|
||||
|
||||
API_VERSION=$(jq -r --arg plugin_file "$PLUGIN_FILE" '.plugins[$plugin_file].version // empty' /tmp/update-check.json)
|
||||
API_PACKAGE=$(jq -r --arg plugin_file "$PLUGIN_FILE" '.plugins[$plugin_file].package // empty' /tmp/update-check.json)
|
||||
|
||||
if [[ "$API_VERSION" == "$VERSION" && "$API_PACKAGE" == *"/releases/download/${TAG}/"* ]]; then
|
||||
UPDATE_OK=1
|
||||
break
|
||||
fi
|
||||
|
||||
echo "[attempt ${attempt}/36] update-check not ready, version=${API_VERSION:-<empty>} package=${API_PACKAGE:-<empty>}"
|
||||
sleep 10
|
||||
done
|
||||
|
||||
if [[ "$UPDATE_OK" -ne 1 ]]; then
|
||||
echo "::error::update-check verification timeout"
|
||||
cat /tmp/update-check.json
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Verify plugin info: ${SLUG}"
|
||||
INFO_OK=0
|
||||
for attempt in $(seq 1 36); do
|
||||
curl -sS -f "${API_BASE}/api/v1/plugins/${SLUG}/info" > /tmp/plugin-info.json
|
||||
INFO_VERSION=$(jq -r '.version // empty' /tmp/plugin-info.json)
|
||||
INFO_PACKAGE=$(jq -r '.download_link // empty' /tmp/plugin-info.json)
|
||||
|
||||
if [[ "$INFO_VERSION" == "$VERSION" && "$INFO_PACKAGE" == *"/releases/download/${TAG}/"* ]]; then
|
||||
INFO_OK=1
|
||||
break
|
||||
fi
|
||||
|
||||
echo "[attempt ${attempt}/36] plugin-info not ready, version=${INFO_VERSION:-<empty>} download=${INFO_PACKAGE:-<empty>}"
|
||||
sleep 10
|
||||
done
|
||||
|
||||
if [[ "$INFO_OK" -ne 1 ]]; then
|
||||
echo "::error::plugin-info verification timeout"
|
||||
cat /tmp/plugin-info.json
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Update API verification passed"
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -9,12 +9,16 @@ Thumbs.db
|
|||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
vendor/
|
||||
/vendor/
|
||||
|
||||
# Build
|
||||
*.min.js
|
||||
*.min.css
|
||||
|
||||
# But keep vendored JS libraries
|
||||
!assets/js/vendor/
|
||||
!assets/js/vendor/**
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
|
|
|
|||
393
CHANGELOG.md
393
CHANGELOG.md
|
|
@ -1,5 +1,398 @@
|
|||
# WPMind 更新日志
|
||||
|
||||
## [3.11.1] - 2026-02-09
|
||||
|
||||
### CSS 架构重构
|
||||
|
||||
统一模块设置页 CSS 架构,消除跨模块样式耦合。
|
||||
|
||||
#### 变更
|
||||
- **新建** `assets/css/components/module-layout.css` — 共享模块布局组件(header/badge/stats/subtabs/options/actions)
|
||||
- **统一命名**: `wpmind-geo-*`/`wpmind-mi-*` 共享类 → `wpmind-module-*`
|
||||
- **JS 作用域化**: GEO/MI/AM 三个模块的 jQuery 选择器限定到各自 panel 容器,防止子标签切换互相干扰
|
||||
- **模块 CSS 瘦身**: 各模块 CSS 仅保留模块专属样式,共享部分统一引用 module-layout.css
|
||||
|
||||
#### 影响范围
|
||||
- 20 个文件变更(6 CSS + 3 JS + 1 PHP + 10 模板)
|
||||
- 420 行新增,499 行删除(净减 79 行)
|
||||
|
||||
---
|
||||
|
||||
## [3.11.0] - 2026-02-09
|
||||
|
||||
### Media Intelligence 模块
|
||||
|
||||
AI 驱动的图片元数据自动生成,复用多模态 Vision API。
|
||||
|
||||
#### 核心功能
|
||||
- **Alt Text 生成**: 上传图片时自动生成无障碍描述
|
||||
- **图片标题**: 自动生成语义化标题
|
||||
- **图片描述**: 自动生成详细描述文本
|
||||
- **批量处理**: 对已有媒体库图片批量生成元数据
|
||||
- **安全检测**: NSFW 内容识别(可选)
|
||||
|
||||
#### 技术实现
|
||||
- **触发方式**: `add_attachment` hook(上传时)+ 手动批量触发
|
||||
- **执行模式**: WP-Cron 异步处理,批量模式支持进度追踪
|
||||
- **API 策略**: `wpmind_vision()` 调用多模态 Provider(Qwen-VL/GPT-4o/Gemini)
|
||||
- **Failover**: Vision API 故障时自动切换到支持视觉的备用 Provider
|
||||
- **语言支持**: 根据站点 locale 自动选择生成语言
|
||||
|
||||
#### 设置页
|
||||
- 子标签页布局:功能开关 + 批量处理
|
||||
- 独立开关(alt text/标题/描述/安全检测)
|
||||
- 生成语言选择
|
||||
- 批量处理进度条和结果统计
|
||||
|
||||
### Auto-Meta 模块
|
||||
|
||||
发布时自动生成摘要、标签、分类、FAQ Schema、SEO 描述,与 GEO 模块协同提升 AI 搜索引擎可见性。
|
||||
|
||||
#### 核心功能
|
||||
- **自动摘要**: 发布文章时生成 100-150 字摘要,仅在摘要为空时填充
|
||||
- **智能标签**: 自动提取 3-5 个关键词标签,仅在无标签时添加
|
||||
- **分类建议**: 从已有分类中匹配最佳分类,仅在只有默认分类时替换(默认关闭)
|
||||
- **FAQ Schema**: 生成 3 个常见问题及回答,通过 `wpmind_article_schema` filter 注入 GEO 模块
|
||||
- **SEO 描述**: 生成 120-160 字符 SEO 描述,存储在 post meta 中
|
||||
|
||||
#### 技术实现
|
||||
- **触发方式**: `transition_post_status`(首次发布)+ `post_updated`(内容变更)
|
||||
- **执行模式**: WP-Cron 异步(延迟 30 秒),手动触发同步执行
|
||||
- **API 策略**: 单次 `wpmind_structured()` 调用生成全部 5 项元数据
|
||||
- **防护机制**: 并发锁、内容哈希去重、hook 临时移除防递归、功能预检跳过无效 AI 调用
|
||||
- **FAQ 注入**: 优先级 15(AiSummary:10 之后,EntityLinker:20 之前)
|
||||
|
||||
#### 设置页
|
||||
- 子标签页布局:功能开关 + 手动生成
|
||||
- 5 个独立功能开关(摘要/标签/分类/FAQ/SEO 描述)
|
||||
- 支持的文章类型多选(默认 post + page)
|
||||
- 手动输入文章 ID 生成并预览结果
|
||||
|
||||
#### 代码统计
|
||||
- 新增 7 个文件(5 PHP + 1 CSS + 1 JS),约 1,200 行
|
||||
- 修改 2 个文件(settings-page.php + wpmind.php)
|
||||
|
||||
#### Codex 评审修复
|
||||
- **High**: 新增 `has_enabled_features()` 预检,所有开关关闭时跳过 AI 调用
|
||||
- **Medium**: `on_update` 允许 source 为空时重试(首次 API 失败后不再永久跳过)
|
||||
- **Medium**: `inject_faq_schema` 新增 FAQ 开关检查,关闭后不再输出 Schema
|
||||
- **Low**: 手动生成对非发布文章返回明确状态错误
|
||||
|
||||
---
|
||||
|
||||
## [3.10.2] - 2026-02-08
|
||||
|
||||
### API Gateway 模块 (Phase 0-10)
|
||||
|
||||
将 WordPress 变为 OpenAI 兼容的自托管 AI API 网关。
|
||||
|
||||
#### 核心架构
|
||||
- **8 级中间件管道**: Auth → Budget → Quota → RequestTransform → Route → ResponseTransform → Error → Log
|
||||
- **API Key 系统**: `sk_mind_{key_id}_{secret}` 格式,SHA-256 + 常量时间验证,防时序攻击
|
||||
- **3 张数据库表**: `wpmind_api_keys`、`wpmind_api_key_usage`、`wpmind_api_audit_log`
|
||||
|
||||
#### REST API 端点 (OpenAI 兼容)
|
||||
- `POST /wp-json/mind/v1/chat/completions` — 对话补全(支持流式 SSE)
|
||||
- `POST /wp-json/mind/v1/embeddings` — 向量嵌入
|
||||
- `POST /wp-json/mind/v1/responses` — Responses API 兼容
|
||||
- `GET /wp-json/mind/v1/models` — 模型列表(19 个模型)
|
||||
- `GET /wp-json/mind/v1/models/{id}` — 单模型详情
|
||||
- `GET /wp-json/mind/v1/status` — 网关状态(管理端点)
|
||||
|
||||
#### 功能特性
|
||||
- **SSE 流式输出**: CancellationToken + 并发槽位控制 + 心跳保活
|
||||
- **速率限制**: Redis 滑动窗口(Lua 原子操作)+ Transient 回退
|
||||
- **预算控制**: 月度预算检查 + 用量统计
|
||||
- **模型映射**: 18 个默认模型 + 用户自定义别名 + auto 智能路由
|
||||
- **错误格式**: 14 种错误码映射为 OpenAI 标准格式
|
||||
- **Admin UI**: 设置页(状态卡片 + 基础设置 + API Key 管理)
|
||||
|
||||
#### 代码统计
|
||||
- 36 个 PHP 文件,5,336 行源代码
|
||||
- 5 个 PHPUnit 测试(36 个测试方法)+ 1 个集成测试脚本
|
||||
- 部署文档 (DEPLOYMENT.md)
|
||||
|
||||
#### 修复
|
||||
- `generate_key_id`: `random_bytes(8)` → `(12)` 确保 key_id 始终 12 字符
|
||||
- `settings-page.php`: 添加 API Gateway 标签页到设置页导航
|
||||
|
||||
---
|
||||
|
||||
## [3.8.0] - 2026-02-07
|
||||
|
||||
### 🧹 兼容层清理 + 扩展点增强
|
||||
|
||||
#### 兼容层清理(-2,245 行)
|
||||
- **删除 10 个兼容层文件**: `includes/Usage/`、`includes/Budget/`、`includes/Analytics/` 下的代理类和回退实现
|
||||
- **命名空间迁移**: 所有调用方直接引用模块类(`WPMind\Modules\CostControl\*`、`WPMind\Modules\Analytics\*`)
|
||||
- **cost-control 模块不可禁用**: `can_disable: false`,用量追踪是路由策略和 API 状态的核心依赖
|
||||
- **ModuleLoader 强制启用**: `can_disable: false` 的模块在升级时自动强制启用,防止旧安装 fatal
|
||||
- **analytics 模块守卫**: `routing.php` 和 `AjaxController` 添加 `class_exists()` 检查,禁用时优雅降级
|
||||
- **wpmind.php 精简**: 删除向后兼容 fallback 代码块,`do_action('wpmind_usage_record')` 保留
|
||||
|
||||
#### Provider 懒加载
|
||||
- **ProviderRegistrar 重构**: 移除 8 个 `use` 导入,改用字符串 FQCN 常量
|
||||
- **`wpmind_provider_map` filter**: 允许第三方注册自定义 Provider
|
||||
|
||||
#### 路由策略可插拔
|
||||
- **`wpmind_register_routing_strategies` action**: 允许第三方在默认策略注册后添加自定义路由策略
|
||||
|
||||
#### 受影响文件(26 个)
|
||||
- 修改 16 个文件(命名空间替换 + 守卫 + filter/action)
|
||||
- 删除 10 个文件(兼容层代理 + 回退实现)
|
||||
- 保留 `includes/Usage/Pricing.php`(共享定价数据类)
|
||||
|
||||
> Codex CLI 评审通过,3 个发现已修复(analytics 守卫、ModuleLoader 强制启用、测试更新)
|
||||
|
||||
---
|
||||
|
||||
## [3.7.0] - 2026-02-07
|
||||
|
||||
### 🏗️ PublicAPI Facade 拆分 + 安全加固
|
||||
|
||||
#### 架构重构
|
||||
- **PublicAPI.php 拆分**: 2124 行单文件拆分为 Facade + 6 个 Service 类
|
||||
- `PublicAPI.php` (398 行): 瘦 Facade,保留单例、递归保护、状态方法
|
||||
- `Services/AbstractService.php` (194 行): 共享基础设施(provider 解析、failover、缓存)
|
||||
- `Services/ChatService.php` (591 行): chat + stream + SDK 路由 + HTTP 请求
|
||||
- `Services/TextProcessingService.php` (312 行): translate + summarize + moderate
|
||||
- `Services/StructuredOutputService.php` (215 行): structured + batch + schema 验证
|
||||
- `Services/EmbeddingService.php` (126 行): embed
|
||||
- `Services/AudioService.php` (265 行): transcribe + speech
|
||||
- `Services/ImageService.php` (66 行): generate_image(委托 ImageRouter)
|
||||
- **依赖注入**: TextProcessingService 注入 ChatService + StructuredOutputService
|
||||
- **递归保护留在 Facade**: Service 内部互调不触发递归检查
|
||||
- **PSR-4 自动加载**: 现有 autoloader 自动映射 `WPMind\API\Services\*`
|
||||
|
||||
#### 安全加固(Codex 审计 9 项)
|
||||
- **transcribe() SSRF 防护**: `wp_http_validate_url()` + 协议白名单
|
||||
- **transcribe() 路径遍历防护**: `realpath()` + uploads 目录校验
|
||||
- **transcribe() 文件验证**: 25MB 大小上限 + 扩展名白名单
|
||||
- **stream() 环境检测**: `allow_url_fopen` 配置检查
|
||||
- **embed() 响应验证**: JSON 解码显式校验 + 非数组防护
|
||||
- **speech() 写入检查**: `file_put_contents()` 返回值验证
|
||||
- **ErrorHandler 信息泄露**: 响应体截断到 500 字符
|
||||
- **SDKAdapter 空值保护**: `getTokenUsage()` null 安全
|
||||
- **SDKAdapter 异常脱敏**: 仅 `WP_DEBUG` 记录详细异常
|
||||
|
||||
#### 文档清理
|
||||
- 归档 5 个过时文档到 `docs/_archive/`
|
||||
- 更新 WPMIND-ROADMAP.md Phase 3/3.5 完成状态
|
||||
|
||||
> 所有 15 个公共方法签名不变,`wpmind_*()` 全局函数兼容,7/7 回归测试通过
|
||||
|
||||
---
|
||||
|
||||
## [3.6.0] - 2026-02-07
|
||||
|
||||
### 🔗 执行层统一到 WP AI Client SDK (Phase C)
|
||||
|
||||
#### C1: SDKAdapter 适配器类
|
||||
- **新增** `includes/SDK/SDKAdapter.php`: 封装 WP AI Client SDK 调用
|
||||
- 异常→WP_Error 转换,保留 HTTP 状态码信息用于重试判断
|
||||
- GenerativeAiResult→PublicAPI 数组格式转换
|
||||
- 支持 SDK 内置 Provider (OpenAI/Anthropic/Google) + WPMind 注册 Provider
|
||||
|
||||
#### C2: SDK 路径集成
|
||||
- **execute_chat_request()** 增加 SDK 优先路径,失败自动回退原 HTTP 实现
|
||||
- **错误分类处理**: 适配错误静默回退(不消耗重试预算),Provider 错误记录失败
|
||||
- 健康统计在 SDK 和 HTTP 两条路径下都正常记录
|
||||
- 新增 `wpmind_sdk_fallback` action hook 用于监控回退事件
|
||||
|
||||
#### C3: 能力 gate + Provider 白名单
|
||||
- **should_use_sdk()**: 5 层检查(SDK 可用性、用户配置、能力 gate、Provider 白名单)
|
||||
- 默认对 Anthropic/Google 启用 SDK(解决 PublicAPI 中不可用的问题)
|
||||
- tools 请求暂不走 SDK(v3.6.0 限制)
|
||||
- `wpmind_sdk_providers` filter 允许扩展白名单
|
||||
- `wpmind_sdk_enabled` 选项允许全局禁用
|
||||
|
||||
> 基于 Claude + Codex 评审共识,详见 `docs/AI-PIPELINE-AUDIT.md` 第 9 节
|
||||
|
||||
---
|
||||
|
||||
## [3.5.0] - 2026-02-07
|
||||
|
||||
### 🔀 模型重选 + 路由统一 (Phase B)
|
||||
|
||||
#### B1: Failover 模型重选
|
||||
- **model=auto 下移**: failover 循环内每个 provider 动态获取默认模型,不再固化首选 provider 的模型 (N2)
|
||||
- **显式模型回退**: 目标 provider 不支持用户指定模型时,自动回退到该 provider 默认模型
|
||||
- **model_fallback 标记**: 模型被自动替换时在结果中标注 `model_fallback: true` + `original_model`
|
||||
|
||||
#### B2: stream() 接入路由和故障转移
|
||||
- **路由接入**: stream() 接入 `wpmind_select_provider` filter (I2)
|
||||
- **故障转移**: 通过 FailoverManager 获取故障转移链,fopen 失败自动切换 provider
|
||||
- **健康记录**: 成功/失败均记录到 FailoverManager,影响后续路由决策
|
||||
|
||||
#### B3: embed() 接入路由和故障转移
|
||||
- **路由接入**: embed() 接入 `wpmind_select_provider` filter (I2)
|
||||
- **故障转移**: wp_remote_post 失败或 HTTP 错误时自动切换 provider
|
||||
- **动态模型**: embed model 在循环内根据 provider 动态选择
|
||||
|
||||
#### B4: transcribe/speech 接入路由
|
||||
- **路由接入**: transcribe() 和 speech() 接入 `wpmind_select_provider` filter
|
||||
- **能力过滤**: failover 链自动过滤不支持 audio API 的 provider
|
||||
- **支持列表**: transcribe 仅 OpenAI,speech 支持 OpenAI + DeepSeek
|
||||
|
||||
> 基于 Claude + Codex 审计 Phase B 计划,详见 `docs/AI-PIPELINE-AUDIT.md`
|
||||
|
||||
---
|
||||
|
||||
## [3.4.0] - 2026-02-07
|
||||
|
||||
### 🛡️ AI 请求链路可靠性修复 (Phase A)
|
||||
|
||||
#### P0 修复
|
||||
- **缓存键加入 provider/model**: `generate_cache_key()` 包含服务商和模型参数,避免跨 Provider 缓存污染 (N1)
|
||||
- **非 JSON 响应防护**: `execute_chat_request()` 检测 `json_decode` 失败,返回 `wpmind_invalid_response` 错误而非 fatal (N3)
|
||||
- **stream() 默认 provider 统一**: 从硬编码 `deepseek` 改为 `get_option()`,与 `chat()` 行为一致 (N4)
|
||||
|
||||
#### P1 修复
|
||||
- **per-provider 重试逻辑**: 激活 `ErrorHandler::should_retry()` + `get_retry_delay()` 死代码,429/5xx 先重试再 failover (I1)
|
||||
- 非最后 Provider: 最多 1 次重试
|
||||
- 最后 Provider: 最多 3 次重试,指数退避 1s→2s→4s
|
||||
- 不可重试错误 (401/403/配置缺失) 直接跳过
|
||||
- 新增 `wpmind_retry` action hook 用于监控
|
||||
|
||||
> 基于 Claude + Codex 两轮审计,详见 `docs/AI-PIPELINE-AUDIT.md`
|
||||
|
||||
---
|
||||
|
||||
## [3.3.0] - 2026-02-07
|
||||
|
||||
### 🔧 编码规范化 (Phase 1 完成)
|
||||
- **方法名 camelCase → snake_case**: 39 文件全量重命名,符合 WordPress PHP 编码规范
|
||||
- 涉及模块: Routing、Failover、Budget、Analytics、Usage、ErrorHandler、API
|
||||
- 兼容层 (`__callStatic` 代理) 同步更新
|
||||
- 模板文件静态调用同步更新
|
||||
- 外部库接口方法 (Providers/Image) 保持不变
|
||||
- **Chart.js CDN 兜底**: 本地优先加载,失败时自动切换 CDN
|
||||
- **后台 JS 模块化**: admin 逻辑拆分为 `admin-*.js`,Chart.js 仅 analytics 依赖
|
||||
- **模板去内嵌**: modules/cost-control 模板移除内联脚本,Modules 样式迁移到 `assets/css/modules.css`
|
||||
- **后台 PHP 拆分**: admin 逻辑迁移至 `includes/Admin/*`
|
||||
|
||||
---
|
||||
|
||||
## [3.2.1] - 2026-02-06
|
||||
|
||||
### 🔒 全面审查修复 (18 个问题)
|
||||
|
||||
#### P0 紧急修复
|
||||
- **AnalyticsModule Fatal Error**: 移除私有构造函数单例模式,改为 public 构造函数
|
||||
- **版本号统一**: 插件头部、常量、CLAUDE.md 统一为 3.2.0
|
||||
- **损坏注释块**: 删除 wpmind.php 残留的未闭合 PHPDoc 注释
|
||||
- **设置链接 404**: `options-general.php` 修正为 `admin.php`
|
||||
|
||||
#### P1 高优先级修复
|
||||
- **ImageRouter 命名空间**: `Routing\ImageRouter` 修正为 `Providers\Image\ImageRouter`
|
||||
- **Analytics nonce 错误**: `wpmind_admin_nonce` 修正为 `wpmind_ajax`
|
||||
- **AJAX 重复注册**: 移除 `wpmind_clear_usage_stats` 重复注册
|
||||
- **卸载脚本补全**: 添加 GEO/模块状态等 13 个选项清理 + `$wpdb->prepare()`
|
||||
- **测试端点安全**: 添加 nonce 验证,移除 nopriv 未认证访问
|
||||
- **speech() 路径遍历**: 添加 uploads 目录路径验证
|
||||
- **模块依赖排序**: ModuleLoader 添加 `resolve_load_order()` 确保加载顺序
|
||||
|
||||
#### P2 中优先级修复
|
||||
- **XSS 防护**: admin.js errorCode 添加 `escapeHtml()`
|
||||
- **wp_unslash**: GeoModule/CostControlModule 统一使用 `wp_unslash()`
|
||||
- **命名空间验证**: ModuleLoader 添加 `WPMind\` 前缀安全检查
|
||||
- **定价数据去重**: 提取共享 `Pricing.php` 类,消除 ~160 行重复
|
||||
- **GEO 设置去重**: 从 wpmind.php 移除 5 个重复的 register_setting
|
||||
- **strict_types**: 29 个文件添加 `declare(strict_types=1)`
|
||||
|
||||
---
|
||||
|
||||
## [3.2.0] - 2026-02-05
|
||||
|
||||
### ✨ 模块化架构
|
||||
|
||||
将 Cost Control 和 Analytics 功能迁移为独立可选模块:
|
||||
|
||||
#### 🏗️ 模块系统
|
||||
- **ModuleLoader**: 模块发现、加载、生命周期管理
|
||||
- **ModuleInterface**: 标准模块契约
|
||||
- **module.json**: 模块元数据和配置
|
||||
- 支持模块启用/禁用切换
|
||||
|
||||
#### 📦 三个模块
|
||||
- **Cost Control**: 用量追踪、预算限额、告警通知
|
||||
- **Analytics**: 用量趋势、服务商对比、成本分析
|
||||
- **GEO**: Markdown Feeds、llms.txt、Schema.org、AI 爬虫追踪
|
||||
|
||||
#### 🔧 兼容层
|
||||
- 保留 `includes/Usage/`、`includes/Budget/`、`includes/Analytics/` 兼容层
|
||||
- 模块加载时委托给模块实现,未加载时使用 Fallback
|
||||
|
||||
---
|
||||
|
||||
## [3.1.0] - 2026-02-05
|
||||
|
||||
### ✨ GEO 增强
|
||||
|
||||
#### 统一核心管线
|
||||
- **MarkdownProcessor**: 统一的 Markdown 处理管线,替代分散的处理逻辑
|
||||
- **ProcessOptions**: 处理选项封装类
|
||||
|
||||
#### 新功能
|
||||
- **llms.txt 生成器**: `/llms.txt` 端点,AI 友好的站点描述
|
||||
- **Schema.org 集成**: 自动注入结构化数据 (Article/WebPage)
|
||||
- **GEO 设置界面**: 5 个配置选项的管理界面
|
||||
|
||||
#### 修复
|
||||
- admin.js AJAX 变量错误 (wpmind_admin → wpmindData)
|
||||
- 多站点缓存键问题 (添加 blog_id)
|
||||
- 中文阅读时间计算 (400字/分钟)
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0] - 2026-02-05
|
||||
|
||||
### ✨ GEO 优化模块 (Generative Engine Optimization)
|
||||
|
||||
面向 AI 搜索引擎的内容优化:
|
||||
|
||||
#### 核心功能
|
||||
- **Markdown Feed**: `/?feed=markdown` 端点,AI 友好的内容格式
|
||||
- **单篇 .md 支持**: 任意文章添加 `.md` 后缀获取 Markdown 版本
|
||||
- **Accept 内容协商**: `Accept: text/markdown` 自动返回 Markdown
|
||||
- **中文内容优化器**: 针对中文内容的 Markdown 优化
|
||||
- **GEO 信号注入**: 权威性声明、引用格式等 AI 引用信号
|
||||
- **AI 爬虫追踪**: 追踪 GPTBot、ClaudeBot 等 AI 爬虫访问
|
||||
|
||||
#### 📁 新增文件
|
||||
```
|
||||
includes/GEO/
|
||||
├── MarkdownFeed.php # Markdown Feed 端点
|
||||
├── HtmlToMarkdown.php # HTML 转 Markdown
|
||||
├── MarkdownEnhancer.php # Markdown 增强
|
||||
├── ChineseOptimizer.php # 中文优化
|
||||
├── GeoSignalInjector.php # GEO 信号注入
|
||||
└── CrawlerTracker.php # AI 爬虫追踪
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [2.5.0] - 2026-02-04
|
||||
|
||||
### ✨ 稳定性增强
|
||||
|
||||
#### 公共 API
|
||||
- **PublicAPI 类**: 统一的 AI 能力调用接口
|
||||
- **递归调用保护**: 防止无限循环
|
||||
- **便捷函数**: `wpmind_chat()`, `wpmind_translate()`, `wpmind_summarize()` 等
|
||||
- **图像生成 API**: 支持 8 个图像生成 Provider
|
||||
|
||||
#### UI 错误反馈优化
|
||||
- Toast 通知位置调整
|
||||
- 自动跳过不健康 Provider
|
||||
- 手动优先级设置
|
||||
|
||||
#### 安全修复 (Codex 审计)
|
||||
- 输入验证加强
|
||||
- ErrorHandler 加载顺序修复
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] - 2026-02-01
|
||||
|
||||
### ✨ 重大更新:Gutenberg 风格设计系统
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@
|
|||
CSS Variables - 设计令牌
|
||||
======================================== */
|
||||
:root {
|
||||
/* Primary Colors - WordPress Core Blue */
|
||||
--wpmind-primary: #2271b1;
|
||||
--wpmind-primary-hover: #135e96;
|
||||
--wpmind-primary-light: #f0f6fc;
|
||||
--wpmind-primary-dark: #0a4b78;
|
||||
/* Primary Colors - WPMind Brand Blue */
|
||||
--wpmind-primary: #3858e9;
|
||||
--wpmind-primary-hover: #2d48cc;
|
||||
--wpmind-primary-light: #eef1fd;
|
||||
--wpmind-primary-dark: #2035a8;
|
||||
|
||||
/* Gray Scale */
|
||||
--wpmind-gray-50: #f9fafb;
|
||||
|
|
@ -33,6 +33,7 @@
|
|||
--wpmind-warning: #d97706;
|
||||
--wpmind-warning-light: #fef3c7;
|
||||
--wpmind-error: #dc2626;
|
||||
--wpmind-error-dark: #b91c1c;
|
||||
--wpmind-error-light: #fee2e2;
|
||||
--wpmind-info: #0284c7;
|
||||
--wpmind-info-light: #e0f2fe;
|
||||
|
|
@ -44,15 +45,20 @@
|
|||
--wpmind-space-4: 16px;
|
||||
--wpmind-space-5: 20px;
|
||||
--wpmind-space-6: 24px;
|
||||
--wpmind-space-7: 28px;
|
||||
--wpmind-space-8: 32px;
|
||||
--wpmind-space-10: 40px;
|
||||
--wpmind-space-12: 48px;
|
||||
|
||||
/* Typography - 参考 block-visibility */
|
||||
--wpmind-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
--wpmind-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--wpmind-font-sans:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans,
|
||||
Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
--wpmind-font-mono:
|
||||
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
|
||||
--wpmind-text-xs: 11px;
|
||||
--wpmind-text-xs: 10px;
|
||||
--wpmind-text-sm: 12px;
|
||||
--wpmind-text-base: 13px;
|
||||
--wpmind-text-md: 14px;
|
||||
|
|
@ -188,10 +194,11 @@
|
|||
.wpmind-tab-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
box-shadow: var(--wpmind-shadow);
|
||||
padding: 0 var(--wpmind-space-6);
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--wpmind-gray-200);
|
||||
}
|
||||
|
||||
.wpmind-tab {
|
||||
|
|
@ -199,7 +206,7 @@
|
|||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-5);
|
||||
font-size: var(--wpmind-text-lg);
|
||||
font-size: var(--wpmind-text-md);
|
||||
font-weight: 400;
|
||||
color: var(--wpmind-gray-600);
|
||||
background: transparent;
|
||||
|
|
@ -231,15 +238,22 @@
|
|||
/* Tab 内容 */
|
||||
.wpmind-tab-pane {
|
||||
display: none;
|
||||
padding: var(--wpmind-space-6);
|
||||
padding: 0 var(--wpmind-space-6) var(--wpmind-space-6);
|
||||
}
|
||||
|
||||
.wpmind-tab-pane-active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wpmind-tab-pane> :first-child {
|
||||
margin-top: 0;
|
||||
/* Tab 页面 header 突破 tab-pane 左右 padding 贴边显示 */
|
||||
.wpmind-tab-pane .wpmind-module-header,
|
||||
.wpmind-tab-pane .wpmind-routing-header,
|
||||
.wpmind-tab-pane .wpmind-budget-header,
|
||||
.wpmind-tab-pane .wpmind-usage-header,
|
||||
.wpmind-tab-pane .wpmind-cost-control-header,
|
||||
.wpmind-tab-pane .wpmind-modules-header {
|
||||
margin-left: calc(-1 * var(--wpmind-space-6));
|
||||
margin-right: calc(-1 * var(--wpmind-space-6));
|
||||
}
|
||||
|
||||
/* Tab 内面板样式重置 */
|
||||
|
|
@ -248,7 +262,10 @@
|
|||
.wpmind-tab-pane .wpmind-analytics-panel,
|
||||
.wpmind-tab-pane .wpmind-status-panel,
|
||||
.wpmind-tab-pane .wpmind-routing-panel,
|
||||
.wpmind-tab-pane .wpmind-budget-panel {
|
||||
.wpmind-tab-pane .wpmind-budget-panel,
|
||||
.wpmind-tab-pane .wpmind-geo-panel,
|
||||
.wpmind-tab-pane .wpmind-media-panel,
|
||||
.wpmind-tab-pane .wpmind-auto-meta-panel {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
|
|
@ -261,10 +278,64 @@
|
|||
.wpmind-tab-pane .wpmind-analytics-panel:last-child,
|
||||
.wpmind-tab-pane .wpmind-status-panel:last-child,
|
||||
.wpmind-tab-pane .wpmind-routing-panel:last-child,
|
||||
.wpmind-tab-pane .wpmind-budget-panel:last-child {
|
||||
.wpmind-tab-pane .wpmind-budget-panel:last-child,
|
||||
.wpmind-tab-pane .wpmind-geo-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Tab-pane Title - 页面标题统一 14px */
|
||||
.wpmind-tab-pane .title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
font-size: var(--wpmind-text-md);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-900);
|
||||
}
|
||||
|
||||
.wpmind-tab-pane .title .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
/* Endpoint Card Description - 卡片描述 12px */
|
||||
.wpmind-endpoint-card .description {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Button Base - 按钮统一基础样式
|
||||
======================================== */
|
||||
|
||||
/* 所有 .button 统一 flex 对齐(修复 icon+text 错位) */
|
||||
.wpmind-tab-pane .button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
gap: var(--wpmind-space-1);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 常规按钮图标 16px */
|
||||
.wpmind-tab-pane .button .dashicons {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 小按钮图标 14px */
|
||||
.wpmind-tab-pane .button-small .dashicons {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Panel Component - 面板组件(block-visibility 风格)
|
||||
======================================== */
|
||||
|
|
@ -279,13 +350,13 @@
|
|||
align-items: center;
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-6);
|
||||
border-bottom: 1px solid #ccd0d4;
|
||||
border-bottom: 1px solid var(--wpmind-border-color);
|
||||
min-height: 54px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wpmind-panel-header .dashicons {
|
||||
color: var(--wpmind-gray-600);
|
||||
color: var(--wpmind-primary);
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
|
@ -293,8 +364,8 @@
|
|||
|
||||
.wpmind-panel-title {
|
||||
font-size: var(--wpmind-text-md);
|
||||
font-weight: 500;
|
||||
color: #1e1e1e;
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-900);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
|
@ -308,7 +379,7 @@
|
|||
align-items: center;
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-6);
|
||||
border-top: 1px solid #ccd0d4;
|
||||
border-top: 1px solid var(--wpmind-border-color);
|
||||
background: var(--wpmind-gray-50);
|
||||
}
|
||||
|
||||
|
|
@ -345,7 +416,7 @@
|
|||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-6);
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ccd0d4;
|
||||
border-bottom: 1px solid var(--wpmind-border-color);
|
||||
min-height: 54px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
|
@ -377,7 +448,9 @@
|
|||
|
||||
.wpmind-btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px #fff, 0 0 0 3px var(--wpmind-primary);
|
||||
box-shadow:
|
||||
0 0 0 1px #fff,
|
||||
0 0 0 3px var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-btn-primary {
|
||||
|
|
@ -393,15 +466,15 @@
|
|||
}
|
||||
|
||||
.wpmind-btn-secondary {
|
||||
background: #f6f7f7;
|
||||
color: #2271b1;
|
||||
border-color: #2271b1;
|
||||
background: var(--wpmind-gray-50);
|
||||
color: var(--wpmind-primary);
|
||||
border-color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-btn-secondary:hover {
|
||||
background: #f0f0f1;
|
||||
border-color: #0a4b78;
|
||||
color: #0a4b78;
|
||||
background: var(--wpmind-gray-100);
|
||||
border-color: var(--wpmind-primary-dark);
|
||||
color: var(--wpmind-primary-dark);
|
||||
}
|
||||
|
||||
.wpmind-btn-tertiary {
|
||||
|
|
@ -416,12 +489,12 @@
|
|||
|
||||
.wpmind-btn-danger {
|
||||
background: #fff;
|
||||
color: #b32d2e;
|
||||
border-color: #b32d2e;
|
||||
color: var(--wpmind-error);
|
||||
border-color: var(--wpmind-error);
|
||||
}
|
||||
|
||||
.wpmind-btn-danger:hover {
|
||||
background: #b32d2e;
|
||||
background: var(--wpmind-error);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
|
@ -498,7 +571,7 @@
|
|||
display: block;
|
||||
font-size: var(--wpmind-text-xs);
|
||||
font-weight: 500;
|
||||
color: #1e1e1e;
|
||||
color: var(--wpmind-gray-900);
|
||||
margin-bottom: var(--wpmind-space-3);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
|
@ -511,9 +584,9 @@
|
|||
min-height: 40px;
|
||||
font-size: var(--wpmind-text-base);
|
||||
line-height: 40px;
|
||||
color: #1e1e1e;
|
||||
color: var(--wpmind-gray-900);
|
||||
background: #fff;
|
||||
border: 1px solid #949494;
|
||||
border: 1px solid var(--wpmind-gray-400);
|
||||
border-radius: 2px;
|
||||
transition: all var(--wpmind-transition-fast);
|
||||
}
|
||||
|
|
@ -536,7 +609,7 @@
|
|||
.wpmind-form-help {
|
||||
margin-top: var(--wpmind-space-2);
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: #757575;
|
||||
color: var(--wpmind-gray-500);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
|
@ -559,7 +632,7 @@
|
|||
position: relative;
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
background: #949494;
|
||||
background: var(--wpmind-gray-400);
|
||||
border-radius: 9px;
|
||||
transition: background var(--wpmind-transition-fast);
|
||||
}
|
||||
|
|
@ -577,21 +650,23 @@
|
|||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.wpmind-toggle input:checked+.wpmind-toggle-slider {
|
||||
.wpmind-toggle input:checked + .wpmind-toggle-slider {
|
||||
background: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-toggle input:checked+.wpmind-toggle-slider::before {
|
||||
.wpmind-toggle input:checked + .wpmind-toggle-slider::before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.wpmind-toggle input:focus+.wpmind-toggle-slider {
|
||||
box-shadow: 0 0 0 1px #fff, 0 0 0 3px var(--wpmind-primary);
|
||||
.wpmind-toggle input:focus + .wpmind-toggle-slider {
|
||||
box-shadow:
|
||||
0 0 0 1px #fff,
|
||||
0 0 0 3px var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-toggle-label {
|
||||
font-size: var(--wpmind-text-base);
|
||||
color: #1e1e1e;
|
||||
color: var(--wpmind-gray-900);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
|
|
@ -646,7 +721,6 @@
|
|||
}
|
||||
|
||||
@keyframes wpmind-pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
|
|
@ -746,13 +820,13 @@
|
|||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-5);
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ccd0d4;
|
||||
border-bottom: 1px solid var(--wpmind-border-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wpmind-endpoint-header:hover {
|
||||
background: #f9f9f9;
|
||||
background: var(--wpmind-gray-50);
|
||||
}
|
||||
|
||||
.wpmind-endpoint-toggle {
|
||||
|
|
@ -772,7 +846,7 @@
|
|||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #757575;
|
||||
color: var(--wpmind-gray-500);
|
||||
transition: transform var(--wpmind-transition-fast);
|
||||
}
|
||||
|
||||
|
|
@ -789,9 +863,9 @@
|
|||
}
|
||||
|
||||
.wpmind-provider-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -800,16 +874,16 @@
|
|||
|
||||
.wpmind-endpoint-name {
|
||||
font-weight: 500;
|
||||
font-size: var(--wpmind-text-md);
|
||||
color: #1e1e1e;
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-900);
|
||||
}
|
||||
|
||||
.wpmind-endpoint-key {
|
||||
font-size: var(--wpmind-text-xs);
|
||||
background: #f0f0f1;
|
||||
background: var(--wpmind-gray-100);
|
||||
padding: 4px var(--wpmind-space-2);
|
||||
border-radius: 2px;
|
||||
color: #757575;
|
||||
color: var(--wpmind-gray-500);
|
||||
font-family: var(--wpmind-font-mono);
|
||||
}
|
||||
|
||||
|
|
@ -833,7 +907,8 @@
|
|||
|
||||
.wpmind-endpoint-card .form-table th {
|
||||
width: 100px;
|
||||
padding: var(--wpmind-space-3) var(--wpmind-space-3) var(--wpmind-space-3) var(--wpmind-space-4);
|
||||
padding: var(--wpmind-space-3) var(--wpmind-space-3) var(--wpmind-space-3)
|
||||
var(--wpmind-space-4);
|
||||
font-weight: 500;
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-600);
|
||||
|
|
@ -988,53 +1063,51 @@ input.is-invalid {
|
|||
Usage Panel - Token 用量统计(block-visibility 风格)
|
||||
======================================== */
|
||||
.wpmind-usage-panel {
|
||||
background: #fff;
|
||||
box-shadow: var(--wpmind-shadow);
|
||||
margin: var(--wpmind-space-6) 0;
|
||||
/* padding handled by .wpmind-tab-pane */
|
||||
}
|
||||
|
||||
.wpmind-usage-panel .title {
|
||||
margin: 0;
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-6);
|
||||
border-bottom: 1px solid #ccd0d4;
|
||||
.wpmind-usage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--wpmind-space-3);
|
||||
font-size: var(--wpmind-text-md);
|
||||
font-weight: 500;
|
||||
color: #1e1e1e;
|
||||
min-height: 54px;
|
||||
box-sizing: border-box;
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-5);
|
||||
border-bottom: 1px solid var(--wpmind-gray-200);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wpmind-usage-panel .title .button {
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
.wpmind-usage-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
font-size: var(--wpmind-text-base);
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
transition: all var(--wpmind-transition-fast);
|
||||
font-size: var(--wpmind-text-md);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-900);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wpmind-usage-panel .title .dashicons {
|
||||
.wpmind-usage-title .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-usage-desc {
|
||||
color: var(--wpmind-gray-600);
|
||||
margin: 0 0 var(--wpmind-space-6);
|
||||
}
|
||||
|
||||
.wpmind-last-updated {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
font-weight: 400;
|
||||
color: #757575;
|
||||
color: var(--wpmind-gray-500);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.wpmind-usage-note {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: #32373c;
|
||||
color: var(--wpmind-gray-700);
|
||||
line-height: var(--wpmind-leading-relaxed);
|
||||
margin: 0 0 var(--wpmind-space-5) 0;
|
||||
padding: var(--wpmind-space-3) var(--wpmind-space-4);
|
||||
|
|
@ -1045,7 +1118,7 @@ input.is-invalid {
|
|||
.wpmind-usage-empty {
|
||||
text-align: center;
|
||||
padding: var(--wpmind-space-10) var(--wpmind-space-6);
|
||||
color: #757575;
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
|
||||
.wpmind-usage-empty .dashicons {
|
||||
|
|
@ -1080,12 +1153,12 @@ input.is-invalid {
|
|||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: var(--wpmind-space-6);
|
||||
padding: var(--wpmind-space-6);
|
||||
}
|
||||
|
||||
.wpmind-usage-card {
|
||||
background: #fff;
|
||||
box-shadow: var(--wpmind-shadow);
|
||||
background: var(--wpmind-gray-50);
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: var(--wpmind-radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -1093,27 +1166,26 @@ input.is-invalid {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
background: #fff;
|
||||
color: #1e1e1e;
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-5);
|
||||
font-size: var(--wpmind-text-md);
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid #ccd0d4;
|
||||
color: var(--wpmind-gray-800);
|
||||
padding: var(--wpmind-space-3) var(--wpmind-space-4);
|
||||
font-size: var(--wpmind-text-base);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wpmind-usage-card-header .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #757575;
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-usage-card-body {
|
||||
padding: var(--wpmind-space-6);
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-5);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--wpmind-space-4);
|
||||
background: #fff;
|
||||
border-top: 1px solid var(--wpmind-gray-200);
|
||||
}
|
||||
|
||||
.wpmind-usage-stat {
|
||||
|
|
@ -1124,7 +1196,7 @@ input.is-invalid {
|
|||
.wpmind-usage-value {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #1e1e1e;
|
||||
color: var(--wpmind-gray-900);
|
||||
line-height: var(--wpmind-leading-tight);
|
||||
}
|
||||
|
||||
|
|
@ -1136,30 +1208,40 @@ input.is-invalid {
|
|||
display: block;
|
||||
font-size: var(--wpmind-text-xs);
|
||||
font-weight: 500;
|
||||
color: #757575;
|
||||
color: var(--wpmind-gray-500);
|
||||
margin-top: var(--wpmind-space-2);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Provider Usage Grid */
|
||||
.wpmind-usage-section-title {
|
||||
font-size: var(--wpmind-text-xs);
|
||||
font-weight: 500;
|
||||
color: #1e1e1e;
|
||||
margin: var(--wpmind-space-6) var(--wpmind-space-6) var(--wpmind-space-4);
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
font-size: var(--wpmind-text-base);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-800);
|
||||
margin: var(--wpmind-space-6) 0 var(--wpmind-space-4);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wpmind-usage-section-title .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-provider-usage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--wpmind-space-6);
|
||||
padding: 0 var(--wpmind-space-6) var(--wpmind-space-6);
|
||||
}
|
||||
|
||||
.wpmind-provider-usage-item {
|
||||
background: #fff;
|
||||
box-shadow: var(--wpmind-shadow);
|
||||
background: var(--wpmind-gray-50);
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: var(--wpmind-radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -1168,8 +1250,7 @@ input.is-invalid {
|
|||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
padding: var(--wpmind-space-3) var(--wpmind-space-4);
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ccd0d4;
|
||||
border-bottom: 1px solid var(--wpmind-gray-200);
|
||||
}
|
||||
|
||||
.wpmind-provider-usage-icon {
|
||||
|
|
@ -1180,7 +1261,7 @@ input.is-invalid {
|
|||
.wpmind-provider-usage-name {
|
||||
font-size: var(--wpmind-text-md);
|
||||
font-weight: 500;
|
||||
color: #1e1e1e;
|
||||
color: var(--wpmind-gray-900);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
|
@ -1197,6 +1278,7 @@ input.is-invalid {
|
|||
|
||||
.wpmind-provider-usage-body {
|
||||
padding: var(--wpmind-space-3);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wpmind-provider-usage-row {
|
||||
|
|
@ -1220,3 +1302,179 @@ input.is-invalid {
|
|||
font-weight: 600;
|
||||
color: var(--wpmind-gray-900);
|
||||
}
|
||||
|
||||
/* Manual Priority Section */
|
||||
.wpmind-routing-priority {
|
||||
margin-top: var(--wpmind-space-6);
|
||||
}
|
||||
|
||||
.wpmind-routing-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--wpmind-space-2);
|
||||
}
|
||||
|
||||
.wpmind-routing-priority-actions {
|
||||
display: flex;
|
||||
gap: var(--wpmind-space-2);
|
||||
}
|
||||
|
||||
.wpmind-priority-badge {
|
||||
display: inline-block;
|
||||
background: var(--wpmind-primary);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin-left: var(--wpmind-space-2);
|
||||
}
|
||||
|
||||
.wpmind-priority-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--wpmind-space-2);
|
||||
margin-top: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
.wpmind-priority-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-3) var(--wpmind-space-4);
|
||||
background: #fff;
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
cursor: move;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.wpmind-priority-item:hover {
|
||||
border-color: var(--wpmind-primary);
|
||||
box-shadow: var(--wpmind-shadow);
|
||||
}
|
||||
|
||||
.wpmind-priority-handle {
|
||||
color: var(--wpmind-gray-400);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.wpmind-priority-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.wpmind-priority-index {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--wpmind-gray-100);
|
||||
color: var(--wpmind-gray-600);
|
||||
font-size: var(--wpmind-text-sm);
|
||||
font-weight: 600;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.wpmind-priority-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: var(--wpmind-gray-900);
|
||||
}
|
||||
|
||||
.wpmind-priority-score {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
|
||||
.wpmind-priority-placeholder {
|
||||
height: 48px;
|
||||
background: var(--wpmind-primary-light);
|
||||
border: 2px dashed var(--wpmind-primary);
|
||||
margin: var(--wpmind-space-1) 0;
|
||||
}
|
||||
|
||||
.wpmind-priority-item.ui-sortable-helper {
|
||||
box-shadow: var(--wpmind-shadow-lg);
|
||||
border-color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
/* Toast Notification Styles */
|
||||
.wpmind-notice-container {
|
||||
margin: var(--wpmind-space-4) 0;
|
||||
}
|
||||
|
||||
.wpmind-notice {
|
||||
margin: 0 0 var(--wpmind-space-2) 0 !important;
|
||||
padding: var(--wpmind-space-3) var(--wpmind-space-4) !important;
|
||||
border-left-width: 4px !important;
|
||||
}
|
||||
|
||||
.wpmind-notice p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.wpmind-notice-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wpmind-notice.notice-success .wpmind-notice-icon {
|
||||
color: #00a32a;
|
||||
}
|
||||
|
||||
.wpmind-notice.notice-error .wpmind-notice-icon {
|
||||
color: #d63638;
|
||||
}
|
||||
|
||||
.wpmind-notice.notice-warning .wpmind-notice-icon {
|
||||
color: #dba617;
|
||||
}
|
||||
|
||||
.wpmind-notice.notice-info .wpmind-notice-icon {
|
||||
color: #72aee6;
|
||||
}
|
||||
|
||||
.wpmind-notice-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Test Result Styles */
|
||||
.wpmind-test-result {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-1);
|
||||
font-size: var(--wpmind-text-sm);
|
||||
padding: var(--wpmind-space-1) var(--wpmind-space-2);
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.wpmind-test-result.success {
|
||||
color: #00a32a;
|
||||
background: rgba(0, 163, 42, 0.1);
|
||||
}
|
||||
|
||||
.wpmind-test-result.error {
|
||||
color: #d63638;
|
||||
background: rgba(214, 54, 56, 0.1);
|
||||
}
|
||||
|
||||
.wpmind-test-result.warning {
|
||||
color: #dba617;
|
||||
background: rgba(219, 166, 23, 0.1);
|
||||
}
|
||||
|
||||
.wpmind-test-result .dashicons {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Module shared components (header, badge, stats) moved to components/module-layout.css */
|
||||
|
|
|
|||
249
assets/css/components/module-layout.css
Normal file
249
assets/css/components/module-layout.css
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
/**
|
||||
* WPMind Shared Module Layout Components
|
||||
*
|
||||
* Reusable UI components for all module settings pages:
|
||||
* header, badge, stats, subtabs, options, actions.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.12.0
|
||||
*/
|
||||
|
||||
/* ========================================
|
||||
Module Header
|
||||
======================================== */
|
||||
.wpmind-module-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-5);
|
||||
border-bottom: 1px solid var(--wpmind-gray-200);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wpmind-module-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
font-size: var(--wpmind-text-md);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-900);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wpmind-module-title .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-module-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--wpmind-primary);
|
||||
background: var(--wpmind-primary-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wpmind-module-desc {
|
||||
color: var(--wpmind-gray-600);
|
||||
margin: 0 0 var(--wpmind-space-6);
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-5) 0;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Stat Cards
|
||||
======================================== */
|
||||
.wpmind-module-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--wpmind-space-4);
|
||||
margin-bottom: var(--wpmind-space-6);
|
||||
}
|
||||
|
||||
.wpmind-stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-3);
|
||||
background: #fff;
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wpmind-stat-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--wpmind-gray-100);
|
||||
border-radius: 6px;
|
||||
color: var(--wpmind-gray-600);
|
||||
}
|
||||
|
||||
.wpmind-stat-icon .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.wpmind-stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wpmind-stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--wpmind-gray-900);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.wpmind-stat-value small {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
|
||||
.wpmind-stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.wpmind-module-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Sub-tab Navigation
|
||||
======================================== */
|
||||
.wpmind-module-subtabs {
|
||||
display: flex;
|
||||
gap: var(--wpmind-space-2);
|
||||
margin-bottom: var(--wpmind-space-6);
|
||||
border-bottom: 2px solid var(--wpmind-gray-200);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.wpmind-module-subtab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--wpmind-gray-600);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.wpmind-module-subtab:hover {
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-module-subtab.active {
|
||||
color: var(--wpmind-primary);
|
||||
border-bottom-color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-module-subtab .dashicons {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Tab panels */
|
||||
.wpmind-module-tab-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wpmind-module-tab-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Options & Actions
|
||||
======================================== */
|
||||
.wpmind-module-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--wpmind-space-3);
|
||||
}
|
||||
|
||||
.wpmind-module-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-3);
|
||||
background: white;
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: var(--wpmind-radius-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.wpmind-module-option:hover {
|
||||
border-color: var(--wpmind-primary);
|
||||
background: var(--wpmind-primary-light);
|
||||
}
|
||||
|
||||
.wpmind-module-option input[type="checkbox"] {
|
||||
margin: 2px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wpmind-module-option-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wpmind-module-option-title {
|
||||
font-weight: 500;
|
||||
color: var(--wpmind-gray-800);
|
||||
}
|
||||
|
||||
.wpmind-module-option-desc {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
|
||||
.wpmind-module-option-text strong {
|
||||
display: block;
|
||||
color: var(--wpmind-gray-800);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.wpmind-module-option-text p {
|
||||
margin: 0;
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
|
||||
.wpmind-module-actions {
|
||||
margin-top: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
.wpmind-module-actions .button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
}
|
||||
|
||||
.wpmind-module-actions .dashicons {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
233
assets/css/modules.css
Normal file
233
assets/css/modules.css
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
/* WPMind modules panel styles */
|
||||
.wpmind-modules-container {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.wpmind-modules-header {
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-5);
|
||||
border-bottom: 1px solid var(--wpmind-gray-200);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wpmind-modules-header h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
margin: 0;
|
||||
font-size: var(--wpmind-text-md);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-900);
|
||||
}
|
||||
|
||||
.wpmind-modules-header h2 .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-modules-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--wpmind-space-5);
|
||||
}
|
||||
|
||||
.wpmind-module-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: var(--wpmind-radius-lg);
|
||||
overflow: hidden;
|
||||
transition: all var(--wpmind-transition-normal);
|
||||
}
|
||||
|
||||
.wpmind-module-card:hover {
|
||||
box-shadow: var(--wpmind-shadow-md);
|
||||
}
|
||||
|
||||
.wpmind-module-card.is-disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.wpmind-module-card .wpmind-module-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--wpmind-space-4);
|
||||
border-bottom: 1px solid var(--wpmind-gray-100);
|
||||
gap: var(--wpmind-space-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wpmind-module-icon {
|
||||
font-size: 24px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--wpmind-gray-100);
|
||||
border-radius: var(--wpmind-radius-lg);
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
|
||||
.wpmind-module-card.is-enabled .wpmind-module-icon {
|
||||
background: var(--wpmind-success-light);
|
||||
color: var(--wpmind-success);
|
||||
}
|
||||
|
||||
.wpmind-module-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wpmind-module-name {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wpmind-module-version {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-400);
|
||||
}
|
||||
|
||||
.wpmind-module-body {
|
||||
padding: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
.wpmind-module-description {
|
||||
margin: 0;
|
||||
color: var(--wpmind-gray-500);
|
||||
font-size: var(--wpmind-text-base);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.wpmind-module-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--wpmind-space-3) var(--wpmind-space-4);
|
||||
background: var(--wpmind-gray-50);
|
||||
border-top: 1px solid var(--wpmind-gray-100);
|
||||
}
|
||||
|
||||
.wpmind-module-status {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
}
|
||||
|
||||
.status-enabled {
|
||||
color: var(--wpmind-success);
|
||||
}
|
||||
|
||||
.status-disabled {
|
||||
color: var(--wpmind-gray-400);
|
||||
}
|
||||
|
||||
.wpmind-module-settings-link {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
text-decoration: none;
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-module-settings-link:hover {
|
||||
color: var(--wpmind-primary-hover);
|
||||
}
|
||||
|
||||
/* Switch styles */
|
||||
.wpmind-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.wpmind-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.wpmind-switch-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--wpmind-gray-300);
|
||||
transition: all var(--wpmind-transition-slow);
|
||||
border-radius: var(--wpmind-radius-full);
|
||||
}
|
||||
|
||||
.wpmind-switch-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: all var(--wpmind-transition-slow);
|
||||
border-radius: var(--wpmind-radius-full);
|
||||
}
|
||||
|
||||
.wpmind-switch input:checked + .wpmind-switch-slider {
|
||||
background-color: var(--wpmind-success);
|
||||
}
|
||||
|
||||
.wpmind-switch input:checked + .wpmind-switch-slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.wpmind-switch input:disabled + .wpmind-switch-slider {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Core module badge */
|
||||
.wpmind-module-badge-core {
|
||||
display: inline-block;
|
||||
font-size: var(--wpmind-text-xs);
|
||||
font-weight: 500;
|
||||
padding: 1px var(--wpmind-space-2);
|
||||
margin-left: var(--wpmind-space-2);
|
||||
background: var(--wpmind-info-light);
|
||||
color: var(--wpmind-info);
|
||||
border-radius: var(--wpmind-radius-md);
|
||||
vertical-align: middle;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Lock icon for non-disableable modules */
|
||||
.wpmind-toggle-locked {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
color: var(--wpmind-gray-400);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.wpmind-toggle-locked .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Core module status */
|
||||
.status-core {
|
||||
color: var(--wpmind-info);
|
||||
}
|
||||
|
||||
.wpmind-no-modules {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: var(--wpmind-space-12) var(--wpmind-space-5);
|
||||
color: var(--wpmind-gray-400);
|
||||
}
|
||||
|
||||
.wpmind-no-modules .dashicons {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: var(--wpmind-space-4);
|
||||
}
|
||||
510
assets/css/overview.css
Normal file
510
assets/css/overview.css
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
/* WPMind Overview Tab — Refined single-accent design */
|
||||
|
||||
.wpmind-overview {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
/* Hero — card style */
|
||||
.wpmind-overview-hero {
|
||||
background: #fff;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--wpmind-gray-200);
|
||||
padding: var(--wpmind-space-8) var(--wpmind-space-8);
|
||||
margin-bottom: var(--wpmind-space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wpmind-overview-hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.035;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60'%3E%3Ccircle cx='30' cy='30' r='1.5' fill='%233858e9'/%3E%3Ccircle cx='0' cy='0' r='1' fill='%233858e9'/%3E%3Ccircle cx='60' cy='0' r='1' fill='%233858e9'/%3E%3Ccircle cx='0' cy='60' r='1' fill='%233858e9'/%3E%3Ccircle cx='60' cy='60' r='1' fill='%233858e9'/%3E%3Cpath d='M30 0v12M30 48v12M0 30h12M48 30h12' stroke='%233858e9' stroke-width='0.5' fill='none'/%3E%3C/svg%3E");
|
||||
background-size: 60px 60px;
|
||||
background-repeat: repeat;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wpmind-overview-hero-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
.wpmind-overview-hero-icon .dashicons {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--wpmind-gray-900);
|
||||
}
|
||||
|
||||
.wpmind-overview-hero-title {
|
||||
margin: 0 0 var(--wpmind-space-2) 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--wpmind-gray-900);
|
||||
}
|
||||
|
||||
.wpmind-overview-hero-subtitle {
|
||||
margin: 0 0 var(--wpmind-space-3) 0;
|
||||
font-size: var(--wpmind-text-base);
|
||||
color: var(--wpmind-gray-600);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.wpmind-overview-hero-meta {
|
||||
margin: 0 0 var(--wpmind-space-6) 0;
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-400);
|
||||
}
|
||||
|
||||
.wpmind-overview-hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--wpmind-space-3);
|
||||
}
|
||||
|
||||
.wpmind-overview-hero-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
border-radius: 2px;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
transition: box-shadow 0.1s linear;
|
||||
}
|
||||
|
||||
.wpmind-overview-hero-btn--primary {
|
||||
background: var(--wpmind-primary);
|
||||
color: #fff;
|
||||
border: 1px solid var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-overview-hero-btn--primary:hover {
|
||||
background: var(--wpmind-primary-hover);
|
||||
border-color: var(--wpmind-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wpmind-overview-hero-btn--secondary {
|
||||
background: transparent;
|
||||
color: var(--wpmind-primary);
|
||||
border: 1px solid var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-overview-hero-btn--secondary:hover {
|
||||
background: var(--wpmind-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Stat cards row */
|
||||
.wpmind-overview-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--wpmind-space-4);
|
||||
margin-bottom: var(--wpmind-space-6);
|
||||
}
|
||||
|
||||
.wpmind-overview-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-3);
|
||||
background: var(--wpmind-gray-50, #f9fafb);
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-5);
|
||||
}
|
||||
|
||||
.wpmind-overview-stat-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-overview-stat-icon .dashicons {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.wpmind-overview-stat-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wpmind-overview-stat-value {
|
||||
font-size: 1.25em;
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-900);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.wpmind-overview-stat-sub {
|
||||
font-size: 0.6em;
|
||||
font-weight: 400;
|
||||
color: var(--wpmind-gray-400);
|
||||
}
|
||||
|
||||
.wpmind-overview-stat-label {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Two-column grid */
|
||||
.wpmind-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--wpmind-space-5);
|
||||
margin-bottom: var(--wpmind-space-5);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.wpmind-overview-card {
|
||||
background: var(--wpmind-gray-50, #f9fafb);
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wpmind-overview-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-5);
|
||||
border-bottom: 1px solid var(--wpmind-gray-200);
|
||||
}
|
||||
|
||||
.wpmind-overview-card-header h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
margin: 0;
|
||||
font-size: var(--wpmind-text-base);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-800);
|
||||
}
|
||||
|
||||
.wpmind-overview-card-header h3 .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-overview-card-link {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wpmind-overview-card-link:hover {
|
||||
color: var(--wpmind-primary-hover);
|
||||
}
|
||||
|
||||
.wpmind-overview-card-body {
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-5);
|
||||
}
|
||||
|
||||
/* Provider list */
|
||||
.wpmind-overview-provider-list,
|
||||
.wpmind-overview-module-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--wpmind-space-2);
|
||||
}
|
||||
|
||||
.wpmind-overview-provider-item,
|
||||
.wpmind-overview-module-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-2) 0;
|
||||
}
|
||||
|
||||
.wpmind-overview-provider-item .dashicons,
|
||||
.wpmind-overview-module-item .dashicons {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wpmind-overview-provider-name,
|
||||
.wpmind-overview-module-name {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-700);
|
||||
}
|
||||
|
||||
.wpmind-overview-badge-core {
|
||||
display: inline-block;
|
||||
font-size: 0.75em;
|
||||
padding: 0 5px;
|
||||
margin-left: 4px;
|
||||
background: var(--wpmind-info-light);
|
||||
color: var(--wpmind-info);
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Empty state (guided) */
|
||||
.wpmind-overview-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--wpmind-space-6) var(--wpmind-space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wpmind-overview-empty-icon {
|
||||
font-size: 40px !important;
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
color: var(--wpmind-gray-300);
|
||||
margin-bottom: var(--wpmind-space-3);
|
||||
}
|
||||
|
||||
.wpmind-overview-empty-text {
|
||||
margin: 0 0 var(--wpmind-space-2) 0;
|
||||
font-size: var(--wpmind-text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
|
||||
.wpmind-overview-empty-hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--wpmind-gray-400);
|
||||
}
|
||||
|
||||
.wpmind-overview-empty-action {
|
||||
display: inline-block;
|
||||
font-size: var(--wpmind-text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--wpmind-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wpmind-overview-empty-action:hover {
|
||||
color: var(--wpmind-primary-hover);
|
||||
}
|
||||
|
||||
/* Summary grid (value card — 2x2) */
|
||||
.wpmind-overview-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--wpmind-space-3);
|
||||
}
|
||||
|
||||
.wpmind-overview-summary-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-3);
|
||||
background: #fff;
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.wpmind-overview-summary-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-overview-summary-icon .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.wpmind-overview-summary-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wpmind-overview-summary-value {
|
||||
font-size: var(--wpmind-text-base);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-900);
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wpmind-overview-summary-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--wpmind-gray-500);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Activity list (recent activity card) */
|
||||
.wpmind-overview-activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wpmind-overview-activity-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-3) var(--wpmind-space-2);
|
||||
border-bottom: 1px solid var(--wpmind-gray-100);
|
||||
font-size: var(--wpmind-text-sm);
|
||||
border-radius: 4px;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.wpmind-overview-activity-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.wpmind-overview-activity-item:hover {
|
||||
background: var(--wpmind-gray-50, #f9fafb);
|
||||
}
|
||||
|
||||
.wpmind-overview-activity-time {
|
||||
color: var(--wpmind-gray-400);
|
||||
flex-shrink: 0;
|
||||
min-width: 52px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.wpmind-overview-activity-provider {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--wpmind-gray-700);
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wpmind-overview-activity-provider .dashicons {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--wpmind-gray-400);
|
||||
}
|
||||
|
||||
.wpmind-overview-activity-model {
|
||||
color: var(--wpmind-gray-500);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wpmind-overview-activity-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.wpmind-overview-activity-tokens {
|
||||
color: var(--wpmind-gray-600);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wpmind-overview-activity-latency {
|
||||
color: var(--wpmind-gray-400);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Link list inside cards */
|
||||
.wpmind-overview-link-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wpmind-overview-link-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
padding: var(--wpmind-space-3) var(--wpmind-space-2);
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-primary);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--wpmind-gray-100);
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.wpmind-overview-link-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.wpmind-overview-link-item:hover {
|
||||
background: var(--wpmind-gray-50, #f9fafb);
|
||||
color: var(--wpmind-primary-hover);
|
||||
}
|
||||
|
||||
.wpmind-overview-link-item .dashicons {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--wpmind-gray-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Footer version */
|
||||
.wpmind-overview-footer {
|
||||
text-align: center;
|
||||
padding: var(--wpmind-space-2) 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--wpmind-gray-400);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
.wpmind-overview-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.wpmind-overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.wpmind-overview-activity-latency {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.wpmind-overview-hero {
|
||||
padding: var(--wpmind-space-6);
|
||||
}
|
||||
.wpmind-overview-hero-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
.wpmind-overview-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.wpmind-overview-summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
383
assets/css/pages/api-gateway.css
Normal file
383
assets/css/pages/api-gateway.css
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
/**
|
||||
* WPMind API Gateway Module Styles
|
||||
*
|
||||
* API Gateway-specific UI components: subtabs, keys table,
|
||||
* audit log, docs panel, code blocks.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.6.0
|
||||
*/
|
||||
|
||||
/* Stats grid */
|
||||
.wpmind-gw-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--wpmind-space-4);
|
||||
margin-bottom: var(--wpmind-space-6);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.wpmind-gw-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sub-tab navigation */
|
||||
.wpmind-gw-subtabs {
|
||||
display: flex;
|
||||
gap: var(--wpmind-space-2);
|
||||
margin-bottom: var(--wpmind-space-6);
|
||||
border-bottom: 2px solid var(--wpmind-gray-200);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.wpmind-gw-subtab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--wpmind-gray-600);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.wpmind-gw-subtab:hover {
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-gw-subtab.active {
|
||||
color: var(--wpmind-primary);
|
||||
border-bottom-color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-gw-subtab .dashicons {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Tab panels */
|
||||
.wpmind-gw-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wpmind-gw-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Settings form */
|
||||
.wpmind-gw-form-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.wpmind-gw-form-table th {
|
||||
text-align: left;
|
||||
padding: var(--wpmind-space-3) var(--wpmind-space-4);
|
||||
font-weight: 500;
|
||||
font-size: var(--wpmind-text-base);
|
||||
color: var(--wpmind-gray-700);
|
||||
width: 200px;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--wpmind-gray-100);
|
||||
}
|
||||
|
||||
.wpmind-gw-form-table td {
|
||||
padding: var(--wpmind-space-3) var(--wpmind-space-4);
|
||||
border-bottom: 1px solid var(--wpmind-gray-100);
|
||||
}
|
||||
|
||||
.wpmind-gw-form-table input[type="number"],
|
||||
.wpmind-gw-form-table input[type="text"],
|
||||
.wpmind-gw-form-table input[type="date"] {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.wpmind-gw-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: var(--wpmind-space-2) var(--wpmind-space-4);
|
||||
font-size: var(--wpmind-text-base);
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: var(--wpmind-primary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.wpmind-gw-btn:hover {
|
||||
background: var(--wpmind-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wpmind-gw-btn-secondary {
|
||||
background: var(--wpmind-gray-500);
|
||||
}
|
||||
|
||||
.wpmind-gw-btn-secondary:hover {
|
||||
background: var(--wpmind-gray-600);
|
||||
}
|
||||
|
||||
.wpmind-gw-btn-danger {
|
||||
background: var(--wpmind-error);
|
||||
}
|
||||
|
||||
.wpmind-gw-btn-danger:hover {
|
||||
background: var(--wpmind-error-dark);
|
||||
}
|
||||
|
||||
.wpmind-gw-btn-sm {
|
||||
padding: var(--wpmind-space-1) var(--wpmind-space-3);
|
||||
font-size: var(--wpmind-text-sm);
|
||||
}
|
||||
|
||||
/* Create form panel */
|
||||
.wpmind-gw-create-panel {
|
||||
background: var(--wpmind-gray-50);
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
padding: var(--wpmind-space-4);
|
||||
margin-bottom: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
.wpmind-gw-create-panel h4 {
|
||||
margin: 0 0 var(--wpmind-space-3);
|
||||
font-size: var(--wpmind-text-md);
|
||||
color: var(--wpmind-gray-800);
|
||||
}
|
||||
|
||||
/* New key success box */
|
||||
.wpmind-gw-key-success {
|
||||
background: var(--wpmind-success-light);
|
||||
border: 1px solid var(--wpmind-success);
|
||||
padding: var(--wpmind-space-4);
|
||||
margin-bottom: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
.wpmind-gw-key-success strong {
|
||||
display: block;
|
||||
margin-bottom: var(--wpmind-space-2);
|
||||
color: var(--wpmind-gray-800);
|
||||
}
|
||||
|
||||
.wpmind-gw-key-display {
|
||||
display: flex;
|
||||
gap: var(--wpmind-space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wpmind-gw-key-display code {
|
||||
flex: 1;
|
||||
padding: var(--wpmind-space-2) var(--wpmind-space-3);
|
||||
background: #fff;
|
||||
border: 1px solid var(--wpmind-gray-300);
|
||||
font-family: var(--wpmind-font-mono);
|
||||
font-size: var(--wpmind-text-base);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Keys table */
|
||||
.wpmind-gw-keys-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: var(--wpmind-space-3);
|
||||
}
|
||||
|
||||
.wpmind-gw-keys-table th {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-600);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wpmind-gw-keys-table code {
|
||||
font-family: var(--wpmind-font-mono);
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-700);
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.wpmind-gw-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
font-size: var(--wpmind-text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.wpmind-gw-badge-active {
|
||||
color: var(--wpmind-success);
|
||||
background: var(--wpmind-success-light);
|
||||
}
|
||||
|
||||
.wpmind-gw-badge-revoked {
|
||||
color: var(--wpmind-error);
|
||||
background: var(--wpmind-error-light);
|
||||
}
|
||||
|
||||
/* Inline edit panel */
|
||||
.wpmind-gw-edit-row td {
|
||||
padding: 0 !important;
|
||||
border-bottom: 1px solid var(--wpmind-gray-200);
|
||||
}
|
||||
|
||||
.wpmind-gw-edit-panel {
|
||||
background: var(--wpmind-gray-50);
|
||||
border-top: 1px solid var(--wpmind-gray-200);
|
||||
padding: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
.wpmind-gw-edit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--wpmind-space-3);
|
||||
margin-bottom: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.wpmind-gw-edit-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.wpmind-gw-edit-field label {
|
||||
display: block;
|
||||
font-size: var(--wpmind-text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--wpmind-gray-600);
|
||||
margin-bottom: var(--wpmind-space-1);
|
||||
}
|
||||
|
||||
.wpmind-gw-edit-field input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wpmind-gw-edit-actions {
|
||||
display: flex;
|
||||
gap: var(--wpmind-space-2);
|
||||
}
|
||||
|
||||
/* Audit log table */
|
||||
.wpmind-gw-log-filters {
|
||||
display: flex;
|
||||
gap: var(--wpmind-space-3);
|
||||
align-items: flex-end;
|
||||
margin-bottom: var(--wpmind-space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wpmind-gw-filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--wpmind-space-1);
|
||||
}
|
||||
|
||||
.wpmind-gw-filter-group label {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--wpmind-gray-600);
|
||||
}
|
||||
|
||||
.wpmind-gw-filter-group select,
|
||||
.wpmind-gw-filter-group input {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.wpmind-gw-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--wpmind-space-3) 0;
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
|
||||
.wpmind-gw-pagination-btns {
|
||||
display: flex;
|
||||
gap: var(--wpmind-space-2);
|
||||
}
|
||||
|
||||
/* API Docs panel */
|
||||
.wpmind-gw-docs-section {
|
||||
margin-bottom: var(--wpmind-space-6);
|
||||
}
|
||||
|
||||
.wpmind-gw-docs-section h4 {
|
||||
font-size: var(--wpmind-text-md);
|
||||
color: var(--wpmind-gray-800);
|
||||
margin: 0 0 var(--wpmind-space-3);
|
||||
}
|
||||
|
||||
.wpmind-gw-endpoint-list {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.wpmind-gw-endpoint-list th {
|
||||
text-align: left;
|
||||
font-size: var(--wpmind-text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-600);
|
||||
}
|
||||
|
||||
.wpmind-gw-endpoint-list td {
|
||||
font-size: var(--wpmind-text-base);
|
||||
}
|
||||
|
||||
.wpmind-gw-endpoint-list code {
|
||||
font-family: var(--wpmind-font-mono);
|
||||
font-size: var(--wpmind-text-sm);
|
||||
}
|
||||
|
||||
.wpmind-gw-code-block {
|
||||
position: relative;
|
||||
background: var(--wpmind-gray-900);
|
||||
color: var(--wpmind-gray-100);
|
||||
padding: var(--wpmind-space-4);
|
||||
overflow-x: auto;
|
||||
font-family: var(--wpmind-font-mono);
|
||||
font-size: var(--wpmind-text-sm);
|
||||
line-height: var(--wpmind-leading-relaxed);
|
||||
}
|
||||
|
||||
.wpmind-gw-code-block pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.wpmind-gw-copy-btn {
|
||||
position: absolute;
|
||||
top: var(--wpmind-space-2);
|
||||
right: var(--wpmind-space-2);
|
||||
padding: var(--wpmind-space-1) var(--wpmind-space-2);
|
||||
font-size: var(--wpmind-text-xs);
|
||||
color: var(--wpmind-gray-400);
|
||||
background: var(--wpmind-gray-800);
|
||||
border: 1px solid var(--wpmind-gray-700);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.wpmind-gw-copy-btn:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Save message */
|
||||
.wpmind-gw-save-msg {
|
||||
margin-left: var(--wpmind-space-3);
|
||||
color: var(--wpmind-success);
|
||||
font-size: var(--wpmind-text-base);
|
||||
}
|
||||
85
assets/css/pages/auto-meta.css
Normal file
85
assets/css/pages/auto-meta.css
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* WPMind Auto-Meta Module Styles
|
||||
*
|
||||
* Auto-Meta-specific UI components: post type checkboxes, manual form, result table.
|
||||
* Shared components (header, stats, subtabs, options) in components/module-layout.css.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.11.0
|
||||
*/
|
||||
|
||||
/* Badge alignment */
|
||||
.wpmind-auto-meta-panel .wpmind-module-badge {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Post types row */
|
||||
.wpmind-am-post-types-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--wpmind-space-2);
|
||||
padding: var(--wpmind-space-3) 0;
|
||||
}
|
||||
|
||||
.wpmind-am-post-types {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--wpmind-space-3);
|
||||
}
|
||||
|
||||
.wpmind-am-type-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-1);
|
||||
font-size: var(--wpmind-text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Manual generate form */
|
||||
.wpmind-am-manual-info {
|
||||
color: var(--wpmind-gray-600);
|
||||
margin: 0 0 var(--wpmind-space-3);
|
||||
}
|
||||
|
||||
.wpmind-am-manual-form {
|
||||
display: flex;
|
||||
gap: var(--wpmind-space-2);
|
||||
align-items: center;
|
||||
margin-bottom: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
.wpmind-am-manual-form input[type="number"] {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
/* Result table */
|
||||
.wpmind-am-result h4 {
|
||||
margin: 0 0 var(--wpmind-space-2);
|
||||
font-size: var(--wpmind-text-base);
|
||||
}
|
||||
|
||||
.wpmind-am-result-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.wpmind-am-result-table th,
|
||||
.wpmind-am-result-table td {
|
||||
padding: var(--wpmind-space-2) var(--wpmind-space-3);
|
||||
border-bottom: 1px solid var(--wpmind-gray-200);
|
||||
text-align: left;
|
||||
font-size: var(--wpmind-text-sm);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.wpmind-am-result-table th {
|
||||
width: 100px;
|
||||
color: var(--wpmind-gray-600);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wpmind-am-result-table td {
|
||||
color: var(--wpmind-gray-800);
|
||||
line-height: 1.5;
|
||||
}
|
||||
43
assets/css/pages/exact-cache.css
Normal file
43
assets/css/pages/exact-cache.css
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* WPMind Exact Cache Module Styles
|
||||
*
|
||||
* Exact Cache-specific UI components: trend chart, cache panel badge.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.6.0
|
||||
*/
|
||||
|
||||
/* === Exact Cache Module === */
|
||||
.wpmind-cache-panel .wpmind-module-badge {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.wpmind-cache-trend-chart {
|
||||
margin-bottom: var(--wpmind-space-6);
|
||||
}
|
||||
|
||||
.wpmind-cache-trend-chart h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
font-size: var(--wpmind-text-md);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-900);
|
||||
margin: 0 0 var(--wpmind-space-3);
|
||||
}
|
||||
|
||||
.wpmind-cache-trend-chart h3 .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-cache-trend-chart .wpmind-chart-wrapper {
|
||||
position: relative;
|
||||
height: 260px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: 4px;
|
||||
padding: var(--wpmind-space-3);
|
||||
}
|
||||
285
assets/css/pages/geo.css
Normal file
285
assets/css/pages/geo.css
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
/**
|
||||
* WPMind GEO Module Styles
|
||||
*
|
||||
* GEO-specific UI components: sections, crawlers, notices, grid layout.
|
||||
* Shared components (header, stats, subtabs, options) in components/module-layout.css.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.6.0
|
||||
*/
|
||||
|
||||
/* Full-width grid when sidebar is hidden */
|
||||
.wpmind-geo-grid-full {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
/* NEW badge */
|
||||
.wpmind-geo-new-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--wpmind-primary);
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Stats responsive moved to components/module-layout.css */
|
||||
|
||||
.wpmind-geo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--wpmind-space-6);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.wpmind-geo-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.wpmind-geo-section {
|
||||
background: var(--wpmind-gray-50);
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: var(--wpmind-radius-lg);
|
||||
padding: var(--wpmind-space-5);
|
||||
margin-bottom: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
.wpmind-geo-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
font-size: var(--wpmind-text-base);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-800);
|
||||
margin: 0 0 var(--wpmind-space-2);
|
||||
}
|
||||
|
||||
.wpmind-geo-section-title .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-geo-section-desc {
|
||||
color: var(--wpmind-gray-600);
|
||||
font-size: var(--wpmind-text-sm);
|
||||
margin: 0 0 var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
.wpmind-geo-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--wpmind-space-2);
|
||||
padding: var(--wpmind-space-3);
|
||||
border-radius: var(--wpmind-radius-md);
|
||||
font-size: var(--wpmind-text-sm);
|
||||
margin-bottom: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
.wpmind-geo-notice-info {
|
||||
background: var(--wpmind-info-light);
|
||||
color: var(--wpmind-info);
|
||||
}
|
||||
|
||||
.wpmind-geo-notice .dashicons {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.wpmind-geo-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--wpmind-space-3);
|
||||
}
|
||||
|
||||
/* Options and actions styles moved to components/module-layout.css as .wpmind-module-option */
|
||||
|
||||
.wpmind-geo-select-group {
|
||||
margin-top: var(--wpmind-space-4);
|
||||
padding: var(--wpmind-space-3);
|
||||
background: white;
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: var(--wpmind-radius-md);
|
||||
}
|
||||
/* Options and actions moved to components/module-layout.css */
|
||||
|
||||
.wpmind-geo-urls {
|
||||
margin-top: var(--wpmind-space-4);
|
||||
padding: var(--wpmind-space-3);
|
||||
background: white;
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: var(--wpmind-radius-md);
|
||||
}
|
||||
|
||||
.wpmind-geo-url-title {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--wpmind-gray-700);
|
||||
margin: 0 0 var(--wpmind-space-2);
|
||||
}
|
||||
|
||||
.wpmind-geo-url {
|
||||
display: block;
|
||||
padding: var(--wpmind-space-2);
|
||||
background: var(--wpmind-gray-100);
|
||||
border-radius: var(--wpmind-radius-sm);
|
||||
font-size: var(--wpmind-text-xs);
|
||||
color: var(--wpmind-gray-700);
|
||||
margin-bottom: var(--wpmind-space-2);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.wpmind-geo-url:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Actions styles moved to components/module-layout.css as .wpmind-module-actions */
|
||||
|
||||
/* Crawler List */
|
||||
.wpmind-crawler-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--wpmind-space-2);
|
||||
}
|
||||
|
||||
.wpmind-crawler-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--wpmind-space-3);
|
||||
background: white;
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: var(--wpmind-radius-md);
|
||||
}
|
||||
|
||||
.wpmind-crawler-item.is-ai {
|
||||
border-left: 3px solid var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-crawler-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
}
|
||||
|
||||
.wpmind-crawler-name {
|
||||
font-weight: 500;
|
||||
color: var(--wpmind-gray-800);
|
||||
}
|
||||
|
||||
.wpmind-crawler-company {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
|
||||
.wpmind-crawler-badge {
|
||||
display: inline-flex;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: var(--wpmind-primary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.wpmind-crawler-stats {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--wpmind-space-1);
|
||||
}
|
||||
|
||||
.wpmind-crawler-hits {
|
||||
font-size: var(--wpmind-text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-800);
|
||||
}
|
||||
|
||||
.wpmind-crawler-label {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.wpmind-geo-empty {
|
||||
text-align: center;
|
||||
padding: var(--wpmind-space-8) var(--wpmind-space-4);
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
|
||||
.wpmind-geo-empty .dashicons {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--wpmind-gray-300);
|
||||
margin-bottom: var(--wpmind-space-3);
|
||||
}
|
||||
|
||||
.wpmind-geo-empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wpmind-geo-empty-hint {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
margin-top: var(--wpmind-space-2) !important;
|
||||
}
|
||||
|
||||
/* GEO Info */
|
||||
.wpmind-geo-info-content {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-600);
|
||||
}
|
||||
|
||||
.wpmind-geo-info-content p {
|
||||
margin: 0 0 var(--wpmind-space-3);
|
||||
}
|
||||
|
||||
.wpmind-geo-info-content ul {
|
||||
margin: 0;
|
||||
padding-left: var(--wpmind-space-5);
|
||||
}
|
||||
|
||||
.wpmind-geo-info-content li {
|
||||
margin-bottom: var(--wpmind-space-1);
|
||||
}
|
||||
|
||||
/* Brand Entity Fields */
|
||||
.wpmind-brand-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
.wpmind-brand-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--wpmind-space-1);
|
||||
}
|
||||
|
||||
.wpmind-brand-label {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--wpmind-gray-700);
|
||||
}
|
||||
|
||||
.wpmind-brand-fields input[type="text"],
|
||||
.wpmind-brand-fields input[type="url"],
|
||||
.wpmind-brand-fields input[type="email"],
|
||||
.wpmind-brand-fields input[type="tel"],
|
||||
.wpmind-brand-fields textarea,
|
||||
.wpmind-brand-fields select {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wpmind-brand-fields .description {
|
||||
margin-top: var(--wpmind-space-1);
|
||||
font-size: var(--wpmind-text-xs);
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
90
assets/css/pages/media-intelligence.css
Normal file
90
assets/css/pages/media-intelligence.css
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* WPMind Media Intelligence Module Styles
|
||||
*
|
||||
* Media Intelligence-specific UI components: bulk progress, language select, safety note.
|
||||
* Shared components (header, stats, subtabs, options) in components/module-layout.css.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 4.3.0
|
||||
*/
|
||||
|
||||
/* Badge alignment */
|
||||
.wpmind-media-panel .wpmind-module-badge {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Safety note */
|
||||
.wpmind-mi-safety-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--wpmind-space-2);
|
||||
padding: var(--wpmind-space-3);
|
||||
background: var(--wpmind-gray-50);
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: var(--wpmind-radius-md);
|
||||
margin-top: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
.wpmind-mi-safety-note .dashicons {
|
||||
color: var(--wpmind-primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.wpmind-mi-safety-note p {
|
||||
margin: 0;
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-600);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Language select row */
|
||||
.wpmind-media-language-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-3) 0;
|
||||
}
|
||||
|
||||
.wpmind-media-language-row select {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
/* Bulk processing */
|
||||
.wpmind-media-bulk-info {
|
||||
color: var(--wpmind-gray-600);
|
||||
margin: 0 0 var(--wpmind-space-3);
|
||||
}
|
||||
|
||||
.wpmind-media-bulk-actions {
|
||||
display: flex;
|
||||
gap: var(--wpmind-space-2);
|
||||
margin-bottom: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.wpmind-media-progress {
|
||||
margin-top: var(--wpmind-space-3);
|
||||
}
|
||||
|
||||
.wpmind-media-progress-bar {
|
||||
height: 20px;
|
||||
background: var(--wpmind-gray-100);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
}
|
||||
|
||||
.wpmind-media-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--wpmind-primary);
|
||||
border-radius: 10px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.wpmind-media-progress-text {
|
||||
display: inline-block;
|
||||
margin-top: var(--wpmind-space-1);
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-600);
|
||||
}
|
||||
|
|
@ -10,10 +10,15 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-5);
|
||||
border-bottom: 1px solid var(--wpmind-gray-100);
|
||||
min-height: 54px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--wpmind-gray-200);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wpmind-routing-desc {
|
||||
color: var(--wpmind-gray-600);
|
||||
margin: 0 0 var(--wpmind-space-6);
|
||||
}
|
||||
|
||||
.wpmind-routing-title {
|
||||
|
|
@ -50,13 +55,11 @@
|
|||
.wpmind-routing-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-5);
|
||||
background: var(--wpmind-gray-50);
|
||||
border-bottom: 1px solid var(--wpmind-gray-100);
|
||||
gap: var(--wpmind-space-4);
|
||||
margin-bottom: var(--wpmind-space-6);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
@media (max-width: 1200px) {
|
||||
.wpmind-routing-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
|
@ -68,66 +71,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
.wpmind-stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-3);
|
||||
background: #fff;
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wpmind-stat-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--wpmind-gray-100);
|
||||
border-radius: 6px;
|
||||
color: var(--wpmind-gray-600);
|
||||
}
|
||||
|
||||
.wpmind-stat-icon .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.wpmind-stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wpmind-stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--wpmind-gray-900);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.wpmind-stat-value small {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
|
||||
.wpmind-stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--wpmind-gray-500);
|
||||
}
|
||||
/* Stat cards moved to components/module-layout.css */
|
||||
|
||||
/* Grid Layout */
|
||||
.wpmind-routing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: var(--wpmind-space-5);
|
||||
padding: var(--wpmind-space-5);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--wpmind-space-6);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
@media (max-width: 1200px) {
|
||||
.wpmind-routing-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
@ -135,6 +88,10 @@
|
|||
|
||||
/* Section Styles */
|
||||
.wpmind-routing-section {
|
||||
background: var(--wpmind-gray-50);
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: var(--wpmind-radius-lg);
|
||||
padding: var(--wpmind-space-5);
|
||||
margin-bottom: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
|
|
@ -143,17 +100,27 @@
|
|||
}
|
||||
|
||||
.wpmind-routing-section-title {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
font-size: var(--wpmind-text-base);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-900);
|
||||
color: var(--wpmind-gray-800);
|
||||
margin: 0 0 var(--wpmind-space-2) 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wpmind-routing-section-title .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-routing-section-desc {
|
||||
margin: 0 0 var(--wpmind-space-3) 0;
|
||||
font-size: var(--wpmind-text-xs);
|
||||
color: var(--wpmind-gray-500);
|
||||
margin: 0 0 var(--wpmind-space-4) 0;
|
||||
font-size: var(--wpmind-text-sm);
|
||||
color: var(--wpmind-gray-600);
|
||||
}
|
||||
|
||||
/* Strategy List - Vertical Group Style */
|
||||
|
|
@ -289,8 +256,8 @@
|
|||
|
||||
/* Status Card - 推荐 Provider 卡片 */
|
||||
.wpmind-routing-status-card {
|
||||
background: linear-gradient(135deg, var(--wpmind-success-light) 0%, #d1fae5 100%);
|
||||
border: 1px solid #a7f3d0;
|
||||
background: var(--wpmind-success-light);
|
||||
border: 1px solid var(--wpmind-success-light);
|
||||
padding: var(--wpmind-space-4);
|
||||
margin-bottom: var(--wpmind-space-4);
|
||||
}
|
||||
|
|
@ -338,7 +305,7 @@
|
|||
.wpmind-routing-status-provider {
|
||||
font-size: var(--wpmind-text-lg);
|
||||
font-weight: 700;
|
||||
color: #065f46;
|
||||
color: var(--wpmind-success);
|
||||
}
|
||||
|
||||
.wpmind-routing-status-score {
|
||||
|
|
@ -349,13 +316,13 @@
|
|||
|
||||
.wpmind-routing-status-score-label {
|
||||
font-size: var(--wpmind-text-xs);
|
||||
color: #047857;
|
||||
color: var(--wpmind-success);
|
||||
}
|
||||
|
||||
.wpmind-routing-status-score-value {
|
||||
font-size: var(--wpmind-text-xl);
|
||||
font-weight: 700;
|
||||
color: #065f46;
|
||||
color: var(--wpmind-success);
|
||||
}
|
||||
|
||||
/* Failover Flow - 故障转移链可视化 */
|
||||
|
|
@ -417,8 +384,6 @@
|
|||
|
||||
/* Provider Scores - 排名区域 */
|
||||
.wpmind-routing-ranking {
|
||||
padding: var(--wpmind-space-5);
|
||||
border-top: 1px solid var(--wpmind-gray-100);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
|
@ -433,13 +398,13 @@
|
|||
align-items: center;
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-3) var(--wpmind-space-4);
|
||||
background: var(--wpmind-gray-50);
|
||||
background: #fff;
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
}
|
||||
|
||||
.wpmind-routing-score-item.is-top {
|
||||
background: linear-gradient(90deg, #fef3c7 0%, #fef9c3 100%);
|
||||
border-color: #fcd34d;
|
||||
background: var(--wpmind-warning-light);
|
||||
border-color: var(--wpmind-warning);
|
||||
}
|
||||
|
||||
.wpmind-routing-rank {
|
||||
|
|
@ -457,7 +422,7 @@
|
|||
}
|
||||
|
||||
.wpmind-routing-score-item.is-top .wpmind-routing-rank {
|
||||
background: #f59e0b;
|
||||
background: var(--wpmind-warning);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
|
@ -491,7 +456,7 @@
|
|||
}
|
||||
|
||||
.wpmind-routing-score-item.is-top .wpmind-routing-score-fill {
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
background: var(--wpmind-warning);
|
||||
}
|
||||
|
||||
.wpmind-routing-score-value {
|
||||
|
|
@ -503,5 +468,5 @@
|
|||
}
|
||||
|
||||
.wpmind-routing-score-item.is-top .wpmind-routing-score-value {
|
||||
color: #b45309;
|
||||
color: var(--wpmind-warning);
|
||||
}
|
||||
|
|
@ -17,13 +17,13 @@
|
|||
.wpmind-status-panel .title {
|
||||
margin: 0;
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-6);
|
||||
border-bottom: 1px solid #ccd0d4;
|
||||
border-bottom: 1px solid var(--wpmind-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-3);
|
||||
font-size: var(--wpmind-text-md);
|
||||
font-weight: 500;
|
||||
color: #1e1e1e;
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-900);
|
||||
min-height: 54px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
|
@ -158,27 +158,65 @@
|
|||
Budget Panel - 预算设置面板
|
||||
======================================== */
|
||||
.wpmind-budget-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: 0;
|
||||
padding: var(--wpmind-space-5);
|
||||
margin: var(--wpmind-space-5) 0;
|
||||
/* padding handled by .wpmind-tab-pane */
|
||||
}
|
||||
|
||||
.wpmind-budget-panel .title {
|
||||
margin: 0 0 var(--wpmind-space-4) 0;
|
||||
padding: 0 0 var(--wpmind-space-3) 0;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--wpmind-gray-100);
|
||||
.wpmind-budget-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-5);
|
||||
border-bottom: 1px solid var(--wpmind-gray-200);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wpmind-budget-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
font-size: var(--wpmind-text-lg);
|
||||
font-size: var(--wpmind-text-md);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-900);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wpmind-budget-panel .title .dashicons {
|
||||
.wpmind-budget-title .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-budget-desc {
|
||||
color: var(--wpmind-gray-600);
|
||||
margin: 0 0 var(--wpmind-space-6);
|
||||
}
|
||||
|
||||
.wpmind-cost-control-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--wpmind-space-3);
|
||||
padding: var(--wpmind-space-4) var(--wpmind-space-5);
|
||||
border-bottom: 1px solid var(--wpmind-gray-200);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wpmind-cost-control-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
font-size: var(--wpmind-text-md);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-900);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wpmind-cost-control-title .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
|
|
@ -205,12 +243,15 @@
|
|||
}
|
||||
|
||||
.wpmind-budget-settings {
|
||||
border-top: 1px solid var(--wpmind-gray-100);
|
||||
padding-top: var(--wpmind-space-5);
|
||||
/* sections handle their own styling */
|
||||
}
|
||||
|
||||
.wpmind-budget-section {
|
||||
margin-bottom: var(--wpmind-space-6);
|
||||
background: var(--wpmind-gray-50);
|
||||
border: 1px solid var(--wpmind-gray-200);
|
||||
border-radius: var(--wpmind-radius-lg);
|
||||
padding: var(--wpmind-space-5);
|
||||
margin-bottom: var(--wpmind-space-4);
|
||||
}
|
||||
|
||||
.wpmind-budget-section:last-child {
|
||||
|
|
@ -218,13 +259,23 @@
|
|||
}
|
||||
|
||||
.wpmind-budget-section h3 {
|
||||
font-size: var(--wpmind-text-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wpmind-space-2);
|
||||
font-size: var(--wpmind-text-base);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-900);
|
||||
color: var(--wpmind-gray-800);
|
||||
margin: 0 0 var(--wpmind-space-3) 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wpmind-budget-section h3 .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-budget-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
|
|
@ -347,8 +398,6 @@
|
|||
align-items: center;
|
||||
gap: var(--wpmind-space-3);
|
||||
margin-top: var(--wpmind-space-5);
|
||||
padding-top: var(--wpmind-space-4);
|
||||
border-top: 1px solid var(--wpmind-gray-100);
|
||||
}
|
||||
|
||||
.wpmind-budget-actions .spinner {
|
||||
|
|
@ -411,10 +460,13 @@
|
|||
margin: 0 0 var(--wpmind-space-5) 0;
|
||||
padding-bottom: var(--wpmind-space-3);
|
||||
border-bottom: 1px solid var(--wpmind-gray-200);
|
||||
font-size: var(--wpmind-text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--wpmind-gray-900);
|
||||
}
|
||||
|
||||
.wpmind-analytics-panel .title .dashicons {
|
||||
color: var(--wpmind-gray-500);
|
||||
color: var(--wpmind-primary);
|
||||
}
|
||||
|
||||
.wpmind-analytics-range-select {
|
||||
|
|
@ -603,8 +655,8 @@
|
|||
}
|
||||
|
||||
.wpmind-dialog-danger .wpmind-dialog-confirm:hover {
|
||||
background: #b91c1c;
|
||||
border-color: #b91c1c;
|
||||
background: var(--wpmind-error-dark);
|
||||
border-color: var(--wpmind-error-dark);
|
||||
}
|
||||
|
||||
.wpmind-dialog-info .wpmind-dialog-header .dashicons {
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@
|
|||
order: -1;
|
||||
}
|
||||
|
||||
.wpmind-usage-panel .title {
|
||||
.wpmind-usage-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
|
@ -194,7 +194,8 @@
|
|||
.wpmind-tab-pane .wpmind-analytics-panel,
|
||||
.wpmind-tab-pane .wpmind-status-panel,
|
||||
.wpmind-tab-pane .wpmind-routing-panel,
|
||||
.wpmind-tab-pane .wpmind-budget-panel {
|
||||
.wpmind-tab-pane .wpmind-budget-panel,
|
||||
.wpmind-tab-pane .wpmind-geo-panel {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
|
|
@ -206,7 +207,8 @@
|
|||
.wpmind-tab-pane .wpmind-analytics-panel:last-child,
|
||||
.wpmind-tab-pane .wpmind-status-panel:last-child,
|
||||
.wpmind-tab-pane .wpmind-routing-panel:last-child,
|
||||
.wpmind-tab-pane .wpmind-budget-panel:last-child {
|
||||
.wpmind-tab-pane .wpmind-budget-panel:last-child,
|
||||
.wpmind-tab-pane .wpmind-geo-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
548
assets/js/admin-analytics.js
Normal file
548
assets/js/admin-analytics.js
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
/**
|
||||
* WPMind Admin analytics charts.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.3.0
|
||||
*/
|
||||
|
||||
( function( $ ) {
|
||||
'use strict';
|
||||
|
||||
var Admin = window.WPMindAdmin || ( window.WPMindAdmin = {} );
|
||||
var Toast = Admin.Toast || {
|
||||
success: function() {},
|
||||
error: function() {},
|
||||
warning: function() {},
|
||||
info: function() {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Analytics Dashboard 图表管理
|
||||
*/
|
||||
var AnalyticsCharts = {
|
||||
charts: {},
|
||||
|
||||
// 现代化配色方案
|
||||
colors: {
|
||||
primary: '#3858e9',
|
||||
primaryLight: 'rgba(56, 88, 233, 0.1)',
|
||||
secondary: '#10b981',
|
||||
secondaryLight: 'rgba(16, 185, 129, 0.1)',
|
||||
accent: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
gray: {
|
||||
50: '#f9fafb',
|
||||
100: '#f3f4f6',
|
||||
200: '#e5e7eb',
|
||||
300: '#d1d5db',
|
||||
400: '#9ca3af',
|
||||
500: '#6b7280',
|
||||
600: '#4b5563',
|
||||
700: '#374151',
|
||||
800: '#1f2937',
|
||||
900: '#111827'
|
||||
}
|
||||
},
|
||||
|
||||
// 全局图表默认配置
|
||||
getDefaultOptions: function() {
|
||||
var self = this;
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: 750,
|
||||
easing: 'easeOutQuart'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
align: 'end',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
padding: 20,
|
||||
font: {
|
||||
family: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
size: 12,
|
||||
weight: '500'
|
||||
},
|
||||
color: self.colors.gray[ 600 ]
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: self.colors.gray[ 800 ],
|
||||
titleFont: {
|
||||
family: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
size: 13,
|
||||
weight: '600'
|
||||
},
|
||||
bodyFont: {
|
||||
family: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
size: 12
|
||||
},
|
||||
padding: 12,
|
||||
cornerRadius: 0,
|
||||
displayColors: true,
|
||||
boxPadding: 6
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
family: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
size: 11
|
||||
},
|
||||
color: self.colors.gray[ 500 ]
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: self.colors.gray[ 100 ],
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
family: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
size: 11
|
||||
},
|
||||
color: self.colors.gray[ 500 ]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
init: function() {
|
||||
if ( ! $( '#wpmind-usage-trend-chart' ).length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
|
||||
if ( 'undefined' === typeof Chart ) {
|
||||
// Chart.js 尚未加载,轮询等待
|
||||
var retries = 0;
|
||||
var maxRetries = 10;
|
||||
var timer = setInterval( function() {
|
||||
retries++;
|
||||
if ( 'undefined' !== typeof Chart ) {
|
||||
clearInterval( timer );
|
||||
self.loadData();
|
||||
self.bindEvents();
|
||||
} else if ( retries >= maxRetries ) {
|
||||
clearInterval( timer );
|
||||
$( '.wpmind-chart-container' ).html(
|
||||
'<p style="text-align:center;color:#6b7280;padding:2em 0;">' +
|
||||
'图表库加载失败,其他功能不受影响。</p>'
|
||||
);
|
||||
}
|
||||
}, 500 );
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadData();
|
||||
this.bindEvents();
|
||||
},
|
||||
|
||||
bindEvents: function() {
|
||||
var self = this;
|
||||
|
||||
// 时间范围切换
|
||||
$( '#wpmind-analytics-range' ).on( 'change', function() {
|
||||
self.loadData();
|
||||
} );
|
||||
|
||||
// 刷新按钮
|
||||
$( '.wpmind-refresh-analytics' ).on( 'click', function( e ) {
|
||||
e.preventDefault();
|
||||
var $btn = $( this );
|
||||
$btn.find( '.dashicons' ).addClass( 'wpmind-spinning' );
|
||||
self.loadData( function() {
|
||||
$btn.find( '.dashicons' ).removeClass( 'wpmind-spinning' );
|
||||
} );
|
||||
} );
|
||||
},
|
||||
|
||||
loadData: function( callback ) {
|
||||
var self = this;
|
||||
var range = $( '#wpmind-analytics-range' ).val() || '7d';
|
||||
|
||||
if ( 'undefined' === typeof wpmindData ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示加载状态
|
||||
$( '.wpmind-chart-container' ).addClass( 'is-loading' );
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl || ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_get_analytics_data',
|
||||
range: range,
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success && response.data ) {
|
||||
self.renderCharts( response.data );
|
||||
} else {
|
||||
Toast.error( '加载分析数据失败' );
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
Toast.error( '加载分析数据失败,请稍后重试' );
|
||||
},
|
||||
complete: function() {
|
||||
$( '.wpmind-chart-container' ).removeClass( 'is-loading' );
|
||||
if ( 'function' === typeof callback ) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
renderCharts: function( data ) {
|
||||
this.renderTrendChart( data.trend );
|
||||
this.renderProviderChart( data.providers );
|
||||
this.renderCostChart( data.cost );
|
||||
this.renderModelChart( data.models );
|
||||
},
|
||||
|
||||
renderTrendChart: function( data ) {
|
||||
var ctx = document.getElementById( 'wpmind-usage-trend-chart' );
|
||||
if ( ! ctx ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( this.charts.trend ) {
|
||||
this.charts.trend.destroy();
|
||||
}
|
||||
|
||||
var self = this;
|
||||
var options = this.getDefaultOptions();
|
||||
|
||||
this.charts.trend = new Chart( ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [ {
|
||||
label: 'Tokens',
|
||||
data: data.datasets.tokens,
|
||||
borderColor: self.colors.primary,
|
||||
backgroundColor: self.colors.primaryLight,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 6,
|
||||
pointHoverBackgroundColor: self.colors.primary,
|
||||
pointHoverBorderColor: '#fff',
|
||||
pointHoverBorderWidth: 2,
|
||||
yAxisID: 'y'
|
||||
}, {
|
||||
label: '请求数',
|
||||
data: data.datasets.requests,
|
||||
borderColor: self.colors.secondary,
|
||||
backgroundColor: 'transparent',
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
borderWidth: 2,
|
||||
borderDash: [ 5, 5 ],
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 6,
|
||||
pointHoverBackgroundColor: self.colors.secondary,
|
||||
pointHoverBorderColor: '#fff',
|
||||
pointHoverBorderWidth: 2,
|
||||
yAxisID: 'y1'
|
||||
} ]
|
||||
},
|
||||
options: $.extend( true, {}, options, {
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Tokens',
|
||||
font: { size: 11, weight: '500' },
|
||||
color: self.colors.gray[ 500 ]
|
||||
},
|
||||
grid: {
|
||||
color: self.colors.gray[ 100 ],
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
font: { size: 11 },
|
||||
color: self.colors.gray[ 500 ]
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: '请求数',
|
||||
font: { size: 11, weight: '500' },
|
||||
color: self.colors.gray[ 500 ]
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
},
|
||||
ticks: {
|
||||
font: { size: 11 },
|
||||
color: self.colors.gray[ 500 ]
|
||||
}
|
||||
}
|
||||
}
|
||||
} )
|
||||
} );
|
||||
},
|
||||
|
||||
renderProviderChart: function( data ) {
|
||||
var ctx = document.getElementById( 'wpmind-provider-chart' );
|
||||
if ( ! ctx ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( this.charts.provider ) {
|
||||
this.charts.provider.destroy();
|
||||
}
|
||||
|
||||
if ( ! data.labels || 0 === data.labels.length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
|
||||
this.charts.provider = new Chart( ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [ {
|
||||
data: data.datasets.requests,
|
||||
backgroundColor: data.colors,
|
||||
borderWidth: 0,
|
||||
hoverOffset: 8
|
||||
} ]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '65%',
|
||||
animation: {
|
||||
animateRotate: true,
|
||||
animateScale: true
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
padding: 16,
|
||||
font: {
|
||||
family: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
size: 12
|
||||
},
|
||||
color: self.colors.gray[ 600 ]
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: self.colors.gray[ 800 ],
|
||||
titleFont: { size: 13, weight: '600' },
|
||||
bodyFont: { size: 12 },
|
||||
padding: 12,
|
||||
cornerRadius: 0,
|
||||
callbacks: {
|
||||
label: function( context ) {
|
||||
var total = context.dataset.data.reduce( function( a, b ) {
|
||||
return a + b;
|
||||
}, 0 );
|
||||
var percentage = ( ( context.raw / total ) * 100 ).toFixed( 1 );
|
||||
return context.label + ': ' + context.raw + ' (' + percentage + '%)';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
renderCostChart: function( data ) {
|
||||
var ctx = document.getElementById( 'wpmind-cost-chart' );
|
||||
if ( ! ctx ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( this.charts.cost ) {
|
||||
this.charts.cost.destroy();
|
||||
}
|
||||
|
||||
var self = this;
|
||||
var options = this.getDefaultOptions();
|
||||
|
||||
this.charts.cost = new Chart( ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [ {
|
||||
label: 'USD',
|
||||
data: data.datasets.cost_usd,
|
||||
backgroundColor: self.colors.primary,
|
||||
borderColor: self.colors.primary,
|
||||
borderWidth: 0,
|
||||
borderRadius: 0,
|
||||
barPercentage: 0.7,
|
||||
categoryPercentage: 0.8
|
||||
}, {
|
||||
label: 'CNY',
|
||||
data: data.datasets.cost_cny,
|
||||
backgroundColor: self.colors.danger,
|
||||
borderColor: self.colors.danger,
|
||||
borderWidth: 0,
|
||||
borderRadius: 0,
|
||||
barPercentage: 0.7,
|
||||
categoryPercentage: 0.8
|
||||
} ]
|
||||
},
|
||||
options: $.extend( true, {}, options, {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '费用',
|
||||
font: { size: 11, weight: '500' },
|
||||
color: self.colors.gray[ 500 ]
|
||||
},
|
||||
grid: {
|
||||
color: self.colors.gray[ 100 ],
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
font: { size: 11 },
|
||||
color: self.colors.gray[ 500 ]
|
||||
}
|
||||
}
|
||||
}
|
||||
} )
|
||||
} );
|
||||
},
|
||||
|
||||
renderModelChart: function( data ) {
|
||||
var ctx = document.getElementById( 'wpmind-model-chart' );
|
||||
if ( ! ctx ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( this.charts.model ) {
|
||||
this.charts.model.destroy();
|
||||
}
|
||||
|
||||
if ( ! data.labels || 0 === data.labels.length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
|
||||
this.charts.model = new Chart( ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [ {
|
||||
label: '请求数',
|
||||
data: data.datasets.requests,
|
||||
backgroundColor: self.colors.primary,
|
||||
borderColor: self.colors.primary,
|
||||
borderWidth: 0,
|
||||
borderRadius: 0,
|
||||
barPercentage: 0.6
|
||||
} ]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: 750,
|
||||
easing: 'easeOutQuart'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: self.colors.gray[ 800 ],
|
||||
titleFont: { size: 13, weight: '600' },
|
||||
bodyFont: { size: 12 },
|
||||
padding: 12,
|
||||
cornerRadius: 0
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '请求数',
|
||||
font: { size: 11, weight: '500' },
|
||||
color: self.colors.gray[ 500 ]
|
||||
},
|
||||
grid: {
|
||||
color: self.colors.gray[ 100 ],
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
font: { size: 11 },
|
||||
color: self.colors.gray[ 500 ]
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
font: { size: 11 },
|
||||
color: self.colors.gray[ 600 ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} );
|
||||
}
|
||||
};
|
||||
|
||||
Admin.AnalyticsCharts = AnalyticsCharts;
|
||||
|
||||
/**
|
||||
* Initialize on document ready
|
||||
*/
|
||||
$( function() {
|
||||
if ( ! $( '#wpmind-usage-trend-chart' ).length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var safeInit = Admin.safeInit || function( label, fn ) {
|
||||
try {
|
||||
fn();
|
||||
} catch ( error ) {
|
||||
console.warn( '[WPMind] ' + label + ' init failed:', error );
|
||||
}
|
||||
};
|
||||
|
||||
if ( $( '#analytics' ).hasClass( 'wpmind-tab-pane-active' ) ) {
|
||||
safeInit( 'analytics', Admin.ensureChartsInit || AnalyticsCharts.init.bind( AnalyticsCharts ) );
|
||||
}
|
||||
} );
|
||||
} )( jQuery );
|
||||
205
assets/js/admin-auto-meta.js
Normal file
205
assets/js/admin-auto-meta.js
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* WPMind Admin Auto-Meta handlers.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.11.0
|
||||
*/
|
||||
|
||||
( function( $ ) {
|
||||
'use strict';
|
||||
|
||||
var Admin = window.WPMindAdmin || ( window.WPMindAdmin = {} );
|
||||
var Toast = Admin.Toast || {
|
||||
success: function() {},
|
||||
error: function() {},
|
||||
warning: function() {},
|
||||
info: function() {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-Meta Manager
|
||||
*/
|
||||
var AutoMetaManager = {
|
||||
|
||||
init: function() {
|
||||
this.bindEvents();
|
||||
this.restoreSubTab();
|
||||
this.loadStats();
|
||||
},
|
||||
|
||||
bindEvents: function() {
|
||||
var self = this;
|
||||
|
||||
// Sub-tab switching (scoped to auto-meta panel).
|
||||
$( '.wpmind-auto-meta-panel .wpmind-module-subtab' ).on( 'click', function() {
|
||||
self.switchTab( $( this ).data( 'tab' ) );
|
||||
} );
|
||||
|
||||
$( '#wpmind-save-am-settings' ).on( 'click', function() {
|
||||
self.saveSettings( $( this ) );
|
||||
} );
|
||||
$( '#wpmind-am-generate' ).on( 'click', function() {
|
||||
self.manualGenerate();
|
||||
} );
|
||||
},
|
||||
|
||||
switchTab: function( tab ) {
|
||||
$( '.wpmind-auto-meta-panel .wpmind-module-subtab' ).removeClass( 'active' );
|
||||
$( '.wpmind-auto-meta-panel .wpmind-module-subtab[data-tab="' + tab + '"]' ).addClass( 'active' );
|
||||
|
||||
$( '.wpmind-auto-meta-panel .wpmind-module-tab-panel' ).removeClass( 'active' );
|
||||
$( '.wpmind-auto-meta-panel .wpmind-module-tab-panel[data-panel="' + tab + '"]' ).addClass( 'active' );
|
||||
|
||||
try {
|
||||
sessionStorage.setItem( 'wpmind_am_subtab', tab );
|
||||
} catch ( e ) {}
|
||||
},
|
||||
|
||||
restoreSubTab: function() {
|
||||
var tab = 'am-settings';
|
||||
try {
|
||||
var saved = sessionStorage.getItem( 'wpmind_am_subtab' );
|
||||
if ( saved && $( '.wpmind-auto-meta-panel .wpmind-module-subtab[data-tab="' + saved + '"]' ).length ) {
|
||||
tab = saved;
|
||||
}
|
||||
} catch ( e ) {}
|
||||
this.switchTab( tab );
|
||||
},
|
||||
|
||||
loadStats: function() {
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl,
|
||||
type: 'GET',
|
||||
data: {
|
||||
action: 'wpmind_auto_meta_get_stats',
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
$( '#wpmind-am-total-gen' ).text( response.data.total_generated );
|
||||
$( '#wpmind-am-month-gen' ).text( response.data.month_generated );
|
||||
}
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
saveSettings: function( $button ) {
|
||||
var originalText = $button.html();
|
||||
$button.html( '<span class="dashicons ri-loader-4-line"></span> 保存中...' ).prop( 'disabled', true );
|
||||
|
||||
var postTypes = [];
|
||||
$( 'input[name="wpmind_auto_meta_post_types[]"]:checked' ).each( function() {
|
||||
postTypes.push( $( this ).val() );
|
||||
} );
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_save_auto_meta_settings',
|
||||
nonce: wpmindData.nonce,
|
||||
enabled: '1',
|
||||
auto_excerpt: $( 'input[name="wpmind_auto_meta_excerpt"]' ).is( ':checked' ) ? '1' : '0',
|
||||
auto_tags: $( 'input[name="wpmind_auto_meta_tags"]' ).is( ':checked' ) ? '1' : '0',
|
||||
auto_category: $( 'input[name="wpmind_auto_meta_category"]' ).is( ':checked' ) ? '1' : '0',
|
||||
auto_faq: $( 'input[name="wpmind_auto_meta_faq"]' ).is( ':checked' ) ? '1' : '0',
|
||||
auto_seo_desc: $( 'input[name="wpmind_auto_meta_seo_desc"]' ).is( ':checked' ) ? '1' : '0',
|
||||
post_types: postTypes
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
$button.html( '<span class="dashicons ri-check-line"></span> 已保存' );
|
||||
Toast.success( 'Auto-Meta 设置已保存' );
|
||||
} else {
|
||||
$button.html( '<span class="dashicons ri-error-warning-line"></span> 保存失败' );
|
||||
Toast.error( response.data && response.data.message || '保存失败' );
|
||||
}
|
||||
setTimeout( function() {
|
||||
$button.html( originalText ).prop( 'disabled', false );
|
||||
}, 1500 );
|
||||
},
|
||||
error: function() {
|
||||
$button.html( '<span class="dashicons ri-error-warning-line"></span> 网络错误' );
|
||||
setTimeout( function() {
|
||||
$button.html( originalText ).prop( 'disabled', false );
|
||||
}, 2000 );
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
manualGenerate: function() {
|
||||
var postId = $( '#wpmind-am-post-id' ).val();
|
||||
if ( ! postId || postId <= 0 ) {
|
||||
Toast.warning( '请输入有效的文章 ID' );
|
||||
return;
|
||||
}
|
||||
|
||||
var $button = $( '#wpmind-am-generate' );
|
||||
var originalText = $button.html();
|
||||
$button.html( '<span class="dashicons ri-loader-4-line"></span> 生成中...' ).prop( 'disabled', true );
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_auto_meta_generate',
|
||||
nonce: wpmindData.nonce,
|
||||
post_id: postId
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
var d = response.data;
|
||||
$( '#wpmind-am-result-excerpt' ).text( d.excerpt || '--' );
|
||||
$( '#wpmind-am-result-tags' ).text( d.tags && d.tags.length ? d.tags.join( ', ' ) : '--' );
|
||||
$( '#wpmind-am-result-categories' ).text( d.categories && d.categories.length ? d.categories.join( ', ' ) : '--' );
|
||||
$( '#wpmind-am-result-seo' ).text( d.seo_description || '--' );
|
||||
|
||||
var faqHtml = '--';
|
||||
if ( d.faq && d.faq.length ) {
|
||||
faqHtml = '<ul>';
|
||||
$.each( d.faq, function( i, item ) {
|
||||
faqHtml += '<li><strong>' + $( '<span>' ).text( item.question ).html() + '</strong><br>';
|
||||
faqHtml += $( '<span>' ).text( item.answer ).html() + '</li>';
|
||||
} );
|
||||
faqHtml += '</ul>';
|
||||
}
|
||||
$( '#wpmind-am-result-faq' ).html( faqHtml );
|
||||
|
||||
$( '.wpmind-am-result' ).show();
|
||||
Toast.success( d.message || '生成成功' );
|
||||
} else {
|
||||
Toast.error( response.data && response.data.message || '生成失败' );
|
||||
}
|
||||
$button.html( originalText ).prop( 'disabled', false );
|
||||
},
|
||||
error: function() {
|
||||
Toast.error( '网络错误' );
|
||||
$button.html( originalText ).prop( 'disabled', false );
|
||||
}
|
||||
} );
|
||||
}
|
||||
};
|
||||
|
||||
Admin.AutoMetaManager = AutoMetaManager;
|
||||
|
||||
/**
|
||||
* Initialize on document ready.
|
||||
*/
|
||||
$( function() {
|
||||
if ( ! $( '#wpmind-save-am-settings' ).length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var safeInit = Admin.safeInit || function( label, fn ) {
|
||||
try {
|
||||
fn();
|
||||
} catch ( error ) {
|
||||
console.warn( '[WPMind] ' + label + ' init failed:', error );
|
||||
}
|
||||
};
|
||||
|
||||
safeInit( 'auto-meta', function() {
|
||||
AutoMetaManager.init();
|
||||
} );
|
||||
} );
|
||||
} )( jQuery );
|
||||
105
assets/js/admin-boot.js
Normal file
105
assets/js/admin-boot.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* WPMind Admin boot.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.3.0
|
||||
*/
|
||||
|
||||
( function( $ ) {
|
||||
'use strict';
|
||||
|
||||
var Admin = window.WPMindAdmin || ( window.WPMindAdmin = {} );
|
||||
Admin.state = Admin.state || {
|
||||
chartsLoaded: false
|
||||
};
|
||||
|
||||
Admin.safeInit = Admin.safeInit || function( label, fn ) {
|
||||
try {
|
||||
fn();
|
||||
} catch ( error ) {
|
||||
console.warn( '[WPMind] ' + label + ' init failed:', error );
|
||||
}
|
||||
};
|
||||
|
||||
Admin.ensureChartsInit = Admin.ensureChartsInit || function() {
|
||||
if ( Admin.state.chartsLoaded ) {
|
||||
return;
|
||||
}
|
||||
if ( Admin.AnalyticsCharts && typeof Admin.AnalyticsCharts.init === 'function' ) {
|
||||
Admin.AnalyticsCharts.init();
|
||||
Admin.state.chartsLoaded = true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tab 导航管理
|
||||
*/
|
||||
function initTabs() {
|
||||
var $tabs = $( '.wpmind-tab' );
|
||||
var $panes = $( '.wpmind-tab-pane' );
|
||||
|
||||
if ( ! $tabs.length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 从 URL hash 恢复 Tab 状态,fallback 到第一个可用 Tab
|
||||
var firstTab = $tabs.first().data( 'tab' ) || 'services';
|
||||
var hash = window.location.hash.slice( 1 ) || firstTab;
|
||||
switchTab( hash );
|
||||
|
||||
// Tab 点击事件
|
||||
$tabs.on( 'click', function( e ) {
|
||||
e.preventDefault();
|
||||
var tabId = $( this ).data( 'tab' );
|
||||
switchTab( tabId );
|
||||
history.replaceState( null, null, '#' + tabId );
|
||||
} );
|
||||
|
||||
// 概览页快捷入口点击事件(直接绑定,避免 <a> 锚点干扰)
|
||||
$( '.wpmind-tab-link' ).on( 'click', function( e ) {
|
||||
e.preventDefault();
|
||||
var tabId = $( this ).attr( 'data-tab-link' );
|
||||
if ( tabId ) {
|
||||
switchTab( tabId );
|
||||
history.replaceState( null, null, '#' + tabId );
|
||||
}
|
||||
return false;
|
||||
} );
|
||||
|
||||
function switchTab( tabId ) {
|
||||
// 验证 tabId 是否有效,fallback 到第一个可用 Tab
|
||||
if ( ! $( '#' + tabId ).length ) {
|
||||
tabId = firstTab;
|
||||
}
|
||||
|
||||
$tabs.removeClass( 'wpmind-tab-active' );
|
||||
$tabs.filter( '[data-tab="' + tabId + '"]' ).addClass( 'wpmind-tab-active' );
|
||||
|
||||
$panes.removeClass( 'wpmind-tab-pane-active' );
|
||||
$( '#' + tabId ).addClass( 'wpmind-tab-pane-active' );
|
||||
|
||||
// 懒加载图表(仅在首次切换到数据分析时)
|
||||
if ( 'analytics' === tabId && ! Admin.state.chartsLoaded ) {
|
||||
Admin.ensureChartsInit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize on document ready
|
||||
*/
|
||||
$( function() {
|
||||
// Health check
|
||||
$( 'body' ).addClass( 'wpmind-js-loaded' );
|
||||
if ( typeof wpmindData !== 'undefined' && wpmindData.version ) {
|
||||
console.log( '[WPMind] admin scripts v' + wpmindData.version + ' loaded' );
|
||||
}
|
||||
|
||||
Admin.safeInit( 'tabs', initTabs );
|
||||
|
||||
// 图表懒加载:只在数据分析 Tab 激活时初始化
|
||||
if ( $( '#analytics' ).hasClass( 'wpmind-tab-pane-active' ) ) {
|
||||
Admin.safeInit( 'analytics', Admin.ensureChartsInit );
|
||||
}
|
||||
} );
|
||||
} )( jQuery );
|
||||
387
assets/js/admin-budget.js
Normal file
387
assets/js/admin-budget.js
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
/**
|
||||
* WPMind Admin budget & usage handlers.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.3.0
|
||||
*/
|
||||
|
||||
( function( $ ) {
|
||||
'use strict';
|
||||
|
||||
var Admin = window.WPMindAdmin || ( window.WPMindAdmin = {} );
|
||||
var Toast = Admin.Toast || {
|
||||
success: function() {},
|
||||
error: function() {},
|
||||
warning: function() {},
|
||||
info: function() {}
|
||||
};
|
||||
var Dialog = Admin.Dialog || {
|
||||
show: function() {}
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化 token 数量
|
||||
*/
|
||||
function formatTokens( tokens ) {
|
||||
tokens = tokens || 0;
|
||||
if ( tokens >= 1000000 ) {
|
||||
return ( tokens / 1000000 ).toFixed( 2 ) + 'M';
|
||||
}
|
||||
if ( tokens >= 1000 ) {
|
||||
return ( tokens / 1000 ).toFixed( 1 ) + 'K';
|
||||
}
|
||||
return tokens.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化成本
|
||||
*/
|
||||
function formatCost( cost ) {
|
||||
cost = cost || 0;
|
||||
if ( cost < 0.01 ) {
|
||||
return '$' + cost.toFixed( 4 );
|
||||
}
|
||||
return '$' + cost.toFixed( 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用量显示
|
||||
*/
|
||||
function updateUsageDisplay( data ) {
|
||||
var today = data.today || {};
|
||||
var month = data.month || {};
|
||||
var total = ( data.stats && data.stats.total ) || {};
|
||||
|
||||
$( '#today-tokens' ).text( formatTokens( today.input_tokens + today.output_tokens ) );
|
||||
$( '#today-cost' ).text( formatCost( today.cost || 0 ) );
|
||||
$( '#today-requests' ).text( today.requests || 0 );
|
||||
|
||||
$( '#month-tokens' ).text( formatTokens( month.input_tokens + month.output_tokens ) );
|
||||
$( '#month-cost' ).text( formatCost( month.cost || 0 ) );
|
||||
$( '#month-requests' ).text( month.requests || 0 );
|
||||
|
||||
$( '#total-tokens' ).text( formatTokens( ( total.input_tokens || 0 ) + ( total.output_tokens || 0 ) ) );
|
||||
$( '#total-cost' ).text( formatCost( total.cost || 0 ) );
|
||||
$( '#total-requests' ).text( total.requests || 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新用量统计
|
||||
*/
|
||||
function initUsageRefresh() {
|
||||
$( document ).on( 'click', '.wpmind-refresh-usage', function( e ) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var $button = $( this );
|
||||
if ( $button.hasClass( 'is-loading' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$button.addClass( 'is-loading' );
|
||||
$button.find( '.dashicons' ).addClass( 'wpmind-spinning' );
|
||||
|
||||
if ( 'undefined' === typeof wpmindData ) {
|
||||
Toast.error( '配置错误' );
|
||||
$button.removeClass( 'is-loading' );
|
||||
$button.find( '.dashicons' ).removeClass( 'wpmind-spinning' );
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl || ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_get_usage_stats',
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
updateUsageDisplay( response.data );
|
||||
Toast.success( '统计已刷新' );
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
Toast.error( '刷新失败' );
|
||||
},
|
||||
complete: function() {
|
||||
$button.removeClass( 'is-loading' );
|
||||
$button.find( '.dashicons' ).removeClass( 'wpmind-spinning' );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除用量统计
|
||||
*/
|
||||
function initUsageClear() {
|
||||
$( document ).on( 'click', '.wpmind-clear-usage', function( e ) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var $button = $( this );
|
||||
|
||||
Dialog.show( {
|
||||
title: '清除统计',
|
||||
message: '确定要清除所有用量统计数据吗?<br><small style="color:#666;">此操作不可恢复</small>',
|
||||
type: 'danger',
|
||||
confirmText: '确定清除',
|
||||
cancelText: '取消',
|
||||
onConfirm: function() {
|
||||
var originalHtml = $button.html();
|
||||
$button.prop( 'disabled', true ).html( '<span class="dashicons ri-loader-4-line wpmind-spinning"></span>' );
|
||||
|
||||
if ( 'undefined' === typeof wpmindData ) {
|
||||
Toast.error( '配置错误' );
|
||||
$button.prop( 'disabled', false ).html( originalHtml );
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl || ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_clear_usage_stats',
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
Toast.success( '统计已清除' );
|
||||
// 重置显示
|
||||
updateUsageDisplay( {
|
||||
today: { input_tokens: 0, output_tokens: 0, cost: 0, requests: 0 },
|
||||
month: { input_tokens: 0, output_tokens: 0, cost: 0, requests: 0 },
|
||||
stats: { total: { input_tokens: 0, output_tokens: 0, cost: 0, requests: 0 } }
|
||||
} );
|
||||
} else {
|
||||
Toast.error( '清除失败' );
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
Toast.error( '清除失败' );
|
||||
},
|
||||
complete: function() {
|
||||
$button.prop( 'disabled', false ).html( originalHtml );
|
||||
}
|
||||
} );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* 预算设置管理
|
||||
*/
|
||||
function initBudgetSettings() {
|
||||
// 切换预算设置面板显示
|
||||
$( '#wpmind_budget_enabled' ).on( 'change', function() {
|
||||
$( '#wpmind-budget-settings' ).toggle( this.checked );
|
||||
} );
|
||||
|
||||
// 切换邮件字段显示
|
||||
$( 'input[name="email_alert"]' ).on( 'change', function() {
|
||||
$( '.wpmind-budget-email-field' ).toggle( this.checked );
|
||||
} );
|
||||
|
||||
// 保存预算设置
|
||||
$( '#wpmind-save-budget' ).on( 'click', function( e ) {
|
||||
e.preventDefault();
|
||||
|
||||
var $button = $( this );
|
||||
if ( $button.prop( 'disabled' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var originalText = $button.text();
|
||||
$button.prop( 'disabled', true ).html( '<span class="dashicons ri-loader-4-line wpmind-spinning"></span> 保存中' );
|
||||
|
||||
// 收集设置数据
|
||||
var settings = {
|
||||
enabled: $( '#wpmind_budget_enabled' ).is( ':checked' ),
|
||||
global: {
|
||||
daily_limit_usd: parseFloat( $( 'input[name="daily_limit_usd"]' ).val() ) || 0,
|
||||
monthly_limit_usd: parseFloat( $( 'input[name="monthly_limit_usd"]' ).val() ) || 0,
|
||||
daily_limit_cny: parseFloat( $( 'input[name="daily_limit_cny"]' ).val() ) || 0,
|
||||
monthly_limit_cny: parseFloat( $( 'input[name="monthly_limit_cny"]' ).val() ) || 0,
|
||||
alert_threshold: parseInt( $( 'input[name="alert_threshold"]' ).val() ) || 80
|
||||
},
|
||||
enforcement_mode: $( 'select[name="enforcement_mode"]' ).val() || 'alert',
|
||||
notifications: {
|
||||
admin_notice: $( 'input[name="admin_notice"]' ).is( ':checked' ),
|
||||
email_alert: $( 'input[name="email_alert"]' ).is( ':checked' ),
|
||||
email_address: $( 'input[name="email_address"]' ).val() || ''
|
||||
}
|
||||
};
|
||||
|
||||
if ( 'undefined' === typeof wpmindData ) {
|
||||
Toast.error( '配置错误' );
|
||||
$button.prop( 'disabled', false ).text( originalText );
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl || ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_save_budget_settings',
|
||||
settings: JSON.stringify( settings ),
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
Toast.success( '预算设置已保存' );
|
||||
} else {
|
||||
var msg = ( response.data && response.data.message ) || '保存失败';
|
||||
Toast.error( msg );
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
Toast.error( '保存失败,请重试' );
|
||||
},
|
||||
complete: function() {
|
||||
$button.prop( 'disabled', false ).text( originalText );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cost Control 设置保存
|
||||
*/
|
||||
function initCostControlSettings() {
|
||||
$( '#wpmind-save-cost-control' ).on( 'click', function() {
|
||||
var $button = $( this );
|
||||
var $spinner = $button.siblings( '.spinner' );
|
||||
|
||||
$button.prop( 'disabled', true );
|
||||
$spinner.addClass( 'is-active' );
|
||||
|
||||
var settings = {
|
||||
enabled: $( '#wpmind_budget_enabled' ).is( ':checked' ),
|
||||
global: {
|
||||
daily_limit_usd: parseFloat( $( '#budget_daily_usd' ).val() ) || 0,
|
||||
daily_limit_cny: parseFloat( $( '#budget_daily_cny' ).val() ) || 0,
|
||||
monthly_limit_usd: parseFloat( $( '#budget_monthly_usd' ).val() ) || 0,
|
||||
monthly_limit_cny: parseFloat( $( '#budget_monthly_cny' ).val() ) || 0,
|
||||
alert_threshold: parseInt( $( '#budget_alert_threshold' ).val() ) || 80
|
||||
},
|
||||
enforcement_mode: $( '#budget_enforcement_mode' ).val(),
|
||||
notifications: {
|
||||
admin_notice: $( 'input[name="admin_notice"]' ).is( ':checked' ),
|
||||
email_alert: $( 'input[name="email_alert"]' ).is( ':checked' ),
|
||||
email_address: $( 'input[name="email_address"]' ).val()
|
||||
}
|
||||
};
|
||||
|
||||
if ( 'undefined' === typeof wpmindData ) {
|
||||
$button.prop( 'disabled', false );
|
||||
$spinner.removeClass( 'is-active' );
|
||||
alert( '配置错误' );
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_save_cost_control_settings',
|
||||
nonce: wpmindData.nonce,
|
||||
settings: JSON.stringify( settings )
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
alert( ( response.data && response.data.message ) || '设置已保存' );
|
||||
} else {
|
||||
alert( ( response.data && response.data.message ) || '保存失败' );
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
alert( '保存失败' );
|
||||
},
|
||||
complete: function() {
|
||||
$button.prop( 'disabled', false );
|
||||
$spinner.removeClass( 'is-active' );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cost Control 清除统计
|
||||
*/
|
||||
function initCostControlClearUsage() {
|
||||
$( '#wpmind-clear-usage-stats' ).on( 'click', function() {
|
||||
if ( ! confirm( '确定要清除所有用量统计数据吗?此操作不可恢复。' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var $button = $( this );
|
||||
var $spinner = $button.siblings( '.spinner' );
|
||||
|
||||
$button.prop( 'disabled', true );
|
||||
$spinner.addClass( 'is-active' );
|
||||
|
||||
if ( 'undefined' === typeof wpmindData ) {
|
||||
$button.prop( 'disabled', false );
|
||||
$spinner.removeClass( 'is-active' );
|
||||
alert( '配置错误' );
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_clear_usage_stats',
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
alert( ( response.data && response.data.message ) || '统计已清除' );
|
||||
location.reload();
|
||||
} else {
|
||||
alert( ( response.data && response.data.message ) || '清除失败' );
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
alert( '清除失败' );
|
||||
},
|
||||
complete: function() {
|
||||
$button.prop( 'disabled', false );
|
||||
$spinner.removeClass( 'is-active' );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize on document ready
|
||||
*/
|
||||
$( function() {
|
||||
if (
|
||||
! $( '.wpmind-refresh-usage' ).length &&
|
||||
! $( '.wpmind-clear-usage' ).length &&
|
||||
! $( '#wpmind-save-budget' ).length &&
|
||||
! $( '#wpmind-save-cost-control' ).length &&
|
||||
! $( '#wpmind-clear-usage-stats' ).length &&
|
||||
! $( '#wpmind_budget_enabled' ).length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
var safeInit = Admin.safeInit || function( label, fn ) {
|
||||
try {
|
||||
fn();
|
||||
} catch ( error ) {
|
||||
console.warn( '[WPMind] ' + label + ' init failed:', error );
|
||||
}
|
||||
};
|
||||
|
||||
safeInit( 'usage:refresh', initUsageRefresh );
|
||||
safeInit( 'usage:clear', initUsageClear );
|
||||
safeInit( 'budget:settings', initBudgetSettings );
|
||||
safeInit( 'cost-control:save', initCostControlSettings );
|
||||
safeInit( 'cost-control:clear', initCostControlClearUsage );
|
||||
} );
|
||||
} )( jQuery );
|
||||
367
assets/js/admin-endpoints.js
Normal file
367
assets/js/admin-endpoints.js
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
/**
|
||||
* WPMind Admin endpoints handlers.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.3.0
|
||||
*/
|
||||
|
||||
( function( $ ) {
|
||||
'use strict';
|
||||
|
||||
var Admin = window.WPMindAdmin || ( window.WPMindAdmin = {} );
|
||||
var Toast = Admin.Toast || {
|
||||
success: function() {},
|
||||
error: function() {},
|
||||
warning: function() {},
|
||||
info: function() {}
|
||||
};
|
||||
var escapeHtml = Admin.escapeHtml || function( text ) {
|
||||
return 'string' === typeof text ? text : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle password visibility
|
||||
*/
|
||||
function initPasswordToggle() {
|
||||
$( '.wpmind-toggle-key' ).on( 'click', function( e ) {
|
||||
e.preventDefault();
|
||||
|
||||
var $button = $( this );
|
||||
var targetId = $button.data( 'target' );
|
||||
var $target = $( '#' + targetId );
|
||||
var $icon = $button.find( '.dashicons' );
|
||||
|
||||
if ( 'password' === $target.attr( 'type' ) ) {
|
||||
$target.attr( 'type', 'text' );
|
||||
$icon.removeClass( 'ri-eye-line' ).addClass( 'ri-eye-off-line' );
|
||||
} else {
|
||||
$target.attr( 'type', 'password' );
|
||||
$icon.removeClass( 'ri-eye-off-line' ).addClass( 'ri-eye-line' );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint Card 折叠功能
|
||||
*/
|
||||
function initEndpointCollapse() {
|
||||
// 点击 header 或 toggle 按钮折叠/展开
|
||||
$( document ).on( 'click', '.wpmind-endpoint-header', function( e ) {
|
||||
// 如果点击的是内部的其他按钮或链接,不触发折叠
|
||||
if ( $( e.target ).closest( 'a, button:not(.wpmind-endpoint-toggle), input, select' ).length && ! $( e.target ).closest( '.wpmind-endpoint-toggle' ).length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var $card = $( this ).closest( '.wpmind-endpoint-card' );
|
||||
var $toggle = $( this ).find( '.wpmind-endpoint-toggle' );
|
||||
var isCollapsed = $card.hasClass( 'is-collapsed' );
|
||||
|
||||
if ( isCollapsed ) {
|
||||
$card.removeClass( 'is-collapsed' );
|
||||
$toggle.attr( 'aria-expanded', 'true' );
|
||||
} else {
|
||||
$card.addClass( 'is-collapsed' );
|
||||
$toggle.attr( 'aria-expanded', 'false' );
|
||||
}
|
||||
} );
|
||||
|
||||
// 阻止 toggle 按钮的默认行为(因为 header 已经处理了点击)
|
||||
$( document ).on( 'click', '.wpmind-endpoint-toggle', function( e ) {
|
||||
e.stopPropagation();
|
||||
$( this ).closest( '.wpmind-endpoint-header' ).trigger( 'click' );
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 输入处理
|
||||
*/
|
||||
function initApiKeyValidation() {
|
||||
$( 'input[id^="api_key_"]' ).on( 'input', function() {
|
||||
var $input = $( this );
|
||||
$input.siblings( '.wpmind-validation-message' ).remove();
|
||||
$input.removeClass( 'is-valid is-invalid' );
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接功能
|
||||
*/
|
||||
function initTestConnection() {
|
||||
$( '.wpmind-test-connection' ).on( 'click', function( e ) {
|
||||
e.preventDefault();
|
||||
|
||||
var $button = $( this );
|
||||
var provider = $button.data( 'provider' );
|
||||
var $result = $button.siblings( '.wpmind-test-result' );
|
||||
var $card = $button.closest( '.wpmind-endpoint-card' );
|
||||
|
||||
var $apiKeyInput = $card.find( 'input[name*="[api_key]"]' );
|
||||
var apiKey = $apiKeyInput.val();
|
||||
var $customUrlInput = $card.find( 'input[name*="[custom_base_url]"]' );
|
||||
var customUrl = $customUrlInput.val();
|
||||
|
||||
// 设置加载状态
|
||||
$button.addClass( 'is-testing' ).prop( 'disabled', true );
|
||||
$button.html( '<span class="dashicons ri-loader-4-line wpmind-spinning"></span> 测试中' );
|
||||
$result.text( '' ).removeClass( 'success error warning' ).removeAttr( 'title' );
|
||||
|
||||
if ( 'undefined' === typeof wpmindData ) {
|
||||
$result.text( '配置错误' ).addClass( 'error' );
|
||||
$button.removeClass( 'is-testing' ).prop( 'disabled', false ).text( '测试连接' );
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl || ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_test_connection',
|
||||
provider: provider,
|
||||
api_key: apiKey,
|
||||
custom_url: customUrl,
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
timeout: 45000,
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
var message = '连接成功';
|
||||
var extra = '';
|
||||
if ( response.data ) {
|
||||
if ( response.data.retried ) {
|
||||
extra += ' (重试后)';
|
||||
}
|
||||
if ( response.data.latency ) {
|
||||
extra += ' ' + response.data.latency + 'ms';
|
||||
}
|
||||
}
|
||||
$result.html( '<span class="dashicons ri-checkbox-circle-line"></span> ' + message + extra ).addClass( 'success' );
|
||||
Toast.success( provider.toUpperCase() + ' ' + message );
|
||||
} else {
|
||||
var errorMsg = ( response.data && response.data.message ) || '连接失败';
|
||||
var errorCode = ( response.data && response.data.code ) ? ' [' + escapeHtml( String( response.data.code ) ) + ']' : '';
|
||||
var retryInfo = ( response.data && response.data.retried ) ? ' (已重试)' : '';
|
||||
|
||||
$result.html(
|
||||
'<span class="dashicons ri-close-circle-line"></span> ' +
|
||||
escapeHtml( errorMsg ) + errorCode + retryInfo
|
||||
).addClass( 'error' );
|
||||
|
||||
// 显示详细信息提示
|
||||
if ( response.data && response.data.details ) {
|
||||
$result.attr( 'title', '详细信息: ' + escapeHtml( response.data.details ) );
|
||||
}
|
||||
|
||||
// Toast 也显示错误
|
||||
Toast.error( provider.toUpperCase() + ': ' + errorMsg );
|
||||
}
|
||||
},
|
||||
error: function( xhr, status ) {
|
||||
var message = '连接失败';
|
||||
if ( 'timeout' === status ) {
|
||||
message = '请求超时,请检查网络连接';
|
||||
} else if ( 'error' === status ) {
|
||||
message = '网络错误,请检查连接';
|
||||
}
|
||||
$result.html( '<span class="dashicons ri-close-circle-line"></span> ' + message ).addClass( 'error' );
|
||||
Toast.error( provider.toUpperCase() + ': ' + message );
|
||||
},
|
||||
complete: function() {
|
||||
$button.removeClass( 'is-testing' ).prop( 'disabled', false ).text( '测试连接' );
|
||||
// 延长显示时间到 10 秒,让用户有足够时间阅读错误信息
|
||||
setTimeout( function() {
|
||||
$result.fadeOut( 300, function() {
|
||||
$( this ).text( '' ).removeClass( 'success error warning' ).removeAttr( 'title' ).show();
|
||||
} );
|
||||
}, 10000 );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试图像服务连接功能
|
||||
*/
|
||||
function initImageTestConnection() {
|
||||
$( '.wpmind-test-image-connection' ).on( 'click', function( e ) {
|
||||
e.preventDefault();
|
||||
|
||||
var $button = $( this );
|
||||
var provider = $button.data( 'provider' );
|
||||
var $result = $button.siblings( '.wpmind-test-result' );
|
||||
|
||||
// 设置加载状态
|
||||
$button.addClass( 'is-testing' ).prop( 'disabled', true );
|
||||
$button.html( '<span class="dashicons ri-loader-4-line wpmind-spinning"></span> 测试中' );
|
||||
$result.text( '' ).removeClass( 'success error' );
|
||||
|
||||
if ( 'undefined' === typeof wpmindData ) {
|
||||
$result.text( '配置错误' ).addClass( 'error' );
|
||||
$button.removeClass( 'is-testing' ).prop( 'disabled', false ).text( '测试连接' );
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl || ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_test_image_connection',
|
||||
provider: provider,
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
timeout: 45000,
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
$result.html( '<span class="dashicons ri-checkbox-circle-line"></span> 连接成功' ).addClass( 'success' );
|
||||
Toast.success( provider + ' 连接成功' );
|
||||
} else {
|
||||
var errorMsg = ( response.data && response.data.message ) || '连接失败';
|
||||
var errorCode = ( response.data && response.data.code ) ? ' [' + response.data.code + ']' : '';
|
||||
$result.html( '<span class="dashicons ri-close-circle-line"></span> ' + escapeHtml( errorMsg ) + errorCode ).addClass( 'error' );
|
||||
Toast.error( provider + ': ' + errorMsg );
|
||||
}
|
||||
},
|
||||
error: function( xhr, status ) {
|
||||
var message = 'timeout' === status ? '请求超时,请检查网络连接' : '网络错误,请检查连接';
|
||||
$result.html( '<span class="dashicons ri-close-circle-line"></span> ' + message ).addClass( 'error' );
|
||||
Toast.error( provider + ': ' + message );
|
||||
},
|
||||
complete: function() {
|
||||
$button.removeClass( 'is-testing' ).prop( 'disabled', false ).text( '测试连接' );
|
||||
setTimeout( function() {
|
||||
$result.fadeOut( 300, function() {
|
||||
$( this ).text( '' ).removeClass( 'success error' ).show();
|
||||
} );
|
||||
}, 10000 );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Update card status when checkbox changes
|
||||
*/
|
||||
function initStatusUpdate() {
|
||||
$( '.wpmind-endpoint-card input[type="checkbox"]' ).not( '.wpmind-clear-checkbox' ).on( 'change', function() {
|
||||
var $card = $( this ).closest( '.wpmind-endpoint-card' );
|
||||
var $header = $card.find( '.wpmind-endpoint-header' );
|
||||
var $status = $header.find( '.wpmind-status' ).not( '.wpmind-status-official' );
|
||||
var $apiKey = $card.find( 'input[type="password"], input[type="text"]' ).filter( '[id^="api_key_"]' );
|
||||
var hasKey = $apiKey.attr( 'placeholder' ) && $apiKey.attr( 'placeholder' ).length > 0;
|
||||
|
||||
if ( this.checked && ( hasKey || $apiKey.val() ) ) {
|
||||
if ( ! $status.length ) {
|
||||
$header.append( '<span class="wpmind-status wpmind-status-active">已启用</span>' );
|
||||
}
|
||||
} else {
|
||||
$status.remove();
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clear API key checkbox
|
||||
*/
|
||||
function initClearKeyHandler() {
|
||||
$( '.wpmind-clear-checkbox' ).on( 'change', function() {
|
||||
var $card = $( this ).closest( '.wpmind-endpoint-card' );
|
||||
var $apiKeyInput = $card.find( 'input[id^="api_key_"]' );
|
||||
|
||||
if ( this.checked ) {
|
||||
$apiKeyInput.prop( 'disabled', true ).attr( 'placeholder', 'API Key 将被清除' );
|
||||
$card.addClass( 'wpmind-card-warning' );
|
||||
} else {
|
||||
$apiKeyInput.prop( 'disabled', false ).attr( 'placeholder', '••••••••••••••••' );
|
||||
$card.removeClass( 'wpmind-card-warning' );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Form validation before submit
|
||||
*/
|
||||
function initFormValidation() {
|
||||
$( '#wpmind-settings-form' ).on( 'submit', function( e ) {
|
||||
var hasEnabledWithoutKey = false;
|
||||
var $problemCard = null;
|
||||
|
||||
$( '.wpmind-endpoint-card' ).each( function() {
|
||||
var $card = $( this );
|
||||
var $checkbox = $card.find( 'input[type="checkbox"]' ).not( '.wpmind-clear-checkbox' );
|
||||
var $apiKey = $card.find( 'input[type="password"], input[type="text"]' ).filter( '[id^="api_key_"]' );
|
||||
var $clearCheckbox = $card.find( '.wpmind-clear-checkbox' );
|
||||
var hasExistingKey = $apiKey.attr( 'placeholder' ) && -1 !== $apiKey.attr( 'placeholder' ).indexOf( '•' );
|
||||
var willClear = $clearCheckbox.is( ':checked' );
|
||||
|
||||
$card.removeClass( 'wpmind-card-error' );
|
||||
|
||||
if ( $checkbox.is( ':checked' ) && ! $apiKey.val() && ! hasExistingKey && ! willClear ) {
|
||||
hasEnabledWithoutKey = true;
|
||||
$problemCard = $card;
|
||||
$card.addClass( 'wpmind-card-error' );
|
||||
return false;
|
||||
}
|
||||
} );
|
||||
|
||||
if ( hasEnabledWithoutKey ) {
|
||||
e.preventDefault();
|
||||
Toast.error( '请为已启用的服务填写 API Key' );
|
||||
if ( $problemCard ) {
|
||||
$( 'html, body' ).animate( {
|
||||
scrollTop: $problemCard.offset().top - 100
|
||||
}, 300 );
|
||||
$problemCard.find( 'input[id^="api_key_"]' ).focus();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* 折叠/展开高级设置
|
||||
*/
|
||||
function initAdvancedToggle() {
|
||||
$( '.wpmind-toggle-advanced' ).on( 'click', function( e ) {
|
||||
e.preventDefault();
|
||||
|
||||
var $button = $( this );
|
||||
var $card = $button.closest( '.wpmind-endpoint-card' );
|
||||
var $advanced = $card.find( '.wpmind-advanced-settings' );
|
||||
var $icon = $button.find( '.dashicons' );
|
||||
|
||||
if ( $advanced.is( ':visible' ) ) {
|
||||
$advanced.slideUp( 200 );
|
||||
$icon.removeClass( 'ri-arrow-up-s-line' ).addClass( 'ri-arrow-down-s-line' );
|
||||
} else {
|
||||
$advanced.slideDown( 200 );
|
||||
$icon.removeClass( 'ri-arrow-down-s-line' ).addClass( 'ri-arrow-up-s-line' );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize on document ready
|
||||
*/
|
||||
$( function() {
|
||||
if ( ! $( '.wpmind-endpoint-card' ).length && ! $( '#wpmind-settings-form' ).length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var safeInit = Admin.safeInit || function( label, fn ) {
|
||||
try {
|
||||
fn();
|
||||
} catch ( error ) {
|
||||
console.warn( '[WPMind] ' + label + ' init failed:', error );
|
||||
}
|
||||
};
|
||||
|
||||
safeInit( 'endpoints:password-toggle', initPasswordToggle );
|
||||
safeInit( 'endpoints:collapse', initEndpointCollapse );
|
||||
safeInit( 'endpoints:key-validation', initApiKeyValidation );
|
||||
safeInit( 'endpoints:test-connection', initTestConnection );
|
||||
safeInit( 'endpoints:test-image', initImageTestConnection );
|
||||
safeInit( 'endpoints:status-update', initStatusUpdate );
|
||||
safeInit( 'endpoints:clear-key', initClearKeyHandler );
|
||||
safeInit( 'endpoints:form-validation', initFormValidation );
|
||||
safeInit( 'endpoints:advanced-toggle', initAdvancedToggle );
|
||||
} );
|
||||
} )( jQuery );
|
||||
275
assets/js/admin-exact-cache.js
Normal file
275
assets/js/admin-exact-cache.js
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
/**
|
||||
* WPMind Admin Exact Cache handlers.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.6.0
|
||||
*/
|
||||
|
||||
( function( $ ) {
|
||||
'use strict';
|
||||
|
||||
var Admin = window.WPMindAdmin || ( window.WPMindAdmin = {} );
|
||||
var Toast = Admin.Toast || {
|
||||
success: function() {},
|
||||
error: function() {},
|
||||
warning: function() {},
|
||||
info: function() {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Exact Cache Manager
|
||||
*/
|
||||
var CacheManager = {
|
||||
chart: null,
|
||||
|
||||
init: function() {
|
||||
this.bindEvents();
|
||||
this.loadStats();
|
||||
},
|
||||
|
||||
bindEvents: function() {
|
||||
var self = this;
|
||||
$( '#wpmind-save-cache-settings' ).on( 'click', function() {
|
||||
self.saveSettings();
|
||||
} );
|
||||
$( '#wpmind-flush-cache' ).on( 'click', function() {
|
||||
self.flushCache();
|
||||
} );
|
||||
$( '#wpmind-reset-cache-stats' ).on( 'click', function() {
|
||||
self.resetStats();
|
||||
} );
|
||||
$( '.wpmind-refresh-cache-stats' ).on( 'click', function() {
|
||||
self.loadStats();
|
||||
} );
|
||||
},
|
||||
|
||||
loadStats: function() {
|
||||
var self = this;
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl,
|
||||
type: 'GET',
|
||||
data: {
|
||||
action: 'wpmind_get_cache_stats',
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
self.updateCards( response.data.stats, response.data.savings );
|
||||
self.renderChart( response.data.daily );
|
||||
}
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
updateCards: function( stats, savings ) {
|
||||
var hits = parseInt( stats.hits || 0, 10 );
|
||||
var misses = parseInt( stats.misses || 0, 10 );
|
||||
var total = hits + misses;
|
||||
var rate = total > 0 ? ( hits / total * 100 ).toFixed( 1 ) : '0';
|
||||
|
||||
$( '#wpmind-cache-hit-rate' ).text( rate + '%' );
|
||||
$( '#wpmind-cache-entries' ).text( stats.entries || 0 );
|
||||
$( '#wpmind-cache-savings' ).text( '$' + ( savings.total_usd || 0 ) );
|
||||
$( '#wpmind-cache-total-req' ).text( total );
|
||||
},
|
||||
|
||||
renderChart: function( daily ) {
|
||||
if ( typeof Chart === 'undefined' ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var canvas = document.getElementById( 'wpmind-cache-trend-canvas' );
|
||||
if ( ! canvas ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var labels = [];
|
||||
var hitsData = [];
|
||||
var missesData = [];
|
||||
|
||||
$.each( daily, function( date, metrics ) {
|
||||
labels.push( date.substring( 5 ) ); // MM-DD
|
||||
hitsData.push( metrics.hits || 0 );
|
||||
missesData.push( metrics.misses || 0 );
|
||||
} );
|
||||
|
||||
if ( this.chart ) {
|
||||
this.chart.destroy();
|
||||
}
|
||||
|
||||
this.chart = new Chart( canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '命中',
|
||||
data: hitsData,
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3
|
||||
},
|
||||
{
|
||||
label: '未命中',
|
||||
data: missesData,
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
align: 'end',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
padding: 16,
|
||||
font: { size: 12 }
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { precision: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
saveSettings: function() {
|
||||
var $button = $( '#wpmind-save-cache-settings' );
|
||||
var originalText = $button.html();
|
||||
|
||||
$button.html( '<span class="dashicons ri-loader-4-line"></span> 保存中...' ).prop( 'disabled', true );
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_save_cache_settings',
|
||||
nonce: wpmindData.nonce,
|
||||
enabled: $( 'input[name="wpmind_cache_enabled"]' ).is( ':checked' ) ? '1' : '0',
|
||||
default_ttl: $( 'input[name="wpmind_cache_default_ttl"]' ).val(),
|
||||
max_entries: $( 'input[name="wpmind_cache_max_entries"]' ).val(),
|
||||
scope_mode: $( 'select[name="wpmind_cache_scope_mode"]' ).val()
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
$button.html( '<span class="dashicons ri-check-line"></span> 已保存' );
|
||||
Toast.success( '缓存设置已保存' );
|
||||
} else {
|
||||
$button.html( '<span class="dashicons ri-error-warning-line"></span> 保存失败' );
|
||||
Toast.error( response.data && response.data.message || '保存失败' );
|
||||
}
|
||||
setTimeout( function() {
|
||||
$button.html( originalText ).prop( 'disabled', false );
|
||||
}, 1500 );
|
||||
},
|
||||
error: function() {
|
||||
$button.html( '<span class="dashicons ri-error-warning-line"></span> 网络错误' );
|
||||
setTimeout( function() {
|
||||
$button.html( originalText ).prop( 'disabled', false );
|
||||
}, 2000 );
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
flushCache: function() {
|
||||
if ( ! confirm( '确定要清空所有缓存条目吗?此操作不可撤销。' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
var $button = $( '#wpmind-flush-cache' );
|
||||
$button.prop( 'disabled', true );
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_flush_cache',
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
Toast.success( '缓存已清空' );
|
||||
self.loadStats();
|
||||
} else {
|
||||
Toast.error( '清空失败' );
|
||||
}
|
||||
$button.prop( 'disabled', false );
|
||||
},
|
||||
error: function() {
|
||||
Toast.error( '网络错误' );
|
||||
$button.prop( 'disabled', false );
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
resetStats: function() {
|
||||
if ( ! confirm( '确定要重置所有统计数据吗?此操作不可撤销。' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
var $button = $( '#wpmind-reset-cache-stats' );
|
||||
$button.prop( 'disabled', true );
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_reset_cache_stats',
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
Toast.success( '统计已重置' );
|
||||
self.loadStats();
|
||||
} else {
|
||||
Toast.error( '重置失败' );
|
||||
}
|
||||
$button.prop( 'disabled', false );
|
||||
},
|
||||
error: function() {
|
||||
Toast.error( '网络错误' );
|
||||
$button.prop( 'disabled', false );
|
||||
}
|
||||
} );
|
||||
}
|
||||
};
|
||||
|
||||
Admin.CacheManager = CacheManager;
|
||||
|
||||
/**
|
||||
* Initialize on document ready.
|
||||
*/
|
||||
$( function() {
|
||||
if ( ! $( '#wpmind-save-cache-settings' ).length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var safeInit = Admin.safeInit || function( label, fn ) {
|
||||
try {
|
||||
fn();
|
||||
} catch ( error ) {
|
||||
console.warn( '[WPMind] ' + label + ' init failed:', error );
|
||||
}
|
||||
};
|
||||
|
||||
safeInit( 'exact-cache', function() {
|
||||
CacheManager.init();
|
||||
} );
|
||||
} );
|
||||
} )( jQuery );
|
||||
194
assets/js/admin-geo.js
Normal file
194
assets/js/admin-geo.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/**
|
||||
* WPMind Admin GEO settings handlers.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.10.0
|
||||
*/
|
||||
|
||||
( function( $ ) {
|
||||
'use strict';
|
||||
|
||||
var Admin = window.WPMindAdmin || ( window.WPMindAdmin = {} );
|
||||
|
||||
/**
|
||||
* GEO Settings Manager
|
||||
*/
|
||||
var GeoManager = {
|
||||
init: function() {
|
||||
this.bindEvents();
|
||||
this.restoreSubTab();
|
||||
},
|
||||
|
||||
bindEvents: function() {
|
||||
var self = this;
|
||||
$( '#wpmind-save-geo' ).on( 'click', function() {
|
||||
self.saveSettings();
|
||||
} );
|
||||
|
||||
// Sub-tab switching.
|
||||
$( '.wpmind-geo-panel .wpmind-module-subtab' ).on( 'click', function() {
|
||||
var tab = $( this ).data( 'tab' );
|
||||
self.switchTab( tab );
|
||||
} );
|
||||
|
||||
// Schema preview tab switching.
|
||||
$( '.wpmind-schema-preview-tabs' ).on( 'click', '.wpmind-schema-tab', function() {
|
||||
var preview = $( this ).data( 'preview' );
|
||||
$( '.wpmind-schema-tab' ).removeClass( 'active' );
|
||||
$( this ).addClass( 'active' );
|
||||
$( '.wpmind-schema-preview-panel' ).hide();
|
||||
$( '.wpmind-schema-preview-panel[data-preview-panel="' + preview + '"]' ).show();
|
||||
} );
|
||||
},
|
||||
|
||||
switchTab: function( tab ) {
|
||||
// Update active button.
|
||||
$( '.wpmind-geo-panel .wpmind-module-subtab' ).removeClass( 'active' );
|
||||
$( '.wpmind-geo-panel .wpmind-module-subtab[data-tab="' + tab + '"]' ).addClass( 'active' );
|
||||
|
||||
// Update active panel.
|
||||
$( '.wpmind-geo-panel .wpmind-module-tab-panel' ).removeClass( 'active' );
|
||||
$( '.wpmind-geo-panel .wpmind-module-tab-panel[data-panel="' + tab + '"]' ).addClass( 'active' );
|
||||
|
||||
// Remember active tab.
|
||||
try {
|
||||
sessionStorage.setItem( 'wpmind_geo_subtab', tab );
|
||||
} catch ( e ) {}
|
||||
},
|
||||
|
||||
restoreSubTab: function() {
|
||||
var tab = 'basics';
|
||||
try {
|
||||
var saved = sessionStorage.getItem( 'wpmind_geo_subtab' );
|
||||
if ( saved && $( '.wpmind-geo-panel .wpmind-module-subtab[data-tab="' + saved + '"]' ).length ) {
|
||||
tab = saved;
|
||||
}
|
||||
} catch ( e ) {}
|
||||
this.switchTab( tab );
|
||||
},
|
||||
|
||||
saveSettings: function() {
|
||||
var $button = $( '#wpmind-save-geo' );
|
||||
var originalText = $button.html();
|
||||
|
||||
// Collect all settings across all tabs.
|
||||
var settings = {
|
||||
// Basics tab.
|
||||
wpmind_geo_enabled: $( 'input[name="wpmind_geo_enabled"]' ).is( ':checked' ) ? 1 : 0,
|
||||
wpmind_chinese_optimize: $( 'input[name="wpmind_chinese_optimize"]' ).is( ':checked' ) ? 1 : 0,
|
||||
wpmind_geo_signals: $( 'input[name="wpmind_geo_signals"]' ).is( ':checked' ) ? 1 : 0,
|
||||
wpmind_crawler_tracking: $( 'input[name="wpmind_crawler_tracking"]' ).is( ':checked' ) ? 1 : 0,
|
||||
|
||||
// Content tab.
|
||||
wpmind_standalone_markdown_feed: $( 'input[name="wpmind_standalone_markdown_feed"]' ).is( ':checked' ) ? 1 : 0,
|
||||
wpmind_llms_txt_enabled: $( 'input[name="wpmind_llms_txt_enabled"]' ).is( ':checked' ) ? 1 : 0,
|
||||
wpmind_ai_sitemap_enabled: $( 'input[name="wpmind_ai_sitemap_enabled"]' ).is( ':checked' ) ? 1 : 0,
|
||||
wpmind_ai_sitemap_max_entries: $( 'input[name="wpmind_ai_sitemap_max_entries"]' ).val() || 500,
|
||||
wpmind_ai_summary_enabled: $( 'input[name="wpmind_ai_summary_enabled"]' ).is( ':checked' ) ? 1 : 0,
|
||||
wpmind_ai_summary_fallback: $( 'select[name="wpmind_ai_summary_fallback"]' ).val() || 'excerpt',
|
||||
|
||||
// Schema tab.
|
||||
wpmind_schema_enabled: $( 'input[name="wpmind_schema_enabled"]' ).is( ':checked' ) ? 1 : 0,
|
||||
wpmind_schema_mode: $( 'select[name="wpmind_schema_mode"]' ).val() || 'auto',
|
||||
wpmind_entity_linker_enabled: $( 'input[name="wpmind_entity_linker_enabled"]' ).is( ':checked' ) ? 1 : 0,
|
||||
|
||||
// Brand entity tab.
|
||||
wpmind_brand_entity_enabled: $( 'input[name="wpmind_brand_entity_enabled"]' ).is( ':checked' ) ? 1 : 0,
|
||||
wpmind_brand_org_type: $( 'select[name="wpmind_brand_org_type"]' ).val() || 'Organization',
|
||||
wpmind_brand_name: $( 'input[name="wpmind_brand_name"]' ).val() || '',
|
||||
wpmind_brand_description: $( 'textarea[name="wpmind_brand_description"]' ).val() || '',
|
||||
wpmind_brand_url: $( 'input[name="wpmind_brand_url"]' ).val() || '',
|
||||
wpmind_brand_founding_date: $( 'input[name="wpmind_brand_founding_date"]' ).val() || '',
|
||||
wpmind_brand_social_facebook: $( 'input[name="wpmind_brand_social_facebook"]' ).val() || '',
|
||||
wpmind_brand_social_twitter: $( 'input[name="wpmind_brand_social_twitter"]' ).val() || '',
|
||||
wpmind_brand_social_linkedin: $( 'input[name="wpmind_brand_social_linkedin"]' ).val() || '',
|
||||
wpmind_brand_social_youtube: $( 'input[name="wpmind_brand_social_youtube"]' ).val() || '',
|
||||
wpmind_brand_social_github: $( 'input[name="wpmind_brand_social_github"]' ).val() || '',
|
||||
wpmind_brand_social_weibo: $( 'input[name="wpmind_brand_social_weibo"]' ).val() || '',
|
||||
wpmind_brand_social_zhihu: $( 'input[name="wpmind_brand_social_zhihu"]' ).val() || '',
|
||||
wpmind_brand_social_wechat: $( 'input[name="wpmind_brand_social_wechat"]' ).val() || '',
|
||||
wpmind_brand_wikidata_url: $( 'input[name="wpmind_brand_wikidata_url"]' ).val() || '',
|
||||
wpmind_brand_wikipedia_url: $( 'input[name="wpmind_brand_wikipedia_url"]' ).val() || '',
|
||||
wpmind_brand_contact_email: $( 'input[name="wpmind_brand_contact_email"]' ).val() || '',
|
||||
wpmind_brand_contact_phone: $( 'input[name="wpmind_brand_contact_phone"]' ).val() || '',
|
||||
|
||||
// Control tab.
|
||||
wpmind_ai_indexing_enabled: $( 'input[name="wpmind_ai_indexing_enabled"]' ).is( ':checked' ) ? 1 : 0,
|
||||
wpmind_ai_default_declaration: $( 'select[name="wpmind_ai_default_declaration"]' ).val() || 'original',
|
||||
wpmind_ai_excluded_post_types: [],
|
||||
wpmind_robots_ai_enabled: $( 'input[name="wpmind_robots_ai_enabled"]' ).is( ':checked' ) ? 1 : 0,
|
||||
wpmind_robots_ai_rules: {}
|
||||
};
|
||||
|
||||
// Collect checked post type exclusions.
|
||||
$( 'input[name="wpmind_ai_excluded_post_types[]"]:checked' ).each( function() {
|
||||
settings.wpmind_ai_excluded_post_types.push( $( this ).val() );
|
||||
} );
|
||||
|
||||
// Collect robots.txt AI rules.
|
||||
$( 'select[name^="wpmind_robots_ai_rules["]' ).each( function() {
|
||||
var name = $( this ).attr( 'name' );
|
||||
var match = name.match( /\[(.+?)\]/ );
|
||||
if ( match ) {
|
||||
settings.wpmind_robots_ai_rules[ match[1] ] = $( this ).val();
|
||||
}
|
||||
} );
|
||||
|
||||
// Show loading state.
|
||||
$button.html( '<span class="dashicons ri-loader-4-line"></span> 保存中...' ).prop( 'disabled', true );
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_save_geo_settings',
|
||||
nonce: wpmindData.nonce,
|
||||
settings: settings
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
$button.html( '<span class="dashicons ri-check-line"></span> 已保存' );
|
||||
setTimeout( function() {
|
||||
$button.html( originalText ).prop( 'disabled', false );
|
||||
location.reload();
|
||||
}, 1500 );
|
||||
} else {
|
||||
$button.html( '<span class="dashicons ri-error-warning-line"></span> 保存失败' );
|
||||
setTimeout( function() {
|
||||
$button.html( originalText ).prop( 'disabled', false );
|
||||
}, 2000 );
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$button.html( '<span class="dashicons ri-error-warning-line"></span> 网络错误' );
|
||||
setTimeout( function() {
|
||||
$button.html( originalText ).prop( 'disabled', false );
|
||||
}, 2000 );
|
||||
}
|
||||
} );
|
||||
}
|
||||
};
|
||||
|
||||
Admin.GeoManager = GeoManager;
|
||||
|
||||
/**
|
||||
* Initialize on document ready
|
||||
*/
|
||||
$( function() {
|
||||
if ( ! $( '#wpmind-save-geo' ).length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var safeInit = Admin.safeInit || function( label, fn ) {
|
||||
try {
|
||||
fn();
|
||||
} catch ( error ) {
|
||||
console.warn( '[WPMind] ' + label + ' init failed:', error );
|
||||
}
|
||||
};
|
||||
|
||||
safeInit( 'geo', function() {
|
||||
GeoManager.init();
|
||||
} );
|
||||
} );
|
||||
} )( jQuery );
|
||||
253
assets/js/admin-media-intelligence.js
Normal file
253
assets/js/admin-media-intelligence.js
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* WPMind Admin Media Intelligence handlers.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 4.3.0
|
||||
*/
|
||||
|
||||
( function( $ ) {
|
||||
'use strict';
|
||||
|
||||
var Admin = window.WPMindAdmin || ( window.WPMindAdmin = {} );
|
||||
var Toast = Admin.Toast || {
|
||||
success: function() {},
|
||||
error: function() {},
|
||||
warning: function() {},
|
||||
info: function() {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Media Intelligence Manager
|
||||
*/
|
||||
var MediaManager = {
|
||||
missingCount: 0,
|
||||
totalProcessed: 0,
|
||||
isProcessing: false,
|
||||
|
||||
init: function() {
|
||||
this.bindEvents();
|
||||
this.restoreSubTab();
|
||||
this.loadStats();
|
||||
},
|
||||
|
||||
bindEvents: function() {
|
||||
var self = this;
|
||||
|
||||
// Sub-tab switching (scoped to media panel).
|
||||
$( '.wpmind-media-panel .wpmind-module-subtab' ).on( 'click', function() {
|
||||
self.switchTab( $( this ).data( 'tab' ) );
|
||||
} );
|
||||
|
||||
$( '#wpmind-save-media-settings, #wpmind-save-media-safety' ).on( 'click', function() {
|
||||
self.saveSettings( $( this ) );
|
||||
} );
|
||||
$( '#wpmind-media-scan' ).on( 'click', function() {
|
||||
self.scanImages();
|
||||
} );
|
||||
$( '#wpmind-media-bulk-start' ).on( 'click', function() {
|
||||
self.startBulkProcess();
|
||||
} );
|
||||
},
|
||||
|
||||
switchTab: function( tab ) {
|
||||
$( '.wpmind-media-panel .wpmind-module-subtab' ).removeClass( 'active' );
|
||||
$( '.wpmind-media-panel .wpmind-module-subtab[data-tab="' + tab + '"]' ).addClass( 'active' );
|
||||
|
||||
$( '.wpmind-media-panel .wpmind-module-tab-panel' ).removeClass( 'active' );
|
||||
$( '.wpmind-media-panel .wpmind-module-tab-panel[data-panel="' + tab + '"]' ).addClass( 'active' );
|
||||
|
||||
try {
|
||||
sessionStorage.setItem( 'wpmind_mi_subtab', tab );
|
||||
} catch ( e ) {}
|
||||
},
|
||||
|
||||
restoreSubTab: function() {
|
||||
var tab = 'settings';
|
||||
try {
|
||||
var saved = sessionStorage.getItem( 'wpmind_mi_subtab' );
|
||||
if ( saved && $( '.wpmind-media-panel .wpmind-module-subtab[data-tab="' + saved + '"]' ).length ) {
|
||||
tab = saved;
|
||||
}
|
||||
} catch ( e ) {}
|
||||
this.switchTab( tab );
|
||||
},
|
||||
|
||||
loadStats: function() {
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl,
|
||||
type: 'GET',
|
||||
data: {
|
||||
action: 'wpmind_media_get_stats',
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
$( '#wpmind-media-total-gen' ).text( response.data.total_generated );
|
||||
$( '#wpmind-media-month-gen' ).text( response.data.month_generated );
|
||||
}
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
saveSettings: function( $button ) {
|
||||
$button = $button || $( '#wpmind-save-media-settings' );
|
||||
var originalText = $button.html();
|
||||
|
||||
$button.html( '<span class="dashicons ri-loader-4-line"></span> 保存中...' ).prop( 'disabled', true );
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_save_media_settings',
|
||||
nonce: wpmindData.nonce,
|
||||
auto_alt: $( 'input[name="wpmind_media_auto_alt"]' ).is( ':checked' ) ? '1' : '0',
|
||||
auto_title: $( 'input[name="wpmind_media_auto_title"]' ).is( ':checked' ) ? '1' : '0',
|
||||
nsfw_enabled: $( 'input[name="wpmind_media_nsfw_enabled"]' ).is( ':checked' ) ? '1' : '0',
|
||||
language: $( 'select[name="wpmind_media_language"]' ).val()
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
$button.html( '<span class="dashicons ri-check-line"></span> 已保存' );
|
||||
Toast.success( '媒体智能设置已保存' );
|
||||
} else {
|
||||
$button.html( '<span class="dashicons ri-error-warning-line"></span> 保存失败' );
|
||||
Toast.error( response.data && response.data.message || '保存失败' );
|
||||
}
|
||||
setTimeout( function() {
|
||||
$button.html( originalText ).prop( 'disabled', false );
|
||||
}, 1500 );
|
||||
},
|
||||
error: function() {
|
||||
$button.html( '<span class="dashicons ri-error-warning-line"></span> 网络错误' );
|
||||
setTimeout( function() {
|
||||
$button.html( originalText ).prop( 'disabled', false );
|
||||
}, 2000 );
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
scanImages: function() {
|
||||
var self = this;
|
||||
var $button = $( '#wpmind-media-scan' );
|
||||
$button.prop( 'disabled', true );
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl,
|
||||
type: 'GET',
|
||||
data: {
|
||||
action: 'wpmind_media_bulk_scan',
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
self.missingCount = response.data.missing_alt;
|
||||
$( '#wpmind-media-missing' ).text( self.missingCount );
|
||||
|
||||
if ( self.missingCount > 0 ) {
|
||||
$( '#wpmind-media-bulk-start' ).prop( 'disabled', false );
|
||||
Toast.info( '发现 ' + self.missingCount + ' 张图片缺少 Alt Text' );
|
||||
} else {
|
||||
Toast.success( '所有图片都已有 Alt Text' );
|
||||
}
|
||||
} else {
|
||||
Toast.error( '扫描失败' );
|
||||
}
|
||||
$button.prop( 'disabled', false );
|
||||
},
|
||||
error: function() {
|
||||
Toast.error( '网络错误' );
|
||||
$button.prop( 'disabled', false );
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
startBulkProcess: function() {
|
||||
if ( this.isProcessing ) {
|
||||
return;
|
||||
}
|
||||
this.isProcessing = true;
|
||||
this.totalProcessed = 0;
|
||||
|
||||
$( '#wpmind-media-bulk-start' ).prop( 'disabled', true );
|
||||
$( '.wpmind-media-progress' ).show();
|
||||
|
||||
this.processBatch();
|
||||
},
|
||||
|
||||
processBatch: function() {
|
||||
var self = this;
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_media_bulk_process',
|
||||
nonce: wpmindData.nonce,
|
||||
offset: self.totalProcessed
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( ! response.success ) {
|
||||
self.finishBulk( '处理出错' );
|
||||
return;
|
||||
}
|
||||
|
||||
self.totalProcessed += response.data.processed;
|
||||
|
||||
// Update progress bar.
|
||||
var total = self.missingCount || 1;
|
||||
var pct = Math.min( 100, Math.round( self.totalProcessed / total * 100 ) );
|
||||
$( '.wpmind-media-progress-fill' ).css( 'width', pct + '%' );
|
||||
$( '.wpmind-media-progress-text' ).text( pct + '% (' + self.totalProcessed + '/' + total + ')' );
|
||||
|
||||
if ( response.data.done ) {
|
||||
self.finishBulk( '批量处理完成,共处理 ' + self.totalProcessed + ' 张图片' );
|
||||
} else {
|
||||
// Continue with next batch.
|
||||
self.processBatch();
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
self.finishBulk( '网络错误,已处理 ' + self.totalProcessed + ' 张' );
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
finishBulk: function( message ) {
|
||||
this.isProcessing = false;
|
||||
$( '.wpmind-media-progress-fill' ).css( 'width', '100%' );
|
||||
$( '.wpmind-media-progress-text' ).text( '100%' );
|
||||
Toast.success( message );
|
||||
this.loadStats();
|
||||
|
||||
// Re-enable scan button after a short delay.
|
||||
setTimeout( function() {
|
||||
$( '#wpmind-media-bulk-start' ).prop( 'disabled', true );
|
||||
$( '.wpmind-media-progress' ).fadeOut();
|
||||
}, 3000 );
|
||||
}
|
||||
};
|
||||
|
||||
Admin.MediaManager = MediaManager;
|
||||
|
||||
/**
|
||||
* Initialize on document ready.
|
||||
*/
|
||||
$( function() {
|
||||
if ( ! $( '#wpmind-save-media-settings' ).length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var safeInit = Admin.safeInit || function( label, fn ) {
|
||||
try {
|
||||
fn();
|
||||
} catch ( error ) {
|
||||
console.warn( '[WPMind] ' + label + ' init failed:', error );
|
||||
}
|
||||
};
|
||||
|
||||
safeInit( 'media-intelligence', function() {
|
||||
MediaManager.init();
|
||||
} );
|
||||
} );
|
||||
} )( jQuery );
|
||||
84
assets/js/admin-modules.js
Normal file
84
assets/js/admin-modules.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* WPMind Admin modules toggle handlers.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.3.0
|
||||
*/
|
||||
|
||||
( function( $ ) {
|
||||
'use strict';
|
||||
|
||||
var Admin = window.WPMindAdmin || ( window.WPMindAdmin = {} );
|
||||
var Toast = Admin.Toast || null;
|
||||
var notifyError = Toast ? Toast.error.bind( Toast ) : function( message ) {
|
||||
alert( message );
|
||||
};
|
||||
|
||||
function initModuleSwitches() {
|
||||
$( '.wpmind-module-switch' ).on( 'change', function() {
|
||||
var $switch = $( this );
|
||||
var moduleId = $switch.data( 'module-id' );
|
||||
var enable = $switch.is( ':checked' );
|
||||
var $card = $switch.closest( '.wpmind-module-card' );
|
||||
|
||||
$switch.prop( 'disabled', true );
|
||||
|
||||
if ( 'undefined' === typeof wpmindData ) {
|
||||
notifyError( '配置错误' );
|
||||
$switch.prop( 'checked', ! enable ).prop( 'disabled', false );
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_toggle_module',
|
||||
nonce: wpmindData.nonce,
|
||||
module_id: moduleId,
|
||||
// Use string '1'/'0' instead of boolean to ensure reliable transmission.
|
||||
// jQuery may serialize boolean false inconsistently.
|
||||
enable: enable ? '1' : '0'
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
if ( response.data.reload ) {
|
||||
location.reload();
|
||||
} else {
|
||||
$card.toggleClass( 'is-enabled', enable ).toggleClass( 'is-disabled', ! enable );
|
||||
}
|
||||
} else {
|
||||
notifyError( response.data.message || '操作失败' );
|
||||
$switch.prop( 'checked', ! enable );
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
notifyError( '网络错误' );
|
||||
$switch.prop( 'checked', ! enable );
|
||||
},
|
||||
complete: function() {
|
||||
$switch.prop( 'disabled', false );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize on document ready
|
||||
*/
|
||||
$( function() {
|
||||
if ( ! $( '.wpmind-module-switch' ).length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var safeInit = Admin.safeInit || function( label, fn ) {
|
||||
try {
|
||||
fn();
|
||||
} catch ( error ) {
|
||||
console.warn( '[WPMind] ' + label + ' init failed:', error );
|
||||
}
|
||||
};
|
||||
|
||||
safeInit( 'modules', initModuleSwitches );
|
||||
} );
|
||||
} )( jQuery );
|
||||
343
assets/js/admin-routing.js
Normal file
343
assets/js/admin-routing.js
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
/**
|
||||
* WPMind Admin routing panel handlers.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.3.0
|
||||
*/
|
||||
|
||||
( function( $ ) {
|
||||
'use strict';
|
||||
|
||||
var Admin = window.WPMindAdmin || ( window.WPMindAdmin = {} );
|
||||
var Toast = Admin.Toast || {
|
||||
success: function() {},
|
||||
error: function() {},
|
||||
warning: function() {},
|
||||
info: function() {}
|
||||
};
|
||||
var Dialog = Admin.Dialog || {
|
||||
show: function() {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Routing Panel 路由管理
|
||||
*/
|
||||
var RoutingManager = {
|
||||
init: function() {
|
||||
if ( ! $( '.wpmind-routing-panel' ).length ) return;
|
||||
this.bindEvents();
|
||||
},
|
||||
|
||||
bindEvents: function() {
|
||||
var self = this;
|
||||
|
||||
// 策略选择 - 监听 radio change 事件
|
||||
$( document ).on( 'change', 'input[name="routing_strategy"]', function() {
|
||||
var strategy = $( this ).val();
|
||||
self.setStrategy( strategy );
|
||||
} );
|
||||
|
||||
// 策略卡片点击 - 备用方案
|
||||
$( document ).on( 'click', '.wpmind-strategy-item', function() {
|
||||
var $radio = $( this ).find( 'input[type="radio"]' );
|
||||
if ( ! $radio.prop( 'checked' ) ) {
|
||||
$radio.prop( 'checked', true ).trigger( 'change' );
|
||||
}
|
||||
} );
|
||||
|
||||
// 刷新路由状态
|
||||
$( document ).on( 'click', '.wpmind-refresh-routing', function( e ) {
|
||||
e.preventDefault();
|
||||
var $btn = $( this );
|
||||
$btn.find( '.dashicons' ).addClass( 'wpmind-spinning' );
|
||||
self.refreshStatus( function() {
|
||||
$btn.find( '.dashicons' ).removeClass( 'wpmind-spinning' );
|
||||
} );
|
||||
} );
|
||||
|
||||
// 初始化拖拽排序
|
||||
self.initSortable();
|
||||
|
||||
// 保存优先级
|
||||
$( document ).on( 'click', '.wpmind-save-priority', function( e ) {
|
||||
e.preventDefault();
|
||||
self.savePriority();
|
||||
} );
|
||||
|
||||
// 清除优先级
|
||||
$( document ).on( 'click', '.wpmind-clear-priority', function( e ) {
|
||||
e.preventDefault();
|
||||
self.clearPriority();
|
||||
} );
|
||||
},
|
||||
|
||||
initSortable: function() {
|
||||
var $list = $( '#wpmind-priority-list' );
|
||||
if ( ! $list.length ) return;
|
||||
|
||||
// 检查 jQuery UI sortable 是否可用
|
||||
if ( 'function' !== typeof $.fn.sortable ) {
|
||||
console.warn( 'WPMind: jQuery UI Sortable not available' );
|
||||
return;
|
||||
}
|
||||
|
||||
$list.sortable( {
|
||||
handle: '.wpmind-priority-handle',
|
||||
placeholder: 'wpmind-priority-placeholder',
|
||||
axis: 'y',
|
||||
tolerance: 'pointer',
|
||||
update: function() {
|
||||
// 更新序号显示
|
||||
$list.find( '.wpmind-priority-item' ).each( function( index ) {
|
||||
$( this ).find( '.wpmind-priority-index' ).text( index + 1 );
|
||||
} );
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
savePriority: function() {
|
||||
var self = this;
|
||||
if ( 'undefined' === typeof wpmindData ) {
|
||||
Toast.error( '配置错误' );
|
||||
return;
|
||||
}
|
||||
|
||||
var $list = $( '#wpmind-priority-list' );
|
||||
var priority = [];
|
||||
$list.find( '.wpmind-priority-item' ).each( function() {
|
||||
priority.push( $( this ).data( 'provider' ) );
|
||||
} );
|
||||
|
||||
if ( 0 === priority.length ) {
|
||||
Toast.warning( '没有可排序的 Provider' );
|
||||
return;
|
||||
}
|
||||
|
||||
var $btn = $( '.wpmind-save-priority' );
|
||||
$btn.prop( 'disabled', true ).find( '.dashicons' ).addClass( 'wpmind-spinning' );
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl || ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_set_provider_priority',
|
||||
priority: priority,
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
Toast.success( '优先级已保存' );
|
||||
// 显示清除按钮
|
||||
if ( ! $( '.wpmind-clear-priority' ).length ) {
|
||||
$( '.wpmind-routing-priority-actions' ).prepend(
|
||||
'<button type="button" class="button button-small wpmind-clear-priority" title="清除手动优先级">' +
|
||||
'<span class="dashicons ri-delete-bin-line"></span> 清除</button>'
|
||||
);
|
||||
}
|
||||
// 显示已启用标记
|
||||
if ( ! $( '.wpmind-priority-badge' ).length ) {
|
||||
$( '.wpmind-routing-priority .wpmind-routing-section-desc' ).append(
|
||||
' <span class="wpmind-priority-badge">已启用手动优先级</span>'
|
||||
);
|
||||
}
|
||||
// 刷新路由状态
|
||||
self.refreshStatus();
|
||||
} else {
|
||||
var msg = ( response.data && response.data.message ) || '保存失败';
|
||||
Toast.error( msg );
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
Toast.error( '保存失败,请重试' );
|
||||
},
|
||||
complete: function() {
|
||||
$btn.prop( 'disabled', false ).find( '.dashicons' ).removeClass( 'wpmind-spinning' );
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
clearPriority: function() {
|
||||
var self = this;
|
||||
if ( 'undefined' === typeof wpmindData ) {
|
||||
Toast.error( '配置错误' );
|
||||
return;
|
||||
}
|
||||
|
||||
Dialog.show( {
|
||||
title: '清除手动优先级',
|
||||
message: '确定要清除手动优先级设置吗?<br><small style="color:#666;">清除后将使用智能路由自动排序</small>',
|
||||
type: 'warning',
|
||||
confirmText: '确定清除',
|
||||
cancelText: '取消',
|
||||
onConfirm: function() {
|
||||
var $btn = $( '.wpmind-clear-priority' );
|
||||
$btn.prop( 'disabled', true ).find( '.dashicons' ).addClass( 'wpmind-spinning' );
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl || ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_set_provider_priority',
|
||||
priority: [],
|
||||
clear: 1,
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
Toast.success( '手动优先级已清除' );
|
||||
// 移除清除按钮和标记
|
||||
$( '.wpmind-clear-priority' ).remove();
|
||||
$( '.wpmind-priority-badge' ).remove();
|
||||
// 刷新路由状态
|
||||
self.refreshStatus();
|
||||
} else {
|
||||
var msg = ( response.data && response.data.message ) || '清除失败';
|
||||
Toast.error( msg );
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
Toast.error( '清除失败,请重试' );
|
||||
},
|
||||
complete: function() {
|
||||
$btn.prop( 'disabled', false ).find( '.dashicons' ).removeClass( 'wpmind-spinning' );
|
||||
}
|
||||
} );
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
setStrategy: function( strategy ) {
|
||||
if ( 'undefined' === typeof wpmindData ) {
|
||||
Toast.error( '配置错误' );
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新 UI 状态
|
||||
$( '.wpmind-strategy-item' ).removeClass( 'is-active' );
|
||||
$( 'input[name="routing_strategy"][value="' + strategy + '"]' )
|
||||
.closest( '.wpmind-strategy-item' )
|
||||
.addClass( 'is-active' );
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl || ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_set_routing_strategy',
|
||||
strategy: strategy,
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success ) {
|
||||
Toast.success( '路由策略已更新' );
|
||||
// 刷新得分显示
|
||||
RoutingManager.refreshStatus();
|
||||
} else {
|
||||
var msg = ( response.data && response.data.message ) || '更新失败';
|
||||
Toast.error( msg );
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
Toast.error( '更新失败,请重试' );
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
refreshStatus: function( callback ) {
|
||||
if ( 'undefined' === typeof wpmindData ) {
|
||||
if ( 'function' === typeof callback ) callback();
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax( {
|
||||
url: wpmindData.ajaxurl || ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wpmind_get_routing_status',
|
||||
nonce: wpmindData.nonce
|
||||
},
|
||||
success: function( response ) {
|
||||
if ( response.success && response.data ) {
|
||||
RoutingManager.updateDisplay( response.data );
|
||||
Toast.success( '路由状态已刷新' );
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
Toast.error( '刷新失败' );
|
||||
},
|
||||
complete: function() {
|
||||
if ( 'function' === typeof callback ) callback();
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
updateDisplay: function( data ) {
|
||||
// 更新得分排名
|
||||
var $scores = $( '#wpmind-routing-scores' );
|
||||
if ( $scores.length && data.provider_scores ) {
|
||||
$scores.empty();
|
||||
$.each( data.provider_scores, function( providerId, scoreData ) {
|
||||
var isTop = 1 === scoreData.rank;
|
||||
var $item = $( '<div class="wpmind-routing-score-item"></div>' );
|
||||
if ( isTop ) $item.addClass( 'is-top' );
|
||||
$item.append( $( '<span class="wpmind-routing-rank"></span>' ).text( scoreData.rank ) );
|
||||
$item.append( $( '<span class="wpmind-routing-provider-name"></span>' ).text( scoreData.name ) );
|
||||
var $bar = $( '<div class="wpmind-routing-score-bar"></div>' );
|
||||
$bar.append( $( '<div class="wpmind-routing-score-fill"></div>' ).css( 'width', scoreData.score + '%' ) );
|
||||
$item.append( $bar );
|
||||
$item.append( $( '<span class="wpmind-routing-score-value"></span>' ).text( scoreData.score.toFixed( 1 ) ) );
|
||||
$scores.append( $item );
|
||||
} );
|
||||
}
|
||||
|
||||
// 更新推荐 Provider
|
||||
if ( data.recommended && data.provider_scores && data.provider_scores[ data.recommended ] ) {
|
||||
var recommendedData = data.provider_scores[ data.recommended ];
|
||||
$( '#wpmind-recommended-provider' ).text( recommendedData.name );
|
||||
// 更新得分显示
|
||||
$( '.wpmind-routing-status-score-value' ).text( recommendedData.score.toFixed( 1 ) );
|
||||
}
|
||||
|
||||
// 更新故障转移链 - 新的可视化结构
|
||||
var $failoverChain = $( '#wpmind-failover-chain' );
|
||||
if ( $failoverChain.length && data.failover_chain && data.failover_chain.length ) {
|
||||
$failoverChain.empty();
|
||||
$.each( data.failover_chain, function( index, provider ) {
|
||||
var isFirst = 0 === index;
|
||||
var $node = $( '<div class="wpmind-routing-failover-node"></div>' );
|
||||
if ( isFirst ) $node.addClass( 'is-active' );
|
||||
$node.append( '<span class="wpmind-routing-failover-dot"></span>' );
|
||||
$node.append( $( '<span class="wpmind-routing-failover-name"></span>' ).text( provider ) );
|
||||
if ( isFirst ) {
|
||||
$node.append( '<span class="wpmind-routing-failover-badge">主</span>' );
|
||||
}
|
||||
$failoverChain.append( $node );
|
||||
// 添加连接线(除了最后一个)
|
||||
if ( index < data.failover_chain.length - 1 ) {
|
||||
$failoverChain.append( '<div class="wpmind-routing-failover-line"></div>' );
|
||||
}
|
||||
} );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Admin.RoutingManager = RoutingManager;
|
||||
|
||||
/**
|
||||
* Initialize on document ready
|
||||
*/
|
||||
$( function() {
|
||||
if ( ! $( '.wpmind-routing-panel' ).length ) return;
|
||||
|
||||
var safeInit = Admin.safeInit || function( label, fn ) {
|
||||
try {
|
||||
fn();
|
||||
} catch ( error ) {
|
||||
console.warn( '[WPMind] ' + label + ' init failed:', error );
|
||||
}
|
||||
};
|
||||
|
||||
safeInit( 'routing', function() {
|
||||
RoutingManager.init();
|
||||
} );
|
||||
} );
|
||||
} )( jQuery );
|
||||
203
assets/js/admin-ui.js
Normal file
203
assets/js/admin-ui.js
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* WPMind Admin UI helpers.
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.3.0
|
||||
*/
|
||||
|
||||
( function( $ ) {
|
||||
'use strict';
|
||||
|
||||
var Admin = window.WPMindAdmin || ( window.WPMindAdmin = {} );
|
||||
|
||||
/**
|
||||
* HTML 转义函数 - 防止 XSS
|
||||
*/
|
||||
Admin.escapeHtml = function( text ) {
|
||||
if ( 'string' !== typeof text ) return '';
|
||||
var div = document.createElement( 'div' );
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
/**
|
||||
* Toast 通知系统 - 使用 WordPress 原生 notice 样式
|
||||
*/
|
||||
Admin.Toast = {
|
||||
container: null,
|
||||
|
||||
init: function() {
|
||||
if ( ! this.container ) {
|
||||
// 在 wpmind-title 下方创建通知容器
|
||||
this.container = $( '<div class="wpmind-notice-container"></div>' );
|
||||
$( '.wpmind-title' ).after( this.container );
|
||||
}
|
||||
},
|
||||
|
||||
show: function( message, type, duration ) {
|
||||
this.init();
|
||||
type = type || 'info';
|
||||
duration = duration || 3000;
|
||||
|
||||
// WordPress 原生 notice 类型映射
|
||||
var noticeType = {
|
||||
success: 'notice-success',
|
||||
error: 'notice-error',
|
||||
warning: 'notice-warning',
|
||||
info: 'notice-info'
|
||||
};
|
||||
|
||||
// 图标映射
|
||||
var icons = {
|
||||
success: 'ri-checkbox-circle-line',
|
||||
error: 'ri-close-circle-line',
|
||||
warning: 'ri-alert-line',
|
||||
info: 'ri-information-line'
|
||||
};
|
||||
|
||||
var $notice = $( '<div class="notice ' + noticeType[ type ] + ' is-dismissible wpmind-notice">' +
|
||||
'<p><span class="dashicons ' + icons[ type ] + ' wpmind-notice-icon"></span><span class="wpmind-notice-text"></span></p>' +
|
||||
'</div>' );
|
||||
|
||||
// 使用 .text() 防止 XSS
|
||||
$notice.find( '.wpmind-notice-text' ).text( message );
|
||||
|
||||
this.container.append( $notice );
|
||||
|
||||
// 添加 WordPress 原生关闭按钮
|
||||
$notice.append( '<button type="button" class="notice-dismiss"><span class="screen-reader-text">关闭此通知</span></button>' );
|
||||
|
||||
// 动画显示
|
||||
$notice.hide().slideDown( 200 );
|
||||
|
||||
// 关闭按钮事件
|
||||
$notice.find( '.notice-dismiss' ).on( 'click', function() {
|
||||
Admin.Toast.hide( $notice );
|
||||
} );
|
||||
|
||||
// 自动关闭
|
||||
if ( 0 < duration ) {
|
||||
setTimeout( function() {
|
||||
Admin.Toast.hide( $notice );
|
||||
}, duration );
|
||||
}
|
||||
|
||||
return $notice;
|
||||
},
|
||||
|
||||
hide: function( $notice ) {
|
||||
$notice.slideUp( 200, function() {
|
||||
$( this ).remove();
|
||||
} );
|
||||
},
|
||||
|
||||
success: function( message, duration ) {
|
||||
return this.show( message, 'success', duration );
|
||||
},
|
||||
|
||||
error: function( message, duration ) {
|
||||
// 错误消息显示更长时间
|
||||
return this.show( message, 'error', duration || 8000 );
|
||||
},
|
||||
|
||||
warning: function( message, duration ) {
|
||||
return this.show( message, 'warning', duration || 5000 );
|
||||
},
|
||||
|
||||
info: function( message, duration ) {
|
||||
return this.show( message, 'info', duration );
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 确认对话框
|
||||
*/
|
||||
Admin.Dialog = {
|
||||
show: function( options ) {
|
||||
var defaults = {
|
||||
title: '确认操作',
|
||||
message: '确定要执行此操作吗?',
|
||||
confirmText: '确定',
|
||||
cancelText: '取消',
|
||||
type: 'warning',
|
||||
onConfirm: function() {},
|
||||
onCancel: function() {}
|
||||
};
|
||||
|
||||
var settings = $.extend( {}, defaults, options );
|
||||
|
||||
var icons = {
|
||||
warning: 'ri-alert-line',
|
||||
danger: 'ri-close-circle-line',
|
||||
info: 'ri-information-line',
|
||||
success: 'ri-checkbox-circle-line'
|
||||
};
|
||||
|
||||
var $overlay = $( '<div class="wpmind-dialog-overlay"></div>' );
|
||||
var $dialog = $( '<div class="wpmind-dialog wpmind-dialog-' + settings.type + '">' +
|
||||
'<div class="wpmind-dialog-header">' +
|
||||
'<span class="dashicons ' + icons[ settings.type ] + '"></span>' +
|
||||
'<span class="wpmind-dialog-title">' + settings.title + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="wpmind-dialog-body">' +
|
||||
'<p>' + settings.message + '</p>' +
|
||||
'</div>' +
|
||||
'<div class="wpmind-dialog-footer">' +
|
||||
'<button type="button" class="button wpmind-dialog-cancel">' + settings.cancelText + '</button>' +
|
||||
'<button type="button" class="button button-primary wpmind-dialog-confirm">' + settings.confirmText + '</button>' +
|
||||
'</div>' +
|
||||
'</div>' );
|
||||
|
||||
$( 'body' ).append( $overlay ).append( $dialog );
|
||||
|
||||
// 动画显示
|
||||
setTimeout( function() {
|
||||
$overlay.addClass( 'is-visible' );
|
||||
$dialog.addClass( 'is-visible' );
|
||||
}, 10 );
|
||||
|
||||
// 关闭函数
|
||||
var close = function() {
|
||||
$overlay.removeClass( 'is-visible' );
|
||||
$dialog.removeClass( 'is-visible' );
|
||||
setTimeout( function() {
|
||||
$overlay.remove();
|
||||
$dialog.remove();
|
||||
}, 300 );
|
||||
};
|
||||
|
||||
// 事件绑定
|
||||
$dialog.find( '.wpmind-dialog-cancel' ).on( 'click', function() {
|
||||
close();
|
||||
settings.onCancel();
|
||||
} );
|
||||
|
||||
$dialog.find( '.wpmind-dialog-confirm' ).on( 'click', function() {
|
||||
close();
|
||||
settings.onConfirm();
|
||||
} );
|
||||
|
||||
$overlay.on( 'click', function() {
|
||||
close();
|
||||
settings.onCancel();
|
||||
} );
|
||||
|
||||
// ESC 关闭
|
||||
$( document ).on( 'keydown.wpmind-dialog', function( e ) {
|
||||
if ( 27 === e.keyCode ) {
|
||||
close();
|
||||
settings.onCancel();
|
||||
$( document ).off( 'keydown.wpmind-dialog' );
|
||||
}
|
||||
} );
|
||||
},
|
||||
|
||||
confirm: function( message, onConfirm, onCancel ) {
|
||||
this.show( {
|
||||
message: message,
|
||||
onConfirm: onConfirm || function() {},
|
||||
onCancel: onCancel || function() {}
|
||||
} );
|
||||
}
|
||||
};
|
||||
} )( jQuery );
|
||||
1528
assets/js/admin.js
1528
assets/js/admin.js
File diff suppressed because it is too large
Load diff
14
assets/js/vendor/chartjs/chart.umd.min.js
vendored
Normal file
14
assets/js/vendor/chartjs/chart.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -19,6 +19,11 @@ if [ ! -d "$TARGET_DIR" ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# 生成 .pot 翻译模板
|
||||
echo "🌐 生成 .pot 翻译模板..."
|
||||
mkdir -p "$SOURCE_DIR/languages"
|
||||
wp i18n make-pot "$SOURCE_DIR" "$SOURCE_DIR/languages/wpmind.pot" --domain=wpmind --skip-audit --quiet
|
||||
|
||||
# 同步文件 (排除 .git 目录)
|
||||
echo "📦 同步文件..."
|
||||
sudo rsync -av --delete \
|
||||
|
|
|
|||
311
includes/API/ErrorHandler.php
Normal file
311
includes/API/ErrorHandler.php
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
<?php
|
||||
/**
|
||||
* WPMind 错误处理类
|
||||
*
|
||||
* @package WPMind
|
||||
* @subpackage API
|
||||
* @since 2.5.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\API;
|
||||
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* 统一错误处理类
|
||||
*
|
||||
* 提供标准化的错误创建和处理机制
|
||||
*
|
||||
* @since 2.5.0
|
||||
*/
|
||||
class ErrorHandler {
|
||||
|
||||
/**
|
||||
* 错误代码常量
|
||||
*/
|
||||
// 通用错误
|
||||
const ERROR_NOT_AVAILABLE = 'wpmind_not_available';
|
||||
const ERROR_INVALID_PARAMS = 'wpmind_invalid_params';
|
||||
const ERROR_EMPTY_INPUT = 'wpmind_empty_input';
|
||||
|
||||
// 调用限制错误
|
||||
const ERROR_RECURSIVE_CALL = 'wpmind_recursive_call';
|
||||
const ERROR_CALL_DEPTH_EXCEEDED = 'wpmind_call_depth_exceeded';
|
||||
const ERROR_RATE_LIMITED = 'wpmind_rate_limited';
|
||||
const ERROR_BUDGET_EXCEEDED = 'wpmind_budget_exceeded';
|
||||
|
||||
// API 错误
|
||||
const ERROR_API_ERROR = 'wpmind_api_error';
|
||||
const ERROR_API_TIMEOUT = 'wpmind_api_timeout';
|
||||
const ERROR_API_AUTH = 'wpmind_api_auth';
|
||||
const ERROR_API_QUOTA = 'wpmind_api_quota';
|
||||
|
||||
// 服务商错误
|
||||
const ERROR_PROVIDER_NOT_FOUND = 'wpmind_provider_not_found';
|
||||
const ERROR_PROVIDER_NOT_CONFIGURED = 'wpmind_provider_not_configured';
|
||||
const ERROR_MODEL_NOT_SUPPORTED = 'wpmind_model_not_supported';
|
||||
|
||||
/**
|
||||
* 错误消息映射
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $error_messages = [
|
||||
self::ERROR_NOT_AVAILABLE => 'WPMind 插件未激活或未配置',
|
||||
self::ERROR_INVALID_PARAMS => '无效的参数',
|
||||
self::ERROR_EMPTY_INPUT => '输入内容为空',
|
||||
self::ERROR_RECURSIVE_CALL => '检测到循环调用',
|
||||
self::ERROR_CALL_DEPTH_EXCEEDED => '调用深度超过限制',
|
||||
self::ERROR_RATE_LIMITED => '请求频率过高,请稍后再试',
|
||||
self::ERROR_BUDGET_EXCEEDED => '已超出预算限制',
|
||||
self::ERROR_API_ERROR => 'API 调用失败',
|
||||
self::ERROR_API_TIMEOUT => 'API 请求超时',
|
||||
self::ERROR_API_AUTH => 'API 认证失败,请检查 API Key',
|
||||
self::ERROR_API_QUOTA => 'API 配额已用尽',
|
||||
self::ERROR_PROVIDER_NOT_FOUND => '找不到指定的服务商',
|
||||
self::ERROR_PROVIDER_NOT_CONFIGURED => '服务商未配置',
|
||||
self::ERROR_MODEL_NOT_SUPPORTED => '不支持的模型',
|
||||
];
|
||||
|
||||
/**
|
||||
* 创建标准错误对象
|
||||
*
|
||||
* @param string $code 错误代码(使用类常量)
|
||||
* @param string $message 可选的自定义消息
|
||||
* @param array $data 额外的错误数据
|
||||
* @return WP_Error
|
||||
*/
|
||||
public static function create(string $code, string $message = '', array $data = []): WP_Error {
|
||||
// 如果没有提供消息,使用默认消息
|
||||
if (empty($message)) {
|
||||
$message = self::$error_messages[$code] ?? __('未知错误', 'wpmind');
|
||||
}
|
||||
|
||||
// 添加时间戳和请求 ID
|
||||
$data['timestamp'] = time();
|
||||
$data['request_id'] = self::generate_request_id();
|
||||
|
||||
// 记录错误日志
|
||||
self::log_error($code, $message, $data);
|
||||
|
||||
return new WP_Error($code, __($message, 'wpmind'), $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:创建"不可用"错误
|
||||
*
|
||||
* @return WP_Error
|
||||
*/
|
||||
public static function not_available(): WP_Error {
|
||||
return self::create(self::ERROR_NOT_AVAILABLE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:创建"无效参数"错误
|
||||
*
|
||||
* @param string $param_name 参数名
|
||||
* @param mixed $value 参数值
|
||||
* @return WP_Error
|
||||
*/
|
||||
public static function invalid_param(string $param_name, $value = null): WP_Error {
|
||||
return self::create(
|
||||
self::ERROR_INVALID_PARAMS,
|
||||
sprintf(__('无效的参数: %s', 'wpmind'), $param_name),
|
||||
['param' => $param_name, 'value' => $value]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:创建"API 错误"
|
||||
*
|
||||
* @param string $provider API 服务商
|
||||
* @param string $message 错误消息
|
||||
* @param int $code HTTP 状态码
|
||||
* @return WP_Error
|
||||
*/
|
||||
public static function api_error(string $provider, string $message, int $code = 0): WP_Error {
|
||||
return self::create(
|
||||
self::ERROR_API_ERROR,
|
||||
$message,
|
||||
['provider' => $provider, 'http_code' => $code]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:创建"循环调用"错误
|
||||
*
|
||||
* @param string $method 方法名
|
||||
* @param string $call_id 调用 ID
|
||||
* @return WP_Error
|
||||
*/
|
||||
public static function recursive_call(string $method, string $call_id): WP_Error {
|
||||
return self::create(
|
||||
self::ERROR_RECURSIVE_CALL,
|
||||
sprintf(__('检测到循环调用: %s', 'wpmind'), $method),
|
||||
['method' => $method, 'call_id' => $call_id]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:创建"调用深度超限"错误
|
||||
*
|
||||
* @param string $method 方法名
|
||||
* @param int $depth 当前深度
|
||||
* @param int $max_depth 最大深度
|
||||
* @return WP_Error
|
||||
*/
|
||||
public static function call_depth_exceeded(string $method, int $depth, int $max_depth): WP_Error {
|
||||
return self::create(
|
||||
self::ERROR_CALL_DEPTH_EXCEEDED,
|
||||
sprintf(__('调用深度超限: %s (当前 %d, 最大 %d)', 'wpmind'), $method, $depth, $max_depth),
|
||||
['method' => $method, 'depth' => $depth, 'max_depth' => $max_depth]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 API 响应创建错误
|
||||
*
|
||||
* @param array $response API 响应
|
||||
* @param string $provider 服务商
|
||||
* @return WP_Error
|
||||
*/
|
||||
public static function from_api_response(array $response, string $provider): WP_Error {
|
||||
$http_code = $response['response']['code'] ?? 0;
|
||||
$body = $response['body'] ?? '';
|
||||
|
||||
// 尝试解析 JSON 错误
|
||||
$error_data = json_decode($body, true);
|
||||
$error_message = $error_data['error']['message']
|
||||
?? $error_data['message']
|
||||
?? $body
|
||||
?? __('未知 API 错误', 'wpmind');
|
||||
|
||||
// 根据 HTTP 状态码分类错误
|
||||
$code = self::ERROR_API_ERROR;
|
||||
if ($http_code === 401 || $http_code === 403) {
|
||||
$code = self::ERROR_API_AUTH;
|
||||
} elseif ($http_code === 429) {
|
||||
$code = self::ERROR_RATE_LIMITED;
|
||||
} elseif ($http_code === 408 || $http_code === 504) {
|
||||
$code = self::ERROR_API_TIMEOUT;
|
||||
}
|
||||
|
||||
return self::create($code, $error_message, [
|
||||
'provider' => $provider,
|
||||
'http_code' => $http_code,
|
||||
'response' => substr((string) $body, 0, 500),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查错误是否为特定类型
|
||||
*
|
||||
* @param WP_Error $error 错误对象
|
||||
* @param string $code 错误代码
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_error_type(WP_Error $error, string $code): bool {
|
||||
return $error->get_error_code() === $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查错误是否可重试
|
||||
*
|
||||
* @param WP_Error $error 错误对象
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_retryable(WP_Error $error): bool {
|
||||
$retryable_codes = [
|
||||
self::ERROR_API_TIMEOUT,
|
||||
self::ERROR_RATE_LIMITED,
|
||||
];
|
||||
|
||||
return in_array($error->get_error_code(), $retryable_codes, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成请求 ID
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function generate_request_id(): string {
|
||||
return 'wpmind_' . substr(md5(uniqid(mt_rand(), true)), 0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录错误日志
|
||||
*
|
||||
* @param string $code 错误代码
|
||||
* @param string $message 错误消息
|
||||
* @param array $data 错误数据
|
||||
*/
|
||||
private static function log_error(string $code, string $message, array $data): void {
|
||||
if (!defined('WP_DEBUG') || !WP_DEBUG) {
|
||||
return;
|
||||
}
|
||||
|
||||
$log_message = sprintf(
|
||||
'[WPMind Error] %s: %s | Request ID: %s',
|
||||
$code,
|
||||
$message,
|
||||
$data['request_id'] ?? 'unknown'
|
||||
);
|
||||
|
||||
if (!empty($data['provider'])) {
|
||||
$log_message .= ' | Provider: ' . $data['provider'];
|
||||
}
|
||||
|
||||
if (!empty($data['http_code'])) {
|
||||
$log_message .= ' | HTTP Code: ' . $data['http_code'];
|
||||
}
|
||||
|
||||
error_log($log_message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有错误代码
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_all_error_codes(): array {
|
||||
return [
|
||||
self::ERROR_NOT_AVAILABLE,
|
||||
self::ERROR_INVALID_PARAMS,
|
||||
self::ERROR_EMPTY_INPUT,
|
||||
self::ERROR_RECURSIVE_CALL,
|
||||
self::ERROR_CALL_DEPTH_EXCEEDED,
|
||||
self::ERROR_RATE_LIMITED,
|
||||
self::ERROR_BUDGET_EXCEEDED,
|
||||
self::ERROR_API_ERROR,
|
||||
self::ERROR_API_TIMEOUT,
|
||||
self::ERROR_API_AUTH,
|
||||
self::ERROR_API_QUOTA,
|
||||
self::ERROR_PROVIDER_NOT_FOUND,
|
||||
self::ERROR_PROVIDER_NOT_CONFIGURED,
|
||||
self::ERROR_MODEL_NOT_SUPPORTED,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户友好的错误消息
|
||||
*
|
||||
* @param WP_Error $error 错误对象
|
||||
* @return string
|
||||
*/
|
||||
public static function get_user_friendly_message(WP_Error $error): string {
|
||||
$code = $error->get_error_code();
|
||||
|
||||
// 用户友好消息映射
|
||||
$user_messages = [
|
||||
self::ERROR_NOT_AVAILABLE => __('AI 服务暂不可用,请稍后再试或检查插件设置。', 'wpmind'),
|
||||
self::ERROR_RATE_LIMITED => __('请求太频繁,请稍后再试。', 'wpmind'),
|
||||
self::ERROR_BUDGET_EXCEEDED => __('本月 AI 使用额度已用完,请联系管理员。', 'wpmind'),
|
||||
self::ERROR_API_TIMEOUT => __('AI 服务响应超时,请稍后再试。', 'wpmind'),
|
||||
self::ERROR_API_AUTH => __('AI 服务认证失败,请联系管理员检查配置。', 'wpmind'),
|
||||
];
|
||||
|
||||
return $user_messages[$code] ?? __('操作失败,请稍后再试。', 'wpmind');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +1,62 @@
|
|||
<?php
|
||||
/**
|
||||
* WPMind 公共 API 主类
|
||||
* WPMind 公共 API 主类(Facade)
|
||||
*
|
||||
* 所有公共方法委托给对应的 Service 类,
|
||||
* Facade 层负责递归保护和单例管理。
|
||||
*
|
||||
* @package WPMind
|
||||
* @subpackage API
|
||||
* @since 2.5.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\API;
|
||||
|
||||
use WP_Error;
|
||||
use WPMind\API\Services\ChatService;
|
||||
use WPMind\API\Services\VisionHelper;
|
||||
use WPMind\API\Services\TextProcessingService;
|
||||
use WPMind\API\Services\StructuredOutputService;
|
||||
use WPMind\API\Services\EmbeddingService;
|
||||
use WPMind\API\Services\AudioService;
|
||||
use WPMind\API\Services\ImageService;
|
||||
|
||||
/**
|
||||
* 公共 API 主类
|
||||
*
|
||||
* 提供统一的 AI 能力调用接口
|
||||
* 公共 API 主类(Facade)
|
||||
*
|
||||
* @since 2.5.0
|
||||
*/
|
||||
class PublicAPI {
|
||||
|
||||
/**
|
||||
* 单例实例
|
||||
*
|
||||
* @var PublicAPI|null
|
||||
*/
|
||||
/** @var PublicAPI|null */
|
||||
private static $instance = null;
|
||||
|
||||
/** @var array 调用栈追踪(防止循环调用) */
|
||||
private static $call_stack = [];
|
||||
|
||||
/** @var int 最大调用深度 */
|
||||
private static $max_call_depth = 3;
|
||||
|
||||
/** @var ChatService */
|
||||
private ChatService $chat_service;
|
||||
|
||||
/** @var StructuredOutputService */
|
||||
private StructuredOutputService $structured_service;
|
||||
|
||||
/** @var TextProcessingService */
|
||||
private TextProcessingService $text_service;
|
||||
|
||||
/** @var EmbeddingService */
|
||||
private EmbeddingService $embedding_service;
|
||||
|
||||
/** @var AudioService */
|
||||
private AudioService $audio_service;
|
||||
|
||||
/** @var ImageService */
|
||||
private ImageService $image_service;
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*
|
||||
|
|
@ -43,38 +73,106 @@ class PublicAPI {
|
|||
* 构造函数
|
||||
*/
|
||||
private function __construct() {
|
||||
// 注册 Hooks
|
||||
// 初始化 Service 实例(依赖注入)
|
||||
$this->chat_service = new ChatService();
|
||||
$this->structured_service = new StructuredOutputService($this->chat_service);
|
||||
$this->text_service = new TextProcessingService($this->chat_service, $this->structured_service);
|
||||
$this->embedding_service = new EmbeddingService();
|
||||
$this->audio_service = new AudioService();
|
||||
$this->image_service = new ImageService();
|
||||
|
||||
$this->register_hooks();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 递归保护
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 注册 Hooks
|
||||
* 检查是否存在循环调用
|
||||
*
|
||||
* @param string $method 方法名
|
||||
* @param string $call_id 调用标识
|
||||
* @return bool|WP_Error
|
||||
*/
|
||||
private function register_hooks(): void {
|
||||
// 默认的响应过滤(可被其他插件覆盖)
|
||||
add_filter('wpmind_chat_response', [$this, 'filter_chat_response'], 10, 3);
|
||||
private function check_recursive_call(string $method, string $call_id) {
|
||||
$key = $method . ':' . $call_id;
|
||||
|
||||
if (isset(self::$call_stack[$key])) {
|
||||
return ErrorHandler::recursive_call($method, $call_id);
|
||||
}
|
||||
|
||||
$method_count = 0;
|
||||
foreach (self::$call_stack as $stack_key => $value) {
|
||||
if (strpos($stack_key, $method . ':') === 0) {
|
||||
$method_count++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($method_count >= self::$max_call_depth) {
|
||||
return ErrorHandler::call_depth_exceeded($method, $method_count, self::$max_call_depth);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function begin_call(string $method, string $call_id): void {
|
||||
self::$call_stack[$method . ':' . $call_id] = microtime(true);
|
||||
}
|
||||
|
||||
private function end_call(string $method, string $call_id): void {
|
||||
unset(self::$call_stack[$method . ':' . $call_id]);
|
||||
}
|
||||
|
||||
private function generate_call_id($args): string {
|
||||
return md5(serialize($args));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Hook 注册
|
||||
// ============================================
|
||||
|
||||
private function register_hooks(): void {
|
||||
add_filter('wpmind_chat_response', [$this->chat_service, 'filter_chat_response'], 10, 3);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 状态方法(保留在 Facade)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 检查 WPMind 是否可用
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_available(): bool {
|
||||
// 检查是否有配置的端点
|
||||
$endpoints = get_option('wpmind_endpoints', []);
|
||||
static $cached_result = null;
|
||||
|
||||
if (empty($endpoints)) {
|
||||
if ($cached_result !== null) {
|
||||
return $cached_result;
|
||||
}
|
||||
|
||||
if (!class_exists('\\WPMind\\WPMind')) {
|
||||
$cached_result = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
$wpmind = \WPMind\WPMind::instance();
|
||||
$endpoints = $wpmind->get_custom_endpoints();
|
||||
|
||||
if (empty($endpoints)) {
|
||||
$cached_result = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否至少有一个启用的端点
|
||||
foreach ($endpoints as $endpoint) {
|
||||
if (!empty($endpoint['enabled']) && !empty($endpoint['api_key'])) {
|
||||
$cached_result = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$cached_result = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -84,29 +182,84 @@ class PublicAPI {
|
|||
* @return array
|
||||
*/
|
||||
public function get_status(): array {
|
||||
$endpoints = get_option('wpmind_endpoints', []);
|
||||
$endpoints = get_option('wpmind_custom_endpoints', []);
|
||||
$default_provider = get_option('wpmind_default_provider', '');
|
||||
|
||||
// 获取用量统计
|
||||
$usage_tracker = null;
|
||||
if (class_exists('\\WPMind\\Usage\\UsageTracker')) {
|
||||
$usage_tracker = \WPMind\Usage\UsageTracker::instance();
|
||||
}
|
||||
$today_tokens = 0;
|
||||
$month_tokens = 0;
|
||||
|
||||
$usage = [
|
||||
'today' => $usage_tracker ? $usage_tracker->get_today_usage() : 0,
|
||||
'month' => $usage_tracker ? $usage_tracker->get_month_usage() : 0,
|
||||
'limit' => 0,
|
||||
];
|
||||
if (class_exists('\\WPMind\\Modules\\CostControl\\UsageTracker')) {
|
||||
$today_stats = \WPMind\Modules\CostControl\UsageTracker::get_today_stats();
|
||||
$month_stats = \WPMind\Modules\CostControl\UsageTracker::get_month_stats();
|
||||
$today_tokens = $today_stats['total_tokens'] ?? 0;
|
||||
$month_tokens = $month_stats['total_tokens'] ?? 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'available' => $this->is_available(),
|
||||
'provider' => $default_provider,
|
||||
'model' => $this->get_current_model($default_provider),
|
||||
'usage' => $usage,
|
||||
'model' => $this->chat_service->get_current_model_public($default_provider),
|
||||
'usage' => [
|
||||
'today' => $today_tokens,
|
||||
'month' => $month_tokens,
|
||||
'limit' => 0,
|
||||
],
|
||||
'cache' => $this->get_exact_cache_stats(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取精确缓存统计
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_exact_cache_stats(): array {
|
||||
if (!class_exists('\WPMind\Cache\ExactCache')) {
|
||||
return [
|
||||
'enabled' => false,
|
||||
'hits' => 0,
|
||||
'misses' => 0,
|
||||
'writes' => 0,
|
||||
'hit_rate' => 0,
|
||||
'entries' => 0,
|
||||
'max_entries' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return \WPMind\Cache\ExactCache::instance()->get_stats();
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 Token 数量(估算)
|
||||
*
|
||||
* @since 2.6.0
|
||||
* @param string|array $content 文本内容或消息数组
|
||||
* @return int
|
||||
*/
|
||||
public function count_tokens($content): int {
|
||||
if (is_array($content)) {
|
||||
$text = '';
|
||||
foreach ($content as $msg) {
|
||||
if (isset($msg['content'])) {
|
||||
$text .= $msg['content'] . ' ';
|
||||
}
|
||||
}
|
||||
$content = $text;
|
||||
}
|
||||
|
||||
$chinese_chars = preg_match_all('/[\x{4e00}-\x{9fff}]/u', $content, $matches);
|
||||
$other_chars = mb_strlen($content) - $chinese_chars;
|
||||
|
||||
$estimated_tokens = (int)($chinese_chars / 1.5 + $other_chars / 4);
|
||||
|
||||
return max(1, $estimated_tokens);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 公共 API 委托(递归保护 + 转发到 Service)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* AI 对话
|
||||
*
|
||||
|
|
@ -115,84 +268,20 @@ class PublicAPI {
|
|||
* @return array|WP_Error
|
||||
*/
|
||||
public function chat($messages, array $options = []) {
|
||||
// 默认选项
|
||||
$defaults = [
|
||||
'context' => '',
|
||||
'system' => '',
|
||||
'max_tokens' => 1000,
|
||||
'temperature' => 0.7,
|
||||
'model' => 'auto',
|
||||
'provider' => 'auto',
|
||||
'json_mode' => false,
|
||||
'cache_ttl' => 0,
|
||||
];
|
||||
$options = wp_parse_args($options, $defaults);
|
||||
$call_id = $this->generate_call_id(['messages' => $messages, 'options' => $options]);
|
||||
|
||||
// 提取 context
|
||||
$context = $options['context'];
|
||||
|
||||
// 标准化消息格式
|
||||
$normalized_messages = $this->normalize_messages($messages, $options);
|
||||
|
||||
// 构建请求参数
|
||||
$args = [
|
||||
'messages' => $normalized_messages,
|
||||
'max_tokens' => $options['max_tokens'],
|
||||
'temperature' => $options['temperature'],
|
||||
'json_mode' => $options['json_mode'],
|
||||
];
|
||||
|
||||
// 应用参数过滤
|
||||
$args = apply_filters('wpmind_chat_args', $args, $context, $messages);
|
||||
|
||||
// 选择模型
|
||||
$model = $options['model'];
|
||||
if ($model === 'auto') {
|
||||
$model = $this->get_default_model();
|
||||
}
|
||||
$model = apply_filters('wpmind_select_model', $model, $context, get_current_user_id());
|
||||
|
||||
// 选择服务商
|
||||
$provider = $options['provider'];
|
||||
if ($provider === 'auto') {
|
||||
$provider = get_option('wpmind_default_provider', 'openai');
|
||||
}
|
||||
$provider = apply_filters('wpmind_select_provider', $provider, $context);
|
||||
|
||||
// 检查缓存
|
||||
if ($options['cache_ttl'] > 0) {
|
||||
$cache_key = $this->generate_cache_key('chat', $args);
|
||||
$cached = get_transient($cache_key);
|
||||
if ($cached !== false) {
|
||||
return $cached;
|
||||
}
|
||||
$recursive_check = $this->check_recursive_call('chat', $call_id);
|
||||
if (is_wp_error($recursive_check)) {
|
||||
return $recursive_check;
|
||||
}
|
||||
|
||||
// 触发请求前 Action
|
||||
do_action('wpmind_before_request', 'chat', $args, $context);
|
||||
$this->begin_call('chat', $call_id);
|
||||
|
||||
// 执行请求
|
||||
$result = $this->execute_chat_request($args, $provider, $model);
|
||||
|
||||
// 错误处理
|
||||
if (is_wp_error($result)) {
|
||||
do_action('wpmind_error', $result, 'chat', $args);
|
||||
return $result;
|
||||
try {
|
||||
return $this->chat_service->chat($messages, $options);
|
||||
} finally {
|
||||
$this->end_call('chat', $call_id);
|
||||
}
|
||||
|
||||
// 应用响应过滤
|
||||
$result = apply_filters('wpmind_chat_response', $result, $args, $context);
|
||||
|
||||
// 触发请求后 Action
|
||||
$usage = $result['usage'] ?? [];
|
||||
do_action('wpmind_after_request', 'chat', $result, $args, $usage);
|
||||
|
||||
// 缓存结果
|
||||
if ($options['cache_ttl'] > 0 && !is_wp_error($result)) {
|
||||
set_transient($cache_key, $result, $options['cache_ttl']);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -205,66 +294,20 @@ class PublicAPI {
|
|||
* @return string|WP_Error
|
||||
*/
|
||||
public function translate(string $text, string $from = 'auto', string $to = 'en', array $options = []) {
|
||||
// 默认选项
|
||||
$defaults = [
|
||||
'context' => 'translation',
|
||||
'format' => 'text',
|
||||
'hint' => '',
|
||||
'cache_ttl' => 86400, // 默认缓存 1 天
|
||||
];
|
||||
$options = wp_parse_args($options, $defaults);
|
||||
$call_id = $this->generate_call_id(['text' => $text, 'from' => $from, 'to' => $to, 'options' => $options]);
|
||||
|
||||
$context = $options['context'];
|
||||
|
||||
// 应用参数过滤
|
||||
$args = compact('text', 'from', 'to', 'options');
|
||||
$args = apply_filters('wpmind_translate_args', $args, $context);
|
||||
|
||||
// 检查缓存
|
||||
if ($options['cache_ttl'] > 0) {
|
||||
$cache_key = $this->generate_cache_key('translate', $args);
|
||||
$cached = get_transient($cache_key);
|
||||
if ($cached !== false) {
|
||||
return $cached;
|
||||
}
|
||||
$recursive_check = $this->check_recursive_call('translate', $call_id);
|
||||
if (is_wp_error($recursive_check)) {
|
||||
return $recursive_check;
|
||||
}
|
||||
|
||||
// 构建翻译 Prompt
|
||||
$prompt = $this->build_translate_prompt($text, $from, $to, $options);
|
||||
$this->begin_call('translate', $call_id);
|
||||
|
||||
do_action('wpmind_before_request', 'translate', $args, $context);
|
||||
|
||||
// 调用 chat
|
||||
$result = $this->chat($prompt, [
|
||||
'context' => $context,
|
||||
'max_tokens' => max(500, strlen($text) * 2),
|
||||
'temperature' => 0.3, // 翻译用较低温度
|
||||
'cache_ttl' => 0, // chat 层不缓存,这里统一缓存
|
||||
]);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
do_action('wpmind_error', $result, 'translate', $args);
|
||||
return $result;
|
||||
try {
|
||||
return $this->text_service->translate($text, $from, $to, $options);
|
||||
} finally {
|
||||
$this->end_call('translate', $call_id);
|
||||
}
|
||||
|
||||
$translated = trim($result['content']);
|
||||
|
||||
// Slug 格式处理
|
||||
if ($options['format'] === 'slug') {
|
||||
$translated = sanitize_title($translated);
|
||||
}
|
||||
|
||||
// 应用响应过滤
|
||||
$translated = apply_filters('wpmind_translate_response', $translated, $text, $from, $to);
|
||||
|
||||
do_action('wpmind_after_request', 'translate', $translated, $args, $result['usage'] ?? []);
|
||||
|
||||
// 缓存结果
|
||||
if ($options['cache_ttl'] > 0) {
|
||||
set_transient($cache_key, $translated, $options['cache_ttl']);
|
||||
}
|
||||
|
||||
return $translated;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -275,311 +318,138 @@ class PublicAPI {
|
|||
* @return array|WP_Error
|
||||
*/
|
||||
public function generate_image(string $prompt, array $options = []) {
|
||||
// 默认选项
|
||||
$defaults = [
|
||||
'context' => 'image_generation',
|
||||
'size' => '1024x1024',
|
||||
'quality' => 'standard',
|
||||
'style' => 'natural',
|
||||
'provider' => 'auto',
|
||||
'return_format' => 'url',
|
||||
];
|
||||
$options = wp_parse_args($options, $defaults);
|
||||
|
||||
$context = $options['context'];
|
||||
|
||||
do_action('wpmind_before_request', 'image', compact('prompt', 'options'), $context);
|
||||
|
||||
// 使用现有的图像路由器
|
||||
if (class_exists('\\WPMind\\Routing\\ImageRouter')) {
|
||||
$router = \WPMind\Routing\ImageRouter::instance();
|
||||
$result = $router->generate($prompt, $options);
|
||||
} else {
|
||||
return new WP_Error(
|
||||
'wpmind_image_not_available',
|
||||
__('图像生成服务不可用', 'wpmind')
|
||||
);
|
||||
}
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
do_action('wpmind_error', $result, 'image', compact('prompt', 'options'));
|
||||
return $result;
|
||||
}
|
||||
|
||||
do_action('wpmind_after_request', 'image', $result, compact('prompt', 'options'), []);
|
||||
|
||||
return $result;
|
||||
return $this->image_service->generate_image($prompt, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化消息格式
|
||||
* AI 图像理解(Vision)
|
||||
*
|
||||
* @param array|string $messages 原始消息
|
||||
* @since 4.3.0
|
||||
* @param string $image_url 图片 URL 或 base64 data URI
|
||||
* @param string $prompt 提示词
|
||||
* @param array $options 选项
|
||||
* @return array
|
||||
*/
|
||||
private function normalize_messages($messages, array $options): array {
|
||||
// 如果是字符串,转换为消息数组
|
||||
if (is_string($messages)) {
|
||||
$normalized = [];
|
||||
|
||||
// 添加 system 消息
|
||||
if (!empty($options['system'])) {
|
||||
$normalized[] = [
|
||||
'role' => 'system',
|
||||
'content' => $options['system'],
|
||||
];
|
||||
}
|
||||
|
||||
// 添加 user 消息
|
||||
$normalized[] = [
|
||||
'role' => 'user',
|
||||
'content' => $messages,
|
||||
];
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
// 已经是数组格式
|
||||
return $messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Chat 请求
|
||||
*
|
||||
* @param array $args 请求参数
|
||||
* @param string $provider 服务商
|
||||
* @param string $model 模型
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
private function execute_chat_request(array $args, string $provider, string $model) {
|
||||
// 获取端点配置
|
||||
$wpmind = \WPMind::instance();
|
||||
$endpoints = $wpmind->get_custom_endpoints();
|
||||
|
||||
if (!isset($endpoints[$provider])) {
|
||||
return new WP_Error(
|
||||
'wpmind_provider_not_found',
|
||||
sprintf(__('服务商 %s 未配置', 'wpmind'), $provider)
|
||||
);
|
||||
}
|
||||
|
||||
$endpoint = $endpoints[$provider];
|
||||
$api_key = $endpoint['api_key'] ?? '';
|
||||
|
||||
if (empty($api_key)) {
|
||||
return new WP_Error(
|
||||
'wpmind_api_key_missing',
|
||||
sprintf(__('服务商 %s 未配置 API Key', 'wpmind'), $provider)
|
||||
);
|
||||
}
|
||||
|
||||
// 确定模型
|
||||
if ($model === 'auto' || empty($model)) {
|
||||
$model = $endpoint['model'] ?? 'gpt-3.5-turbo';
|
||||
}
|
||||
|
||||
// 构建请求体
|
||||
$request_body = [
|
||||
'model' => $model,
|
||||
'messages' => $args['messages'],
|
||||
'max_tokens' => $args['max_tokens'],
|
||||
'temperature' => $args['temperature'],
|
||||
public function vision( string $image_url, string $prompt = '', array $options = [] ) {
|
||||
$defaults = [
|
||||
'system' => '',
|
||||
'max_tokens' => 300,
|
||||
'temperature' => 0.3,
|
||||
'provider' => 'auto',
|
||||
'language' => get_locale() === 'zh_CN' ? 'zh' : 'en',
|
||||
];
|
||||
$options = wp_parse_args( $options, $defaults );
|
||||
|
||||
if (!empty($args['json_mode'])) {
|
||||
$request_body['response_format'] = ['type' => 'json_object'];
|
||||
if ( 'auto' === $options['provider'] ) {
|
||||
$options['provider'] = VisionHelper::get_vision_provider();
|
||||
$options['model'] = VisionHelper::get_vision_model( $options['provider'] );
|
||||
}
|
||||
|
||||
// 确定 API URL
|
||||
$base_url = $endpoint['custom_url'] ?? $endpoint['base_url'] ?? '';
|
||||
$api_url = trailingslashit($base_url) . 'chat/completions';
|
||||
$messages = VisionHelper::build_vision_messages( $image_url, $prompt, $options['system'] );
|
||||
unset( $options['system'] );
|
||||
|
||||
// 发送请求
|
||||
$start_time = microtime(true);
|
||||
// Constrain failover chain to vision-capable providers only.
|
||||
$options['failover_providers'] = VisionHelper::get_configured_vision_providers();
|
||||
|
||||
$response = wp_remote_post($api_url, [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'body' => wp_json_encode($request_body),
|
||||
'timeout' => 60,
|
||||
]);
|
||||
|
||||
$latency_ms = (int)((microtime(true) - $start_time) * 1000);
|
||||
|
||||
// 错误处理
|
||||
if (is_wp_error($response)) {
|
||||
// 记录失败
|
||||
if (class_exists('\\WPMind\\Failover\\FailoverManager')) {
|
||||
\WPMind\Failover\FailoverManager::instance()->recordResult($provider, false, $latency_ms);
|
||||
}
|
||||
|
||||
return new WP_Error(
|
||||
'wpmind_request_failed',
|
||||
sprintf(__('请求失败: %s', 'wpmind'), $response->get_error_message())
|
||||
);
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code($response);
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
$data = json_decode($body, true);
|
||||
|
||||
// HTTP 错误
|
||||
if ($status_code !== 200) {
|
||||
// 记录失败
|
||||
if (class_exists('\\WPMind\\Failover\\FailoverManager')) {
|
||||
\WPMind\Failover\FailoverManager::instance()->recordResult($provider, false, $latency_ms);
|
||||
}
|
||||
|
||||
$error_message = $data['error']['message'] ?? __('未知错误', 'wpmind');
|
||||
return new WP_Error(
|
||||
'wpmind_api_error',
|
||||
sprintf(__('API 错误 (%d): %s', 'wpmind'), $status_code, $error_message),
|
||||
['status' => $status_code]
|
||||
);
|
||||
}
|
||||
|
||||
// 记录成功
|
||||
if (class_exists('\\WPMind\\Failover\\FailoverManager')) {
|
||||
\WPMind\Failover\FailoverManager::instance()->recordResult($provider, true, $latency_ms);
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
return $this->parse_chat_response($data, $provider, $model);
|
||||
return $this->chat( $messages, $options );
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Chat 响应
|
||||
* 流式输出
|
||||
*
|
||||
* @param array $response 原始响应
|
||||
* @param string $provider 服务商
|
||||
* @param string $model 模型
|
||||
* @return array
|
||||
*/
|
||||
private function parse_chat_response(array $response, string $provider, string $model): array {
|
||||
$content = '';
|
||||
$usage = [
|
||||
'prompt_tokens' => 0,
|
||||
'completion_tokens' => 0,
|
||||
'total_tokens' => 0,
|
||||
];
|
||||
|
||||
// 提取内容
|
||||
if (isset($response['choices'][0]['message']['content'])) {
|
||||
$content = $response['choices'][0]['message']['content'];
|
||||
} elseif (isset($response['content'][0]['text'])) {
|
||||
// Anthropic 格式
|
||||
$content = $response['content'][0]['text'];
|
||||
}
|
||||
|
||||
// 提取用量
|
||||
if (isset($response['usage'])) {
|
||||
$usage = [
|
||||
'prompt_tokens' => $response['usage']['prompt_tokens'] ?? 0,
|
||||
'completion_tokens' => $response['usage']['completion_tokens'] ?? 0,
|
||||
'total_tokens' => $response['usage']['total_tokens'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => $content,
|
||||
'provider' => $provider,
|
||||
'model' => $model,
|
||||
'usage' => $usage,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建翻译 Prompt
|
||||
*
|
||||
* @param string $text 文本
|
||||
* @param string $from 源语言
|
||||
* @param string $to 目标语言
|
||||
* @since 2.6.0
|
||||
* @param array|string $messages 消息
|
||||
* @param callable $callback 回调函数
|
||||
* @param array $options 选项
|
||||
* @return string
|
||||
* @return bool|WP_Error
|
||||
*/
|
||||
private function build_translate_prompt(string $text, string $from, string $to, array $options): string {
|
||||
$lang_names = [
|
||||
'zh' => '中文',
|
||||
'en' => '英文',
|
||||
'ja' => '日文',
|
||||
'ko' => '韩文',
|
||||
'fr' => '法文',
|
||||
'de' => '德文',
|
||||
'es' => '西班牙文',
|
||||
'auto' => '自动检测',
|
||||
];
|
||||
|
||||
$from_name = $lang_names[$from] ?? $from;
|
||||
$to_name = $lang_names[$to] ?? $to;
|
||||
|
||||
$prompt = "将以下{$from_name}文本翻译成{$to_name}";
|
||||
|
||||
// Slug 格式特殊处理
|
||||
if ($options['format'] === 'slug') {
|
||||
$prompt .= ",输出结果应该适合作为 URL slug,使用小写英文和连字符";
|
||||
}
|
||||
|
||||
// 添加提示
|
||||
if (!empty($options['hint'])) {
|
||||
$prompt .= "。提示:{$options['hint']}";
|
||||
}
|
||||
|
||||
$prompt .= "。只返回翻译结果,不要其他解释:\n\n{$text}";
|
||||
|
||||
return $prompt;
|
||||
public function stream($messages, callable $callback, array $options = []) {
|
||||
return $this->chat_service->stream($messages, $callback, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成缓存键
|
||||
* 结构化输出(JSON Schema)
|
||||
*
|
||||
* @param string $type 类型
|
||||
* @param array $args 参数
|
||||
* @return string
|
||||
* @since 2.6.0
|
||||
* @param array|string $messages 消息
|
||||
* @param array $schema JSON Schema
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
private function generate_cache_key(string $type, array $args): string {
|
||||
$key = 'wpmind_' . $type . '_' . md5(serialize($args));
|
||||
return apply_filters('wpmind_cache_key', $key, $type, $args);
|
||||
public function structured($messages, array $schema, array $options = []) {
|
||||
return $this->structured_service->structured($messages, $schema, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前模型
|
||||
* 批量处理
|
||||
*
|
||||
* @param string $provider 服务商
|
||||
* @return string
|
||||
* @since 2.6.0
|
||||
* @param array $items 要处理的项目数组
|
||||
* @param string $prompt_template Prompt 模板
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
private function get_current_model(string $provider): string {
|
||||
$endpoints = get_option('wpmind_endpoints', []);
|
||||
|
||||
if (isset($endpoints[$provider]['model'])) {
|
||||
return $endpoints[$provider]['model'];
|
||||
}
|
||||
|
||||
return 'default';
|
||||
public function batch(array $items, string $prompt_template, array $options = []) {
|
||||
return $this->structured_service->batch($items, $prompt_template, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认模型
|
||||
* 文本嵌入向量
|
||||
*
|
||||
* @return string
|
||||
* @since 2.6.0
|
||||
* @param string|array $texts 要嵌入的文本
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
private function get_default_model(): string {
|
||||
$provider = get_option('wpmind_default_provider', 'openai');
|
||||
return $this->get_current_model($provider);
|
||||
public function embed($texts, array $options = []) {
|
||||
return $this->embedding_service->embed($texts, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认响应过滤器
|
||||
* 文本摘要
|
||||
*
|
||||
* @param array $response 响应
|
||||
* @param array $args 参数
|
||||
* @param string $context 上下文
|
||||
* @return array
|
||||
* @since 2.7.0
|
||||
* @param string $text 要摘要的文本
|
||||
* @param array $options 选项
|
||||
* @return string|WP_Error
|
||||
*/
|
||||
public function filter_chat_response(array $response, array $args, string $context): array {
|
||||
// 默认不做修改,允许其他插件覆盖
|
||||
return $response;
|
||||
public function summarize(string $text, array $options = []) {
|
||||
return $this->text_service->summarize($text, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容审核
|
||||
*
|
||||
* @since 2.7.0
|
||||
* @param string $content 要审核的内容
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public function moderate(string $content, array $options = []) {
|
||||
return $this->text_service->moderate($content, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频转录
|
||||
*
|
||||
* @since 2.7.0
|
||||
* @param string $audio_file 音频文件路径或 URL
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public function transcribe(string $audio_file, array $options = []) {
|
||||
return $this->audio_service->transcribe($audio_file, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本转语音
|
||||
*
|
||||
* @since 2.7.0
|
||||
* @param string $text 要转换的文本
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public function speech(string $text, array $options = []) {
|
||||
return $this->audio_service->speech($text, $options);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
287
includes/API/Services/AbstractService.php
Normal file
287
includes/API/Services/AbstractService.php
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<?php
|
||||
/**
|
||||
* Service 基类
|
||||
*
|
||||
* 提供所有 Service 共享的基础设施方法
|
||||
*
|
||||
* @package WPMind
|
||||
* @subpackage API\Services
|
||||
* @since 3.7.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\API\Services;
|
||||
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* Service 基类
|
||||
*
|
||||
* @since 3.7.0
|
||||
*/
|
||||
abstract class AbstractService {
|
||||
|
||||
/**
|
||||
* 解析 Provider('auto' -> 默认值,应用 filter)
|
||||
*
|
||||
* @param string $provider 原始 provider 值
|
||||
* @param string $context 上下文
|
||||
* @return string
|
||||
*/
|
||||
protected function resolve_provider(string $provider, string $context = ''): string {
|
||||
if ($provider === 'auto') {
|
||||
$provider = get_option('wpmind_default_provider', 'openai');
|
||||
}
|
||||
return apply_filters('wpmind_select_provider', $provider, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取故障转移链
|
||||
*
|
||||
* @param string $provider 首选 provider
|
||||
* @return array
|
||||
*/
|
||||
protected function get_failover_chain(string $provider): array {
|
||||
if (!class_exists('\\WPMind\\Failover\\FailoverManager')) {
|
||||
return [$provider];
|
||||
}
|
||||
|
||||
$failover = \WPMind\Failover\FailoverManager::instance();
|
||||
return $failover->get_failover_chain($provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取端点配置
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function get_endpoints(): array {
|
||||
if (!class_exists('\\WPMind\\WPMind')) {
|
||||
return [];
|
||||
}
|
||||
return \WPMind\WPMind::instance()->get_custom_endpoints();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录请求结果到 FailoverManager
|
||||
*
|
||||
* @param string $provider 服务商 ID
|
||||
* @param bool $success 是否成功
|
||||
* @param int $latency_ms 延迟毫秒
|
||||
*/
|
||||
protected function record_result(string $provider, bool $success, int $latency_ms = 0): void {
|
||||
if (class_exists('\\WPMind\\Failover\\FailoverManager')) {
|
||||
\WPMind\Failover\FailoverManager::instance()->record_result($provider, $success, $latency_ms);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 failover 执行模板
|
||||
*
|
||||
* 遍历 failover 链,对每个 provider 执行回调,处理成功/失败/记录。
|
||||
* 适用于 embed/transcribe/speech 等简单 failover 场景。
|
||||
*
|
||||
* @param string $type 请求类型(用于错误消息)
|
||||
* @param string $provider 首选 provider
|
||||
* @param string $context 上下文
|
||||
* @param callable $execute_fn 执行函数 fn(string $provider, array $endpoint): array|WP_Error
|
||||
* @param array $supported_providers 支持的 provider 列表(空数组表示不过滤)
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
protected function execute_with_failover(
|
||||
string $type,
|
||||
string $provider,
|
||||
string $context,
|
||||
callable $execute_fn,
|
||||
array $supported_providers = []
|
||||
) {
|
||||
$failover_chain = $this->get_failover_chain($provider);
|
||||
|
||||
// 过滤出支持的 provider
|
||||
if (!empty($supported_providers)) {
|
||||
$failover_chain = array_values(array_filter($failover_chain, function ($p) use ($supported_providers) {
|
||||
return in_array($p, $supported_providers, true);
|
||||
}));
|
||||
|
||||
if (empty($failover_chain)) {
|
||||
return new WP_Error(
|
||||
"wpmind_{$type}_not_supported",
|
||||
sprintf(__('没有可用的 %s 服务商', 'wpmind'), $type)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$endpoints = $this->get_endpoints();
|
||||
$last_error = null;
|
||||
|
||||
foreach ($failover_chain as $try_provider) {
|
||||
if (!isset($endpoints[$try_provider])) {
|
||||
$last_error = new WP_Error('wpmind_provider_not_found',
|
||||
sprintf(__('服务商 %s 未配置', 'wpmind'), $try_provider));
|
||||
continue;
|
||||
}
|
||||
|
||||
$endpoint = $endpoints[$try_provider];
|
||||
$api_key = $endpoint['api_key'] ?? '';
|
||||
|
||||
if (empty($api_key)) {
|
||||
$last_error = new WP_Error('wpmind_api_key_missing',
|
||||
sprintf(__('服务商 %s 未配置 API Key', 'wpmind'), $try_provider));
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $execute_fn($try_provider, $endpoint);
|
||||
|
||||
if (!is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$last_error = $result;
|
||||
}
|
||||
|
||||
if ($last_error) {
|
||||
do_action('wpmind_error', $last_error, $type, []);
|
||||
return $last_error;
|
||||
}
|
||||
|
||||
return new WP_Error("wpmind_{$type}_failed", sprintf(__('%s 请求失败', 'wpmind'), $type));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前模型
|
||||
*
|
||||
* @param string $provider 服务商
|
||||
* @return string
|
||||
*/
|
||||
protected function get_current_model(string $provider): string {
|
||||
if (!class_exists('\\WPMind\\WPMind')) {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
$endpoints = $this->get_endpoints();
|
||||
|
||||
if (isset($endpoints[$provider]['models']) && is_array($endpoints[$provider]['models'])) {
|
||||
return $endpoints[$provider]['models'][0] ?? 'default';
|
||||
}
|
||||
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认模型
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_default_model(): string {
|
||||
$provider = get_option('wpmind_default_provider', 'openai');
|
||||
return $this->get_current_model($provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成缓存键
|
||||
*
|
||||
* @param string $type 类型
|
||||
* @param array $args 参数
|
||||
* @param string $provider 服务商
|
||||
* @param string $model 模型
|
||||
* @return string
|
||||
*/
|
||||
protected function generate_cache_key(string $type, array $args, string $provider = '', string $model = ''): string {
|
||||
if (class_exists('\WPMind\Cache\ExactCache')) {
|
||||
$key = \WPMind\Cache\ExactCache::instance()->build_key($type, $args, $provider, $model);
|
||||
} else {
|
||||
$key = 'wpmind_' . $type . '_' . $provider . '_' . $model . '_' . md5(serialize($args));
|
||||
}
|
||||
|
||||
return apply_filters('wpmind_cache_key', $key, $type, $args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取缓存值(优先 Exact Cache,失败回退 transient)
|
||||
*
|
||||
* @param string $cache_key 缓存键
|
||||
* @param int $cache_ttl TTL 秒数
|
||||
* @return array{hit:bool,value:mixed}
|
||||
*/
|
||||
protected function get_cached_value(string $cache_key, int $cache_ttl): array {
|
||||
$effective_ttl = $this->get_effective_cache_ttl($cache_ttl);
|
||||
if ($effective_ttl <= 0) {
|
||||
return ['hit' => false, 'value' => null];
|
||||
}
|
||||
|
||||
if (class_exists('\WPMind\Cache\ExactCache')) {
|
||||
$cached = \WPMind\Cache\ExactCache::instance()->get($cache_key);
|
||||
if ($cached !== null) {
|
||||
return ['hit' => true, 'value' => $cached];
|
||||
}
|
||||
|
||||
return ['hit' => false, 'value' => null];
|
||||
}
|
||||
|
||||
$cached = get_transient($cache_key);
|
||||
if ($cached !== false) {
|
||||
return ['hit' => true, 'value' => $cached];
|
||||
}
|
||||
|
||||
return ['hit' => false, 'value' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入缓存值(优先 Exact Cache,失败回退 transient)
|
||||
*
|
||||
* @param string $cache_key 缓存键
|
||||
* @param mixed $value 缓存值
|
||||
* @param int $cache_ttl TTL 秒数
|
||||
* @param array $meta 元数据
|
||||
* @return void
|
||||
*/
|
||||
protected function set_cached_value(string $cache_key, $value, int $cache_ttl, array $meta = []): void {
|
||||
$effective_ttl = $this->get_effective_cache_ttl($cache_ttl);
|
||||
if ($effective_ttl <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (class_exists('\WPMind\Cache\ExactCache')) {
|
||||
\WPMind\Cache\ExactCache::instance()->set($cache_key, $value, $effective_ttl, $meta);
|
||||
return;
|
||||
}
|
||||
|
||||
set_transient($cache_key, $value, $effective_ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算生效缓存 TTL
|
||||
*
|
||||
* 行为说明:
|
||||
* - cache_ttl > 0 : 使用调用方指定的 TTL
|
||||
* - cache_ttl = 0 : 当 ExactCache 启用时使用其默认 TTL(自动缓存),
|
||||
* 否则不缓存(向后兼容)
|
||||
* - cache_ttl < 0 : 强制不缓存(显式禁用)
|
||||
*
|
||||
* 可通过 `wpmind_exact_cache_auto_cache` filter 关闭自动缓存行为。
|
||||
*
|
||||
* @param int $cache_ttl API 调用层传入 TTL
|
||||
* @return int
|
||||
*/
|
||||
private function get_effective_cache_ttl(int $cache_ttl): int {
|
||||
if ($cache_ttl < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($cache_ttl > 0) {
|
||||
return $cache_ttl;
|
||||
}
|
||||
|
||||
if (!class_exists('\WPMind\Cache\ExactCache')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$auto_cache = (bool) apply_filters('wpmind_exact_cache_auto_cache', true);
|
||||
if (!$auto_cache) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return \WPMind\Cache\ExactCache::instance()->get_default_ttl();
|
||||
}
|
||||
}
|
||||
317
includes/API/Services/AudioService.php
Normal file
317
includes/API/Services/AudioService.php
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
<?php
|
||||
/**
|
||||
* Audio Service
|
||||
*
|
||||
* 处理音频转录和语音合成
|
||||
*
|
||||
* @package WPMind
|
||||
* @subpackage API\Services
|
||||
* @since 3.7.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\API\Services;
|
||||
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* Audio Service
|
||||
*
|
||||
* @since 3.7.0
|
||||
*/
|
||||
class AudioService extends AbstractService {
|
||||
|
||||
/**
|
||||
* 音频转录(语音转文字)
|
||||
*
|
||||
* @since 2.7.0
|
||||
* @param string $audio_file 音频文件路径或 URL
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public function transcribe(string $audio_file, array $options = []) {
|
||||
$defaults = [
|
||||
'context' => 'transcription',
|
||||
'language' => 'auto',
|
||||
'prompt' => '',
|
||||
'format' => 'text',
|
||||
'provider' => 'auto',
|
||||
];
|
||||
$options = wp_parse_args($options, $defaults);
|
||||
|
||||
$context = $options['context'];
|
||||
$transcribe_providers = ['openai'];
|
||||
|
||||
$provider = $this->resolve_provider($options['provider'], $context);
|
||||
|
||||
// 文件大小上限(25MB,与 OpenAI Whisper API 限制一致)
|
||||
$max_file_size = apply_filters('wpmind_transcribe_max_file_size', 25 * MB_IN_BYTES);
|
||||
|
||||
// 允许的音频文件扩展名
|
||||
$allowed_extensions = ['mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'wav', 'webm', 'ogg', 'flac'];
|
||||
|
||||
// 准备文件内容(在 failover 循环外处理,避免重复下载/IO)
|
||||
$is_temp = false;
|
||||
if (filter_var($audio_file, FILTER_VALIDATE_URL)) {
|
||||
// URL 安全验证:拒绝内网地址,防止 SSRF
|
||||
if (!wp_http_validate_url($audio_file)) {
|
||||
return new WP_Error('wpmind_invalid_url', __('URL 验证失败:不允许访问内网地址', 'wpmind'));
|
||||
}
|
||||
|
||||
// 协议白名单
|
||||
$scheme = wp_parse_url($audio_file, PHP_URL_SCHEME);
|
||||
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||
return new WP_Error('wpmind_invalid_url', __('仅支持 HTTP/HTTPS 协议', 'wpmind'));
|
||||
}
|
||||
|
||||
$temp_file = download_url($audio_file);
|
||||
if (is_wp_error($temp_file)) {
|
||||
return $temp_file;
|
||||
}
|
||||
$file_path = $temp_file;
|
||||
$is_temp = true;
|
||||
} else {
|
||||
// 本地文件路径安全验证:限制在 uploads 目录内
|
||||
$upload_dir = wp_upload_dir();
|
||||
$realpath = realpath($audio_file);
|
||||
$basedir = realpath($upload_dir['basedir']);
|
||||
|
||||
if ($realpath === false || $basedir === false || strpos($realpath, $basedir) !== 0) {
|
||||
return new WP_Error('wpmind_invalid_path', __('文件路径必须在 uploads 目录内', 'wpmind'));
|
||||
}
|
||||
|
||||
$file_path = $realpath;
|
||||
}
|
||||
|
||||
if (!file_exists($file_path)) {
|
||||
if ($is_temp && isset($temp_file) && file_exists($temp_file)) {
|
||||
unlink($temp_file);
|
||||
}
|
||||
return new WP_Error('wpmind_file_not_found', __('音频文件不存在', 'wpmind'));
|
||||
}
|
||||
|
||||
// 文件扩展名验证
|
||||
$extension = strtolower(pathinfo($file_path, PATHINFO_EXTENSION));
|
||||
if (!in_array($extension, $allowed_extensions, true)) {
|
||||
if ($is_temp && isset($temp_file) && file_exists($temp_file)) {
|
||||
unlink($temp_file);
|
||||
}
|
||||
return new WP_Error('wpmind_invalid_filetype',
|
||||
sprintf(__('不支持的音频格式: %s', 'wpmind'), $extension));
|
||||
}
|
||||
|
||||
// 文件大小验证
|
||||
$file_size = filesize($file_path);
|
||||
if ($file_size === false || $file_size > $max_file_size) {
|
||||
if ($is_temp && isset($temp_file) && file_exists($temp_file)) {
|
||||
unlink($temp_file);
|
||||
}
|
||||
return new WP_Error('wpmind_file_too_large',
|
||||
sprintf(__('文件大小超过限制 (%s)', 'wpmind'), size_format($max_file_size)));
|
||||
}
|
||||
|
||||
$file_content = file_get_contents($file_path);
|
||||
|
||||
if ($is_temp && isset($temp_file) && file_exists($temp_file)) {
|
||||
unlink($temp_file);
|
||||
}
|
||||
|
||||
do_action('wpmind_before_request', 'transcribe', compact('audio_file', 'options'), $context);
|
||||
|
||||
return $this->execute_with_failover('transcribe', $provider, $context, function (string $try_provider, array $endpoint) use ($file_content, $options, $audio_file) {
|
||||
$api_key = $endpoint['api_key'];
|
||||
$base_url = $endpoint['custom_base_url'] ?? $endpoint['base_url'] ?? '';
|
||||
$api_url = trailingslashit($base_url) . 'audio/transcriptions';
|
||||
|
||||
$boundary = wp_generate_password(24, false);
|
||||
$body = '';
|
||||
|
||||
$body .= "--{$boundary}\r\n";
|
||||
$body .= "Content-Disposition: form-data; name=\"file\"; filename=\"audio.mp3\"\r\n";
|
||||
$body .= "Content-Type: audio/mpeg\r\n\r\n";
|
||||
$body .= $file_content . "\r\n";
|
||||
|
||||
$body .= "--{$boundary}\r\n";
|
||||
$body .= "Content-Disposition: form-data; name=\"model\"\r\n\r\n";
|
||||
$body .= "whisper-1\r\n";
|
||||
|
||||
if ($options['language'] !== 'auto') {
|
||||
$body .= "--{$boundary}\r\n";
|
||||
$body .= "Content-Disposition: form-data; name=\"language\"\r\n\r\n";
|
||||
$body .= "{$options['language']}\r\n";
|
||||
}
|
||||
|
||||
if (!empty($options['prompt'])) {
|
||||
$body .= "--{$boundary}\r\n";
|
||||
$body .= "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n";
|
||||
$body .= "{$options['prompt']}\r\n";
|
||||
}
|
||||
|
||||
$response_format = $options['format'] === 'text' ? 'text' : $options['format'];
|
||||
$body .= "--{$boundary}\r\n";
|
||||
$body .= "Content-Disposition: form-data; name=\"response_format\"\r\n\r\n";
|
||||
$body .= "{$response_format}\r\n";
|
||||
|
||||
$body .= "--{$boundary}--\r\n";
|
||||
|
||||
$start_time = microtime(true);
|
||||
|
||||
$response = wp_remote_post($api_url, [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
'Content-Type' => 'multipart/form-data; boundary=' . $boundary,
|
||||
],
|
||||
'body' => $body,
|
||||
'timeout' => 120,
|
||||
]);
|
||||
|
||||
$latency_ms = (int)((microtime(true) - $start_time) * 1000);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
$this->record_result($try_provider, false, $latency_ms);
|
||||
return new WP_Error('wpmind_transcribe_failed',
|
||||
sprintf(__('转录请求失败: %s', 'wpmind'), $response->get_error_message()));
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code($response);
|
||||
$resp_body = wp_remote_retrieve_body($response);
|
||||
|
||||
if ($status_code !== 200) {
|
||||
$this->record_result($try_provider, false, $latency_ms);
|
||||
$data = json_decode($resp_body, true);
|
||||
$error_message = $data['error']['message'] ?? $resp_body;
|
||||
return new WP_Error('wpmind_transcribe_error',
|
||||
sprintf(__('转录 API 错误 (%d): %s', 'wpmind'), $status_code, $error_message));
|
||||
}
|
||||
|
||||
$this->record_result($try_provider, true, $latency_ms);
|
||||
|
||||
$result = [
|
||||
'text' => $options['format'] === 'text' ? $resp_body : '',
|
||||
'data' => $options['format'] !== 'text' ? json_decode($resp_body, true) : null,
|
||||
'provider' => $try_provider,
|
||||
'format' => $options['format'],
|
||||
];
|
||||
|
||||
if ($options['format'] !== 'text' && is_array($result['data'])) {
|
||||
$result['text'] = $result['data']['text'] ?? '';
|
||||
}
|
||||
|
||||
do_action('wpmind_after_request', 'transcribe', $result, compact('audio_file', 'options'), []);
|
||||
|
||||
return $result;
|
||||
}, $transcribe_providers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本转语音
|
||||
*
|
||||
* @since 2.7.0
|
||||
* @param string $text 要转换的文本
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public function speech(string $text, array $options = []) {
|
||||
$defaults = [
|
||||
'context' => 'speech',
|
||||
'voice' => 'alloy',
|
||||
'model' => 'tts-1',
|
||||
'speed' => 1.0,
|
||||
'format' => 'mp3',
|
||||
'save_to' => '',
|
||||
'provider' => 'auto',
|
||||
];
|
||||
$options = wp_parse_args($options, $defaults);
|
||||
|
||||
$context = $options['context'];
|
||||
$speech_providers = ['openai', 'deepseek'];
|
||||
|
||||
$provider = $this->resolve_provider($options['provider'], $context);
|
||||
|
||||
do_action('wpmind_before_request', 'speech', compact('text', 'options'), $context);
|
||||
|
||||
return $this->execute_with_failover('speech', $provider, $context, function (string $try_provider, array $endpoint) use ($text, $options) {
|
||||
$api_key = $endpoint['api_key'];
|
||||
$base_url = $endpoint['custom_base_url'] ?? $endpoint['base_url'] ?? '';
|
||||
$api_url = trailingslashit($base_url) . 'audio/speech';
|
||||
|
||||
$start_time = microtime(true);
|
||||
|
||||
$response = wp_remote_post($api_url, [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'body' => wp_json_encode([
|
||||
'model' => $options['model'],
|
||||
'input' => $text,
|
||||
'voice' => $options['voice'],
|
||||
'speed' => $options['speed'],
|
||||
'response_format' => $options['format'],
|
||||
]),
|
||||
'timeout' => 60,
|
||||
]);
|
||||
|
||||
$latency_ms = (int)((microtime(true) - $start_time) * 1000);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
$this->record_result($try_provider, false, $latency_ms);
|
||||
return new WP_Error('wpmind_speech_failed',
|
||||
sprintf(__('语音合成请求失败: %s', 'wpmind'), $response->get_error_message()));
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code($response);
|
||||
$audio_data = wp_remote_retrieve_body($response);
|
||||
|
||||
if ($status_code !== 200) {
|
||||
$this->record_result($try_provider, false, $latency_ms);
|
||||
$data = json_decode($audio_data, true);
|
||||
$error_message = $data['error']['message'] ?? __('未知错误', 'wpmind');
|
||||
return new WP_Error('wpmind_speech_error',
|
||||
sprintf(__('语音合成 API 错误 (%d): %s', 'wpmind'), $status_code, $error_message));
|
||||
}
|
||||
|
||||
$this->record_result($try_provider, true, $latency_ms);
|
||||
|
||||
$result = [
|
||||
'provider' => $try_provider,
|
||||
'model' => $options['model'],
|
||||
'voice' => $options['voice'],
|
||||
'format' => $options['format'],
|
||||
'size' => strlen($audio_data),
|
||||
];
|
||||
|
||||
if (!empty($options['save_to'])) {
|
||||
$upload_dir = wp_upload_dir();
|
||||
$save_dir = realpath(dirname($options['save_to']));
|
||||
$base_dir = realpath($upload_dir['basedir']);
|
||||
if ($save_dir === false || $base_dir === false || strpos($save_dir, $base_dir) !== 0) {
|
||||
return new WP_Error('wpmind_invalid_path', __('保存路径必须在 uploads 目录内', 'wpmind'));
|
||||
}
|
||||
$written = file_put_contents($options['save_to'], $audio_data);
|
||||
if ($written === false) {
|
||||
return new WP_Error('wpmind_write_failed', __('文件写入失败', 'wpmind'));
|
||||
}
|
||||
$result['file'] = $options['save_to'];
|
||||
} else {
|
||||
$upload = wp_upload_bits(
|
||||
'wpmind-speech-' . time() . '.' . $options['format'],
|
||||
null,
|
||||
$audio_data
|
||||
);
|
||||
|
||||
if (!empty($upload['error'])) {
|
||||
return new WP_Error('wpmind_upload_failed', $upload['error']);
|
||||
}
|
||||
|
||||
$result['url'] = $upload['url'];
|
||||
$result['file'] = $upload['file'];
|
||||
}
|
||||
|
||||
do_action('wpmind_after_request', 'speech', $result, compact('text', 'options'), []);
|
||||
|
||||
return $result;
|
||||
}, $speech_providers);
|
||||
}
|
||||
}
|
||||
618
includes/API/Services/ChatService.php
Normal file
618
includes/API/Services/ChatService.php
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
<?php
|
||||
/**
|
||||
* Chat Service
|
||||
*
|
||||
* 处理 AI 对话和流式输出
|
||||
*
|
||||
* @package WPMind
|
||||
* @subpackage API\Services
|
||||
* @since 3.7.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\API\Services;
|
||||
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* Chat Service
|
||||
*
|
||||
* @since 3.7.0
|
||||
*/
|
||||
class ChatService extends AbstractService {
|
||||
|
||||
/**
|
||||
* AI 对话(核心实现)
|
||||
*
|
||||
* @param string|array $messages 消息
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public function chat($messages, array $options = []) {
|
||||
$defaults = [
|
||||
'context' => '',
|
||||
'system' => '',
|
||||
'max_tokens' => 1000,
|
||||
'temperature' => 0.7,
|
||||
'model' => 'auto',
|
||||
'provider' => 'auto',
|
||||
'json_mode' => false,
|
||||
'cache_ttl' => 0,
|
||||
'tools' => [],
|
||||
'tool_choice' => 'auto',
|
||||
'failover_providers' => [],
|
||||
];
|
||||
$options = wp_parse_args($options, $defaults);
|
||||
|
||||
$context = $options['context'];
|
||||
|
||||
$normalized_messages = $this->normalize_messages($messages, $options);
|
||||
|
||||
$args = [
|
||||
'messages' => $normalized_messages,
|
||||
'max_tokens' => $options['max_tokens'],
|
||||
'temperature' => $options['temperature'],
|
||||
'json_mode' => $options['json_mode'],
|
||||
'tools' => $options['tools'],
|
||||
'tool_choice' => $options['tool_choice'],
|
||||
];
|
||||
|
||||
$args = apply_filters('wpmind_chat_args', $args, $context, $messages);
|
||||
|
||||
$original_model = $options['model'];
|
||||
$model_is_auto = ($original_model === 'auto');
|
||||
|
||||
if ($model_is_auto) {
|
||||
$model = $this->get_default_model();
|
||||
} else {
|
||||
$model = $original_model;
|
||||
}
|
||||
$model = apply_filters('wpmind_select_model', $model, $context, get_current_user_id());
|
||||
|
||||
$provider = $this->resolve_provider($options['provider'], $context);
|
||||
$failover_chain = $this->get_failover_chain($provider);
|
||||
|
||||
// Filter failover chain to only supported providers (e.g. vision-capable).
|
||||
if (!empty($options['failover_providers'])) {
|
||||
$allowed = $options['failover_providers'];
|
||||
$failover_chain = array_values(array_filter($failover_chain, function ($p) use ($allowed) {
|
||||
return in_array($p, $allowed, true);
|
||||
}));
|
||||
|
||||
if (empty($failover_chain)) {
|
||||
return new WP_Error(
|
||||
'wpmind_no_supported_provider',
|
||||
__('没有可用的服务商支持此请求类型', 'wpmind')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($failover_chain) && $failover_chain[0] !== $provider) {
|
||||
do_action('wpmind_provider_failover', $provider, $failover_chain[0], $context);
|
||||
}
|
||||
|
||||
$cache_key = $this->generate_cache_key('chat', $args, $provider, $model);
|
||||
$cache_lookup = $this->get_cached_value($cache_key, (int) $options['cache_ttl']);
|
||||
if ($cache_lookup['hit']) {
|
||||
return $cache_lookup['value'];
|
||||
}
|
||||
|
||||
do_action('wpmind_before_request', 'chat', $args, $context);
|
||||
|
||||
$result = null;
|
||||
$last_error = null;
|
||||
$tried_providers = [];
|
||||
$failover_count = count($failover_chain);
|
||||
|
||||
foreach ($failover_chain as $index => $try_provider) {
|
||||
$tried_providers[] = $try_provider;
|
||||
$is_last_provider = ($index === $failover_count - 1);
|
||||
$max_retries = $is_last_provider ? 3 : 1;
|
||||
|
||||
if ($model_is_auto) {
|
||||
$try_model = $this->get_current_model($try_provider);
|
||||
} else {
|
||||
$try_model = $model;
|
||||
$endpoints = $this->get_endpoints();
|
||||
$provider_models = $endpoints[$try_provider]['models'] ?? [];
|
||||
if (!empty($provider_models) && !in_array($try_model, $provider_models, true)) {
|
||||
$try_model = $this->get_current_model($try_provider);
|
||||
}
|
||||
}
|
||||
|
||||
for ($attempt = 0; $attempt <= $max_retries; $attempt++) {
|
||||
$result = $this->execute_chat_request($args, $try_provider, $try_model);
|
||||
|
||||
if (!is_wp_error($result)) {
|
||||
if ($try_provider !== $provider) {
|
||||
$result['failover'] = [
|
||||
'original_provider' => $provider,
|
||||
'actual_provider' => $try_provider,
|
||||
'tried_providers' => $tried_providers,
|
||||
];
|
||||
}
|
||||
|
||||
if ($try_model !== $model) {
|
||||
$result['model_fallback'] = true;
|
||||
$result['original_model'] = $model;
|
||||
}
|
||||
break 2;
|
||||
}
|
||||
|
||||
$last_error = $result;
|
||||
|
||||
$error_code = $result->get_error_code();
|
||||
if (in_array($error_code, ['wpmind_api_key_missing', 'wpmind_provider_not_found'], true)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$error_data = $result->get_error_data();
|
||||
$status = is_array($error_data) && isset($error_data['status']) ? (int) $error_data['status'] : 0;
|
||||
|
||||
if ($status > 0 && !\WPMind\ErrorHandler::should_retry($status)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($attempt < $max_retries) {
|
||||
$delay_ms = \WPMind\ErrorHandler::get_retry_delay($attempt + 1);
|
||||
do_action('wpmind_retry', $try_provider, $attempt + 1, $status);
|
||||
sleep((int) ($delay_ms / 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
do_action('wpmind_error', $result, 'chat', $args);
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result = apply_filters('wpmind_chat_response', $result, $args, $context);
|
||||
|
||||
$usage = $result['usage'] ?? [];
|
||||
do_action('wpmind_after_request', 'chat', $result, $args, $usage);
|
||||
|
||||
if (!is_wp_error($result)) {
|
||||
$this->set_cached_value($cache_key, $result, (int) $options['cache_ttl'], [
|
||||
'type' => 'chat',
|
||||
'context' => $context,
|
||||
'provider' => $provider,
|
||||
'model' => $model,
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式输出
|
||||
*
|
||||
* @since 2.6.0
|
||||
* @param array|string $messages 消息
|
||||
* @param callable $callback 回调函数
|
||||
* @param array $options 选项
|
||||
* @return bool|WP_Error
|
||||
*/
|
||||
public function stream($messages, callable $callback, array $options = []) {
|
||||
$defaults = [
|
||||
'context' => '',
|
||||
'system' => '',
|
||||
'max_tokens' => 2000,
|
||||
'temperature' => 0.7,
|
||||
'model' => 'auto',
|
||||
'provider' => 'auto',
|
||||
];
|
||||
$options = wp_parse_args($options, $defaults);
|
||||
|
||||
$context = $options['context'];
|
||||
$normalized_messages = $this->normalize_messages($messages, $options);
|
||||
|
||||
$original_model = $options['model'];
|
||||
$model_is_auto = ($original_model === 'auto');
|
||||
|
||||
$provider = $this->resolve_provider($options['provider'], $context);
|
||||
$failover_chain = $this->get_failover_chain($provider);
|
||||
|
||||
if (!empty($failover_chain) && $failover_chain[0] !== $provider) {
|
||||
do_action('wpmind_provider_failover', $provider, $failover_chain[0], $context);
|
||||
}
|
||||
|
||||
do_action('wpmind_before_request', 'stream', compact('messages', 'options'), $context);
|
||||
|
||||
$endpoints = $this->get_endpoints();
|
||||
$last_error = null;
|
||||
|
||||
foreach ($failover_chain as $index => $try_provider) {
|
||||
if (!isset($endpoints[$try_provider])) {
|
||||
$last_error = new WP_Error('wpmind_provider_not_found',
|
||||
sprintf(__('服务商 %s 未配置', 'wpmind'), $try_provider));
|
||||
continue;
|
||||
}
|
||||
|
||||
$endpoint = $endpoints[$try_provider];
|
||||
$api_key = $endpoint['api_key'] ?? '';
|
||||
|
||||
if (empty($api_key)) {
|
||||
$last_error = new WP_Error('wpmind_api_key_missing',
|
||||
sprintf(__('服务商 %s 未配置 API Key', 'wpmind'), $try_provider));
|
||||
continue;
|
||||
}
|
||||
|
||||
$model = $model_is_auto
|
||||
? $this->get_current_model($try_provider)
|
||||
: $original_model;
|
||||
|
||||
if ($model === 'auto' || $model === 'default') {
|
||||
$model = $endpoint['models'][0] ?? 'gpt-3.5-turbo';
|
||||
}
|
||||
|
||||
$base_url = $endpoint['custom_base_url'] ?? $endpoint['base_url'] ?? '';
|
||||
$api_url = trailingslashit($base_url) . 'chat/completions';
|
||||
|
||||
$request_body = [
|
||||
'model' => $model,
|
||||
'messages' => $normalized_messages,
|
||||
'max_tokens' => $options['max_tokens'],
|
||||
'temperature' => $options['temperature'],
|
||||
'stream' => true,
|
||||
];
|
||||
|
||||
$stream_context_options = [
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $api_key,
|
||||
],
|
||||
'content' => wp_json_encode($request_body),
|
||||
'timeout' => 120,
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => true,
|
||||
],
|
||||
];
|
||||
|
||||
$start_time = microtime(true);
|
||||
|
||||
// 检查 allow_url_fopen 是否启用
|
||||
if (!ini_get('allow_url_fopen')) {
|
||||
$last_error = new WP_Error('wpmind_stream_unsupported',
|
||||
__('流式输出需要 PHP allow_url_fopen 配置启用', 'wpmind'));
|
||||
continue;
|
||||
}
|
||||
|
||||
$stream_ctx = stream_context_create($stream_context_options);
|
||||
$stream = @fopen($api_url, 'r', false, $stream_ctx);
|
||||
|
||||
if (!$stream) {
|
||||
$latency_ms = (int)((microtime(true) - $start_time) * 1000);
|
||||
$this->record_result($try_provider, false, $latency_ms);
|
||||
|
||||
$last_error = new WP_Error('wpmind_stream_failed',
|
||||
sprintf(__('服务商 %s 无法建立流式连接', 'wpmind'), $try_provider));
|
||||
continue;
|
||||
}
|
||||
|
||||
$full_content = '';
|
||||
|
||||
while (!feof($stream)) {
|
||||
$line = fgets($stream);
|
||||
if (empty($line)) continue;
|
||||
|
||||
$line = trim($line);
|
||||
if (strpos($line, 'data: ') !== 0) continue;
|
||||
|
||||
$data = substr($line, 6);
|
||||
if ($data === '[DONE]') break;
|
||||
|
||||
$json = json_decode($data, true);
|
||||
if (!$json) continue;
|
||||
|
||||
$delta = $json['choices'][0]['delta']['content'] ?? '';
|
||||
if (!empty($delta)) {
|
||||
$full_content .= $delta;
|
||||
call_user_func($callback, $delta, $json);
|
||||
}
|
||||
}
|
||||
|
||||
fclose($stream);
|
||||
|
||||
$latency_ms = (int)((microtime(true) - $start_time) * 1000);
|
||||
$this->record_result($try_provider, true, $latency_ms);
|
||||
|
||||
do_action('wpmind_after_request', 'stream', ['content' => $full_content], compact('messages', 'options'), []);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($last_error) {
|
||||
do_action('wpmind_error', $last_error, 'stream', compact('messages', 'options'));
|
||||
return $last_error;
|
||||
}
|
||||
|
||||
return new WP_Error('wpmind_stream_failed', __('无法建立流式连接', 'wpmind'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化消息格式
|
||||
*
|
||||
* @param array|string $messages 原始消息
|
||||
* @param array $options 选项
|
||||
* @return array
|
||||
*/
|
||||
public function normalize_messages($messages, array $options): array {
|
||||
if (is_string($messages)) {
|
||||
$normalized = [];
|
||||
|
||||
if (!empty($options['system'])) {
|
||||
$normalized[] = [
|
||||
'role' => 'system',
|
||||
'content' => $options['system'],
|
||||
];
|
||||
}
|
||||
|
||||
$normalized[] = [
|
||||
'role' => 'user',
|
||||
'content' => $messages,
|
||||
];
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应该使用 SDK 执行请求
|
||||
*
|
||||
* @since 3.6.0
|
||||
* @param string $provider 服务商 ID
|
||||
* @param array $args 请求参数
|
||||
* @return bool
|
||||
*/
|
||||
private function should_use_sdk(string $provider, array $args): bool {
|
||||
if (!class_exists('\\WPMind\\SDK\\SDKAdapter')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!class_exists('WordPress\\AiClient\\AiClient')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!get_option('wpmind_sdk_enabled', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($args['tools'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sdk_providers = apply_filters('wpmind_sdk_providers', ['anthropic', 'google']);
|
||||
return in_array($provider, $sdk_providers, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Chat 请求(路由方法)
|
||||
*
|
||||
* @since 3.6.0
|
||||
* @param array $args 请求参数
|
||||
* @param string $provider 服务商
|
||||
* @param string $model 模型
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
private function execute_chat_request(array $args, string $provider, string $model) {
|
||||
if ($this->should_use_sdk($provider, $args)) {
|
||||
$sdk = new \WPMind\SDK\SDKAdapter();
|
||||
$start_time = microtime(true);
|
||||
$result = $sdk->chat($args, $provider, $model);
|
||||
$latency_ms = (int)((microtime(true) - $start_time) * 1000);
|
||||
|
||||
if (!is_wp_error($result)) {
|
||||
$this->record_result($provider, true, $latency_ms);
|
||||
return $result;
|
||||
}
|
||||
|
||||
$error_code = $result->get_error_code();
|
||||
|
||||
if ($error_code === 'wpmind_sdk_invalid_args' || $error_code === 'wpmind_sdk_unavailable') {
|
||||
do_action('wpmind_sdk_fallback', $provider, $error_code, $result->get_error_message());
|
||||
} else {
|
||||
$this->record_result($provider, false, $latency_ms);
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->execute_chat_request_native($args, $provider, $model);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过原生 HTTP 执行 chat 请求
|
||||
*
|
||||
* @since 3.6.0
|
||||
* @param array $args 请求参数
|
||||
* @param string $provider 服务商
|
||||
* @param string $model 模型
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
private function execute_chat_request_native(array $args, string $provider, string $model) {
|
||||
$endpoints = $this->get_endpoints();
|
||||
|
||||
if (!isset($endpoints[$provider])) {
|
||||
return new WP_Error(
|
||||
'wpmind_provider_not_found',
|
||||
sprintf(__('服务商 %s 未配置', 'wpmind'), $provider)
|
||||
);
|
||||
}
|
||||
|
||||
$endpoint = $endpoints[$provider];
|
||||
$api_key = $endpoint['api_key'] ?? '';
|
||||
|
||||
if (empty($api_key)) {
|
||||
return new WP_Error(
|
||||
'wpmind_api_key_missing',
|
||||
sprintf(__('服务商 %s 未配置 API Key', 'wpmind'), $provider)
|
||||
);
|
||||
}
|
||||
|
||||
if ($model === 'auto' || empty($model) || $model === 'default') {
|
||||
$model = $endpoint['models'][0] ?? 'gpt-3.5-turbo';
|
||||
}
|
||||
|
||||
$request_body = [
|
||||
'model' => $model,
|
||||
'messages' => $args['messages'],
|
||||
'max_tokens' => $args['max_tokens'],
|
||||
'temperature' => $args['temperature'],
|
||||
];
|
||||
|
||||
if (!empty($args['json_mode'])) {
|
||||
$request_body['response_format'] = ['type' => 'json_object'];
|
||||
}
|
||||
|
||||
if (!empty($args['tools'])) {
|
||||
$request_body['tools'] = $args['tools'];
|
||||
if (!empty($args['tool_choice']) && $args['tool_choice'] !== 'auto') {
|
||||
$request_body['tool_choice'] = $args['tool_choice'];
|
||||
}
|
||||
}
|
||||
|
||||
$base_url = $endpoint['custom_base_url'] ?? $endpoint['base_url'] ?? '';
|
||||
$api_url = trailingslashit($base_url) . 'chat/completions';
|
||||
|
||||
$start_time = microtime(true);
|
||||
|
||||
$response = wp_remote_post($api_url, [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'body' => wp_json_encode($request_body),
|
||||
'timeout' => 60,
|
||||
]);
|
||||
|
||||
$latency_ms = (int)((microtime(true) - $start_time) * 1000);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
$this->record_result($provider, false, $latency_ms);
|
||||
|
||||
return new WP_Error(
|
||||
'wpmind_request_failed',
|
||||
sprintf(__('请求失败: %s', 'wpmind'), $response->get_error_message())
|
||||
);
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code($response);
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
$data = json_decode($body, true);
|
||||
|
||||
if ($status_code !== 200) {
|
||||
$this->record_result($provider, false, $latency_ms);
|
||||
|
||||
$error_message = $data['error']['message'] ?? __('未知错误', 'wpmind');
|
||||
return new WP_Error(
|
||||
'wpmind_api_error',
|
||||
sprintf(__('API 错误 (%d): %s', 'wpmind'), $status_code, $error_message),
|
||||
['status' => $status_code, 'body' => substr((string) $body, 0, 500)]
|
||||
);
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
$this->record_result($provider, false, $latency_ms);
|
||||
|
||||
return new WP_Error(
|
||||
'wpmind_invalid_response',
|
||||
__('服务商返回了无效的响应格式', 'wpmind'),
|
||||
['status' => $status_code, 'body' => substr((string) $body, 0, 500)]
|
||||
);
|
||||
}
|
||||
|
||||
$this->record_result($provider, true, $latency_ms);
|
||||
|
||||
return $this->parse_chat_response($data, $provider, $model);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Chat 响应
|
||||
*
|
||||
* @param array $response 原始响应
|
||||
* @param string $provider 服务商
|
||||
* @param string $model 模型
|
||||
* @return array
|
||||
*/
|
||||
public function parse_chat_response(array $response, string $provider, string $model): array {
|
||||
$content = '';
|
||||
$tool_calls = [];
|
||||
$finish_reason = '';
|
||||
$usage = [
|
||||
'prompt_tokens' => 0,
|
||||
'completion_tokens' => 0,
|
||||
'total_tokens' => 0,
|
||||
];
|
||||
|
||||
$message = $response['choices'][0]['message'] ?? [];
|
||||
$finish_reason = $response['choices'][0]['finish_reason'] ?? '';
|
||||
|
||||
if (isset($message['content'])) {
|
||||
$content = $message['content'];
|
||||
} elseif (isset($response['content'][0]['text'])) {
|
||||
$content = $response['content'][0]['text'];
|
||||
}
|
||||
|
||||
if (isset($message['tool_calls']) && is_array($message['tool_calls'])) {
|
||||
foreach ($message['tool_calls'] as $call) {
|
||||
$tool_calls[] = [
|
||||
'id' => $call['id'] ?? '',
|
||||
'type' => $call['type'] ?? 'function',
|
||||
'function' => [
|
||||
'name' => $call['function']['name'] ?? '',
|
||||
'arguments' => $call['function']['arguments'] ?? '{}',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($response['usage'])) {
|
||||
$usage = [
|
||||
'prompt_tokens' => $response['usage']['prompt_tokens'] ?? 0,
|
||||
'completion_tokens' => $response['usage']['completion_tokens'] ?? 0,
|
||||
'total_tokens' => $response['usage']['total_tokens'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
$result = [
|
||||
'content' => $content,
|
||||
'provider' => $provider,
|
||||
'model' => $model,
|
||||
'usage' => $usage,
|
||||
'finish_reason' => $finish_reason,
|
||||
];
|
||||
|
||||
if (!empty($tool_calls)) {
|
||||
$result['tool_calls'] = $tool_calls;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认响应过滤器
|
||||
*
|
||||
* @param array $response 响应
|
||||
* @param array $args 参数
|
||||
* @param string $context 上下文
|
||||
* @return array
|
||||
*/
|
||||
public function filter_chat_response(array $response, array $args, string $context): array {
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共访问器:获取当前模型(供 Facade 的 get_status() 使用)
|
||||
*
|
||||
* @param string $provider 服务商
|
||||
* @return string
|
||||
*/
|
||||
public function get_current_model_public(string $provider): string {
|
||||
return $this->get_current_model($provider);
|
||||
}
|
||||
}
|
||||
131
includes/API/Services/EmbeddingService.php
Normal file
131
includes/API/Services/EmbeddingService.php
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
/**
|
||||
* Embedding Service
|
||||
*
|
||||
* 处理文本嵌入向量
|
||||
*
|
||||
* @package WPMind
|
||||
* @subpackage API\Services
|
||||
* @since 3.7.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\API\Services;
|
||||
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* Embedding Service
|
||||
*
|
||||
* @since 3.7.0
|
||||
*/
|
||||
class EmbeddingService extends AbstractService {
|
||||
|
||||
/**
|
||||
* 嵌入模型映射表
|
||||
*/
|
||||
private const EMBED_MODELS = [
|
||||
'openai' => 'text-embedding-3-small',
|
||||
'deepseek' => 'text-embedding-3-small',
|
||||
'zhipu' => 'embedding-2',
|
||||
'qwen' => 'text-embedding-v2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 文本嵌入向量
|
||||
*
|
||||
* @since 2.6.0
|
||||
* @param string|array $texts 要嵌入的文本
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public function embed($texts, array $options = []) {
|
||||
$defaults = [
|
||||
'context' => 'embedding',
|
||||
'model' => 'auto',
|
||||
'provider' => 'auto',
|
||||
];
|
||||
$options = wp_parse_args($options, $defaults);
|
||||
|
||||
$context = $options['context'];
|
||||
$input_texts = is_array($texts) ? $texts : [$texts];
|
||||
|
||||
$original_model = $options['model'];
|
||||
$model_is_auto = ($original_model === 'auto');
|
||||
|
||||
$provider = $this->resolve_provider($options['provider'], $context);
|
||||
|
||||
do_action('wpmind_before_request', 'embed', compact('texts', 'options'), $context);
|
||||
|
||||
return $this->execute_with_failover('embed', $provider, $context, function (string $try_provider, array $endpoint) use ($input_texts, $model_is_auto, $original_model, $texts, $options) {
|
||||
$embed_model = $model_is_auto
|
||||
? (self::EMBED_MODELS[$try_provider] ?? 'text-embedding-3-small')
|
||||
: $original_model;
|
||||
|
||||
$base_url = $endpoint['custom_base_url'] ?? $endpoint['base_url'] ?? '';
|
||||
$api_url = trailingslashit($base_url) . 'embeddings';
|
||||
$api_key = $endpoint['api_key'];
|
||||
|
||||
$start_time = microtime(true);
|
||||
|
||||
$response = wp_remote_post($api_url, [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'body' => wp_json_encode([
|
||||
'model' => $embed_model,
|
||||
'input' => $input_texts,
|
||||
]),
|
||||
'timeout' => 60,
|
||||
]);
|
||||
|
||||
$latency_ms = (int)((microtime(true) - $start_time) * 1000);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
$this->record_result($try_provider, false, $latency_ms);
|
||||
return new WP_Error('wpmind_embed_failed',
|
||||
sprintf(__('嵌入请求失败: %s', 'wpmind'), $response->get_error_message()));
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code($response);
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
$data = json_decode($body, true);
|
||||
|
||||
if ($status_code !== 200) {
|
||||
$this->record_result($try_provider, false, $latency_ms);
|
||||
$error_message = is_array($data) ? ($data['error']['message'] ?? __('未知错误', 'wpmind')) : __('未知错误', 'wpmind');
|
||||
return new WP_Error('wpmind_embed_error',
|
||||
sprintf(__('嵌入 API 错误 (%d): %s', 'wpmind'), $status_code, $error_message));
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
$this->record_result($try_provider, false, $latency_ms);
|
||||
return new WP_Error('wpmind_invalid_response', __('嵌入 API 返回了无效的响应格式', 'wpmind'));
|
||||
}
|
||||
|
||||
$this->record_result($try_provider, true, $latency_ms);
|
||||
|
||||
$embeddings = [];
|
||||
foreach ($data['data'] ?? [] as $item) {
|
||||
$embeddings[] = $item['embedding'];
|
||||
}
|
||||
|
||||
$usage = [
|
||||
'prompt_tokens' => $data['usage']['prompt_tokens'] ?? 0,
|
||||
'total_tokens' => $data['usage']['total_tokens'] ?? 0,
|
||||
];
|
||||
|
||||
do_action('wpmind_after_request', 'embed', $embeddings, compact('texts', 'options'), $usage);
|
||||
|
||||
return [
|
||||
'embeddings' => $embeddings,
|
||||
'model' => $embed_model,
|
||||
'provider' => $try_provider,
|
||||
'usage' => $usage,
|
||||
'dimensions' => !empty($embeddings[0]) ? count($embeddings[0]) : 0,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
66
includes/API/Services/ImageService.php
Normal file
66
includes/API/Services/ImageService.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
/**
|
||||
* Image Service
|
||||
*
|
||||
* 处理图像生成
|
||||
*
|
||||
* @package WPMind
|
||||
* @subpackage API\Services
|
||||
* @since 3.7.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\API\Services;
|
||||
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* Image Service
|
||||
*
|
||||
* @since 3.7.0
|
||||
*/
|
||||
class ImageService extends AbstractService {
|
||||
|
||||
/**
|
||||
* 生成图像
|
||||
*
|
||||
* @param string $prompt 图像描述
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public function generate_image(string $prompt, array $options = []) {
|
||||
$defaults = [
|
||||
'context' => 'image_generation',
|
||||
'size' => '1024x1024',
|
||||
'quality' => 'standard',
|
||||
'style' => 'natural',
|
||||
'provider' => 'auto',
|
||||
'return_format' => 'url',
|
||||
];
|
||||
$options = wp_parse_args($options, $defaults);
|
||||
|
||||
$context = $options['context'];
|
||||
|
||||
do_action('wpmind_before_request', 'image', compact('prompt', 'options'), $context);
|
||||
|
||||
if (class_exists('\\WPMind\\Providers\\Image\\ImageRouter')) {
|
||||
$router = \WPMind\Providers\Image\ImageRouter::instance();
|
||||
$result = $router->generate($prompt, $options);
|
||||
} else {
|
||||
return new WP_Error(
|
||||
'wpmind_image_not_available',
|
||||
__('图像生成服务不可用', 'wpmind')
|
||||
);
|
||||
}
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
do_action('wpmind_error', $result, 'image', compact('prompt', 'options'));
|
||||
return $result;
|
||||
}
|
||||
|
||||
do_action('wpmind_after_request', 'image', $result, compact('prompt', 'options'), []);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
215
includes/API/Services/StructuredOutputService.php
Normal file
215
includes/API/Services/StructuredOutputService.php
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
<?php
|
||||
/**
|
||||
* Structured Output Service
|
||||
*
|
||||
* 处理结构化输出(JSON Schema)和批量处理
|
||||
*
|
||||
* @package WPMind
|
||||
* @subpackage API\Services
|
||||
* @since 3.7.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\API\Services;
|
||||
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* Structured Output Service
|
||||
*
|
||||
* @since 3.7.0
|
||||
*/
|
||||
class StructuredOutputService extends AbstractService {
|
||||
|
||||
private ChatService $chat_service;
|
||||
|
||||
public function __construct(ChatService $chat_service) {
|
||||
$this->chat_service = $chat_service;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结构化输出(JSON Schema)
|
||||
*
|
||||
* @since 2.6.0
|
||||
* @param array|string $messages 消息
|
||||
* @param array $schema JSON Schema
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public function structured($messages, array $schema, array $options = []) {
|
||||
$defaults = [
|
||||
'context' => 'structured',
|
||||
'max_tokens' => 2000,
|
||||
'temperature' => 0.3,
|
||||
'retries' => 3,
|
||||
];
|
||||
$options = wp_parse_args($options, $defaults);
|
||||
|
||||
$context = $options['context'];
|
||||
|
||||
$schema_json = wp_json_encode($schema, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
$schema_prompt = "你必须返回严格符合以下 JSON Schema 的 JSON 对象。不要返回其他内容,只返回 JSON:\n\n```json\n{$schema_json}\n```";
|
||||
|
||||
if (is_string($messages)) {
|
||||
$messages = [
|
||||
['role' => 'system', 'content' => $schema_prompt],
|
||||
['role' => 'user', 'content' => $messages],
|
||||
];
|
||||
} else {
|
||||
array_unshift($messages, ['role' => 'system', 'content' => $schema_prompt]);
|
||||
}
|
||||
|
||||
$max_retries = $options['retries'];
|
||||
$last_error = null;
|
||||
|
||||
for ($attempt = 1; $attempt <= $max_retries; $attempt++) {
|
||||
$result = $this->chat_service->chat($messages, [
|
||||
'context' => $context,
|
||||
'max_tokens' => $options['max_tokens'],
|
||||
'temperature' => $options['temperature'],
|
||||
'json_mode' => true,
|
||||
'cache_ttl' => 0,
|
||||
]);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$content = $result['content'];
|
||||
$parsed = json_decode($content, true);
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
if ($this->validate_schema($parsed, $schema)) {
|
||||
return [
|
||||
'data' => $parsed,
|
||||
'provider' => $result['provider'],
|
||||
'model' => $result['model'],
|
||||
'usage' => $result['usage'],
|
||||
'attempts' => $attempt,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$last_error = json_last_error_msg();
|
||||
|
||||
$messages[] = ['role' => 'assistant', 'content' => $content];
|
||||
$messages[] = [
|
||||
'role' => 'user',
|
||||
'content' => "JSON 解析失败或不符合 Schema: {$last_error}。请重新生成严格符合 Schema 的 JSON。",
|
||||
];
|
||||
}
|
||||
|
||||
return new WP_Error(
|
||||
'wpmind_structured_failed',
|
||||
sprintf(__('结构化输出失败(尝试 %d 次): %s', 'wpmind'), $max_retries, $last_error)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量处理
|
||||
*
|
||||
* @since 2.6.0
|
||||
* @param array $items 要处理的项目数组
|
||||
* @param string $prompt_template Prompt 模板
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public function batch(array $items, string $prompt_template, array $options = []) {
|
||||
$defaults = [
|
||||
'context' => 'batch',
|
||||
'max_tokens' => 500,
|
||||
'temperature' => 0.7,
|
||||
'concurrency' => 1,
|
||||
'delay_ms' => 100,
|
||||
'stop_on_error' => false,
|
||||
];
|
||||
$options = wp_parse_args($options, $defaults);
|
||||
|
||||
$context = $options['context'];
|
||||
$results = [];
|
||||
$errors = [];
|
||||
|
||||
do_action('wpmind_before_request', 'batch', compact('items', 'prompt_template', 'options'), $context);
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
$item_str = is_array($item) ? wp_json_encode($item, JSON_UNESCAPED_UNICODE) : (string)$item;
|
||||
$prompt = str_replace('{{item}}', $item_str, $prompt_template);
|
||||
$prompt = str_replace('{{index}}', (string)$index, $prompt);
|
||||
|
||||
$result = $this->chat_service->chat($prompt, [
|
||||
'context' => $context . '_item_' . $index,
|
||||
'max_tokens' => $options['max_tokens'],
|
||||
'temperature' => $options['temperature'],
|
||||
'cache_ttl' => 0,
|
||||
]);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
$errors[$index] = $result->get_error_message();
|
||||
if ($options['stop_on_error']) {
|
||||
break;
|
||||
}
|
||||
$results[$index] = null;
|
||||
} else {
|
||||
$results[$index] = [
|
||||
'content' => $result['content'],
|
||||
'usage' => $result['usage'],
|
||||
];
|
||||
}
|
||||
|
||||
if ($options['delay_ms'] > 0 && $index < count($items) - 1) {
|
||||
usleep($options['delay_ms'] * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
$total_tokens = array_sum(array_map(function($r) {
|
||||
return $r['usage']['total_tokens'] ?? 0;
|
||||
}, array_filter($results)));
|
||||
|
||||
do_action('wpmind_after_request', 'batch', $results, compact('items', 'options'), ['total_tokens' => $total_tokens]);
|
||||
|
||||
return [
|
||||
'results' => $results,
|
||||
'errors' => $errors,
|
||||
'total_items' => count($items),
|
||||
'success_count'=> count(array_filter($results)),
|
||||
'error_count' => count($errors),
|
||||
'total_tokens' => $total_tokens,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 JSON Schema(简化版)
|
||||
*
|
||||
* @param array $data 数据
|
||||
* @param array $schema Schema
|
||||
* @return bool
|
||||
*/
|
||||
public function validate_schema(array $data, array $schema): bool {
|
||||
if (isset($schema['required'])) {
|
||||
foreach ($schema['required'] as $field) {
|
||||
if (!isset($data[$field])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($schema['properties'])) {
|
||||
foreach ($schema['properties'] as $key => $prop) {
|
||||
if (!isset($data[$key])) continue;
|
||||
|
||||
$value = $data[$key];
|
||||
$type = $prop['type'] ?? null;
|
||||
|
||||
if ($type === 'string' && !is_string($value)) return false;
|
||||
if ($type === 'integer' && !is_int($value)) return false;
|
||||
if ($type === 'number' && !is_numeric($value)) return false;
|
||||
if ($type === 'boolean' && !is_bool($value)) return false;
|
||||
if ($type === 'array' && !is_array($value)) return false;
|
||||
if ($type === 'object' && !is_array($value)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
315
includes/API/Services/TextProcessingService.php
Normal file
315
includes/API/Services/TextProcessingService.php
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
<?php
|
||||
/**
|
||||
* Text Processing Service
|
||||
*
|
||||
* 处理翻译、摘要、内容审核
|
||||
*
|
||||
* @package WPMind
|
||||
* @subpackage API\Services
|
||||
* @since 3.7.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\API\Services;
|
||||
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* Text Processing Service
|
||||
*
|
||||
* @since 3.7.0
|
||||
*/
|
||||
class TextProcessingService extends AbstractService {
|
||||
|
||||
private ChatService $chat_service;
|
||||
private StructuredOutputService $structured_service;
|
||||
|
||||
public function __construct(ChatService $chat_service, StructuredOutputService $structured_service) {
|
||||
$this->chat_service = $chat_service;
|
||||
$this->structured_service = $structured_service;
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译文本
|
||||
*
|
||||
* @param string $text 要翻译的文本
|
||||
* @param string $from 源语言
|
||||
* @param string $to 目标语言
|
||||
* @param array $options 选项
|
||||
* @return string|WP_Error
|
||||
*/
|
||||
public function translate(string $text, string $from, string $to, array $options) {
|
||||
$defaults = [
|
||||
'context' => 'translation',
|
||||
'format' => 'text',
|
||||
'hint' => '',
|
||||
'cache_ttl' => 86400,
|
||||
];
|
||||
$options = wp_parse_args($options, $defaults);
|
||||
|
||||
$context = $options['context'];
|
||||
|
||||
$args = compact('text', 'from', 'to', 'options');
|
||||
$args = apply_filters('wpmind_translate_args', $args, $context);
|
||||
|
||||
$default_provider = get_option('wpmind_default_provider', 'openai');
|
||||
$default_model = $this->get_current_model($default_provider);
|
||||
|
||||
$cache_key = $this->generate_cache_key('translate', $args, $default_provider, $default_model);
|
||||
$cache_lookup = $this->get_cached_value($cache_key, (int) $options['cache_ttl']);
|
||||
if ($cache_lookup['hit']) {
|
||||
return $cache_lookup['value'];
|
||||
}
|
||||
|
||||
$prompt = $this->build_translate_prompt($text, $from, $to, $options);
|
||||
|
||||
do_action('wpmind_before_request', 'translate', $args, $context);
|
||||
|
||||
$result = $this->chat_service->chat($prompt, [
|
||||
'context' => $context,
|
||||
'max_tokens' => max(500, strlen($text) * 2),
|
||||
'temperature' => 0.3,
|
||||
'cache_ttl' => 0,
|
||||
]);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
do_action('wpmind_error', $result, 'translate', $args);
|
||||
return $result;
|
||||
}
|
||||
|
||||
$translated = trim($result['content']);
|
||||
|
||||
if ($options['format'] === 'slug') {
|
||||
$translated = sanitize_title_with_dashes($translated, '', 'save');
|
||||
}
|
||||
|
||||
$translated = apply_filters('wpmind_translate_response', $translated, $text, $from, $to);
|
||||
|
||||
do_action('wpmind_after_request', 'translate', $translated, $args, $result['usage'] ?? []);
|
||||
|
||||
$this->set_cached_value($cache_key, $translated, (int) $options['cache_ttl'], [
|
||||
'type' => 'translate',
|
||||
'context' => $context,
|
||||
'provider' => $default_provider,
|
||||
'model' => $default_model,
|
||||
]);
|
||||
|
||||
return $translated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本摘要
|
||||
*
|
||||
* @since 2.7.0
|
||||
* @param string $text 要摘要的文本
|
||||
* @param array $options 选项
|
||||
* @return string|WP_Error
|
||||
*/
|
||||
public function summarize(string $text, array $options = []) {
|
||||
$defaults = [
|
||||
'context' => 'summarize',
|
||||
'max_length' => 200,
|
||||
'style' => 'paragraph',
|
||||
'language' => 'auto',
|
||||
'cache_ttl' => 3600,
|
||||
];
|
||||
$options = wp_parse_args($options, $defaults);
|
||||
|
||||
$context = $options['context'];
|
||||
|
||||
$default_provider = get_option('wpmind_default_provider', 'openai');
|
||||
$default_model = $this->get_current_model($default_provider);
|
||||
|
||||
$cache_key = $this->generate_cache_key('summarize', compact('text', 'options'), $default_provider, $default_model);
|
||||
$cache_lookup = $this->get_cached_value($cache_key, (int) $options['cache_ttl']);
|
||||
if ($cache_lookup['hit']) {
|
||||
return $cache_lookup['value'];
|
||||
}
|
||||
|
||||
$style_prompts = [
|
||||
'paragraph' => '用一段简洁的文字总结以下内容',
|
||||
'bullet' => '用要点列表总结以下内容的关键信息',
|
||||
'title' => '为以下内容生成一个简洁的标题',
|
||||
];
|
||||
$style_prompt = $style_prompts[$options['style']] ?? $style_prompts['paragraph'];
|
||||
|
||||
$length_hint = $options['style'] === 'title'
|
||||
? '(不超过 20 个字)'
|
||||
: "(不超过 {$options['max_length']} 个字)";
|
||||
|
||||
$lang_hint = $options['language'] !== 'auto'
|
||||
? ",用{$options['language']}输出"
|
||||
: '';
|
||||
|
||||
$prompt = "{$style_prompt}{$length_hint}{$lang_hint}:\n\n{$text}";
|
||||
|
||||
do_action('wpmind_before_request', 'summarize', compact('text', 'options'), $context);
|
||||
|
||||
$result = $this->chat_service->chat($prompt, [
|
||||
'context' => $context,
|
||||
'max_tokens' => max(100, $options['max_length'] * 2),
|
||||
'temperature' => 0.3,
|
||||
'cache_ttl' => 0,
|
||||
]);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
do_action('wpmind_error', $result, 'summarize', compact('text', 'options'));
|
||||
return $result;
|
||||
}
|
||||
|
||||
$summary = trim($result['content']);
|
||||
|
||||
do_action('wpmind_after_request', 'summarize', $summary, compact('text', 'options'), $result['usage']);
|
||||
|
||||
$this->set_cached_value($cache_key, $summary, (int) $options['cache_ttl'], [
|
||||
'type' => 'summarize',
|
||||
'context' => $context,
|
||||
'provider' => $default_provider,
|
||||
'model' => $default_model,
|
||||
]);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容审核
|
||||
*
|
||||
* @since 2.7.0
|
||||
* @param string $content 要审核的内容
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public function moderate(string $content, array $options = []) {
|
||||
$defaults = [
|
||||
'context' => 'moderation',
|
||||
'categories' => ['spam', 'adult', 'violence', 'hate', 'illegal'],
|
||||
'threshold' => 0.7,
|
||||
'cache_ttl' => 300,
|
||||
];
|
||||
$options = wp_parse_args($options, $defaults);
|
||||
|
||||
$context = $options['context'];
|
||||
|
||||
$default_provider = get_option('wpmind_default_provider', 'openai');
|
||||
$default_model = $this->get_current_model($default_provider);
|
||||
|
||||
$cache_key = $this->generate_cache_key('moderate', compact('content', 'options'), $default_provider, $default_model);
|
||||
$cache_lookup = $this->get_cached_value($cache_key, (int) $options['cache_ttl']);
|
||||
if ($cache_lookup['hit']) {
|
||||
return $cache_lookup['value'];
|
||||
}
|
||||
|
||||
$categories = implode('、', $options['categories']);
|
||||
|
||||
$schema = [
|
||||
'type' => 'object',
|
||||
'required' => ['safe', 'categories'],
|
||||
'properties' => [
|
||||
'safe' => ['type' => 'boolean'],
|
||||
'categories' => [
|
||||
'type' => 'object',
|
||||
'properties' => array_combine(
|
||||
$options['categories'],
|
||||
array_fill(0, count($options['categories']), [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'flagged' => ['type' => 'boolean'],
|
||||
'score' => ['type' => 'number'],
|
||||
'reason' => ['type' => 'string'],
|
||||
],
|
||||
])
|
||||
),
|
||||
],
|
||||
'summary' => ['type' => 'string'],
|
||||
],
|
||||
];
|
||||
|
||||
$prompt = "请审核以下内容是否包含不当信息。检查类别:{$categories}。\n\n内容:\n{$content}";
|
||||
|
||||
do_action('wpmind_before_request', 'moderate', compact('content', 'options'), $context);
|
||||
|
||||
$result = $this->structured_service->structured($prompt, $schema, [
|
||||
'context' => $context,
|
||||
'temperature' => 0.1,
|
||||
'retries' => 2,
|
||||
]);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
do_action('wpmind_error', $result, 'moderate', compact('content', 'options'));
|
||||
return $result;
|
||||
}
|
||||
|
||||
$moderation = [
|
||||
'safe' => $result['data']['safe'] ?? true,
|
||||
'categories' => $result['data']['categories'] ?? [],
|
||||
'summary' => $result['data']['summary'] ?? '',
|
||||
'provider' => $result['provider'],
|
||||
'model' => $result['model'],
|
||||
'usage' => $result['usage'],
|
||||
];
|
||||
|
||||
do_action('wpmind_after_request', 'moderate', $moderation, compact('content', 'options'), $result['usage']);
|
||||
|
||||
$this->set_cached_value($cache_key, $moderation, (int) $options['cache_ttl'], [
|
||||
'type' => 'moderate',
|
||||
'context' => $context,
|
||||
'provider' => $default_provider,
|
||||
'model' => $default_model,
|
||||
]);
|
||||
|
||||
return $moderation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建翻译 Prompt
|
||||
*
|
||||
* @param string $text 文本
|
||||
* @param string $from 源语言
|
||||
* @param string $to 目标语言
|
||||
* @param array $options 选项
|
||||
* @return string
|
||||
*/
|
||||
private function build_translate_prompt(string $text, string $from, string $to, array $options): string {
|
||||
$lang_names = [
|
||||
'zh' => '中文',
|
||||
'en' => '英文',
|
||||
'ja' => '日文',
|
||||
'ko' => '韩文',
|
||||
'fr' => '法文',
|
||||
'de' => '德文',
|
||||
'es' => '西班牙文',
|
||||
'auto' => '自动检测',
|
||||
];
|
||||
|
||||
$from_name = $lang_names[$from] ?? $from;
|
||||
$to_name = $lang_names[$to] ?? $to;
|
||||
|
||||
if ($options['format'] === 'pinyin') {
|
||||
$prompt = "将以下中文文本转换为拼音,要求:
|
||||
1. 按词语分隔,不是按字分隔(如 '你好世界' 应为 'nihao-shijie' 而非 'ni-hao-shi-jie')
|
||||
2. 词语之间用连字符 '-' 连接
|
||||
3. 同一词语内的拼音不加分隔符
|
||||
4. 全部小写,无声调
|
||||
5. 保留英文和数字原样
|
||||
6. 只返回拼音结果,不要其他解释
|
||||
|
||||
文本:{$text}";
|
||||
return $prompt;
|
||||
}
|
||||
|
||||
$prompt = "将以下{$from_name}文本翻译成{$to_name}";
|
||||
|
||||
if ($options['format'] === 'slug') {
|
||||
$prompt .= ",输出结果应该适合作为 URL slug,使用小写英文和连字符";
|
||||
}
|
||||
|
||||
if (!empty($options['hint'])) {
|
||||
$prompt .= "。提示:{$options['hint']}";
|
||||
}
|
||||
|
||||
$prompt .= "。只返回翻译结果,不要其他解释:\n\n{$text}";
|
||||
|
||||
return $prompt;
|
||||
}
|
||||
}
|
||||
172
includes/API/Services/VisionHelper.php
Normal file
172
includes/API/Services/VisionHelper.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
/**
|
||||
* Vision Helper
|
||||
*
|
||||
* Static utility class for constructing multimodal vision messages.
|
||||
* Reuses the existing chat API for vision capabilities.
|
||||
*
|
||||
* @package WPMind\API\Services
|
||||
* @since 4.3.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\API\Services;
|
||||
|
||||
/**
|
||||
* Class VisionHelper
|
||||
*
|
||||
* Builds multimodal messages and resolves vision-capable providers.
|
||||
*/
|
||||
class VisionHelper {
|
||||
|
||||
/**
|
||||
* Providers that support vision (multimodal image input).
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
public const VISION_PROVIDERS = [ 'openai', 'anthropic', 'google', 'qwen', 'zhipu' ];
|
||||
|
||||
/**
|
||||
* Default vision model per provider.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public const VISION_MODELS = [
|
||||
'openai' => 'gpt-4o',
|
||||
'anthropic' => 'claude-3-5-sonnet-20241022',
|
||||
'google' => 'gemini-2.0-flash-exp',
|
||||
'qwen' => 'qwen-vl-max',
|
||||
'zhipu' => 'glm-4v',
|
||||
];
|
||||
|
||||
/**
|
||||
* Build multimodal vision messages for the chat API.
|
||||
*
|
||||
* @param string $image_url Image URL or base64 data URI.
|
||||
* @param string $prompt User prompt describing what to do with the image.
|
||||
* @param string $system Optional system prompt.
|
||||
* @return array Messages array compatible with wpmind_chat().
|
||||
*/
|
||||
public static function build_vision_messages( string $image_url, string $prompt, string $system = '' ): array {
|
||||
$messages = [];
|
||||
|
||||
if ( '' !== $system ) {
|
||||
$messages[] = [
|
||||
'role' => 'system',
|
||||
'content' => $system,
|
||||
];
|
||||
}
|
||||
|
||||
$messages[] = [
|
||||
'role' => 'user',
|
||||
'content' => [
|
||||
[
|
||||
'type' => 'image_url',
|
||||
'image_url' => [ 'url' => $image_url ],
|
||||
],
|
||||
[
|
||||
'type' => 'text',
|
||||
'text' => $prompt,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a WordPress attachment to a base64 data URI.
|
||||
*
|
||||
* @param int $attachment_id WordPress attachment ID.
|
||||
* @return string|false Data URI string or false on failure.
|
||||
*/
|
||||
public static function attachment_to_data_uri( int $attachment_id ): string|false {
|
||||
$file = get_attached_file( $attachment_id );
|
||||
if ( ! $file || ! file_exists( $file ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$mime = get_post_mime_type( $attachment_id );
|
||||
if ( ! $mime || ! str_starts_with( $mime, 'image/' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = file_get_contents( $file );
|
||||
if ( false === $data ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'data:' . $mime . ';base64,' . base64_encode( $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a vision-capable provider from configured endpoints.
|
||||
*
|
||||
* Falls back to 'openai' if no vision provider is configured.
|
||||
*
|
||||
* @return string Provider slug.
|
||||
*/
|
||||
public static function get_vision_provider(): string {
|
||||
$endpoints = get_option( 'wpmind_custom_endpoints', [] );
|
||||
|
||||
if ( ! is_array( $endpoints ) ) {
|
||||
return 'openai';
|
||||
}
|
||||
|
||||
// Prefer the default provider if it supports vision.
|
||||
$default = get_option( 'wpmind_default_provider', 'openai' );
|
||||
if ( in_array( $default, self::VISION_PROVIDERS, true ) ) {
|
||||
$ep = $endpoints[ $default ] ?? [];
|
||||
if ( ! empty( $ep['enabled'] ) && ! empty( $ep['api_key'] ) ) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise pick the first enabled vision-capable provider.
|
||||
foreach ( self::VISION_PROVIDERS as $provider ) {
|
||||
$ep = $endpoints[ $provider ] ?? [];
|
||||
if ( ! empty( $ep['enabled'] ) && ! empty( $ep['api_key'] ) ) {
|
||||
return $provider;
|
||||
}
|
||||
}
|
||||
|
||||
return 'openai';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default vision model for a provider.
|
||||
*
|
||||
* @param string $provider Provider slug.
|
||||
* @return string Model identifier.
|
||||
*/
|
||||
public static function get_vision_model( string $provider ): string {
|
||||
return self::VISION_MODELS[ $provider ] ?? 'gpt-4o';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configured vision-capable providers (enabled + has API key).
|
||||
*
|
||||
* Used to constrain the failover chain so non-vision providers
|
||||
* are never tried with multimodal image messages.
|
||||
*
|
||||
* @return string[] Array of provider slugs.
|
||||
*/
|
||||
public static function get_configured_vision_providers(): array {
|
||||
$endpoints = get_option( 'wpmind_custom_endpoints', [] );
|
||||
|
||||
if ( ! is_array( $endpoints ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$configured = [];
|
||||
foreach ( self::VISION_PROVIDERS as $provider ) {
|
||||
$ep = $endpoints[ $provider ] ?? [];
|
||||
if ( ! empty( $ep['enabled'] ) && ! empty( $ep['api_key'] ) ) {
|
||||
$configured[] = $provider;
|
||||
}
|
||||
}
|
||||
|
||||
return $configured;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@
|
|||
* @since 2.5.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
|
@ -60,6 +62,31 @@ if (!function_exists('wpmind_get_status')) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取精确缓存统计
|
||||
*
|
||||
* @since 4.0.0
|
||||
* @return array
|
||||
*/
|
||||
if (!function_exists('wpmind_get_cache_stats')) {
|
||||
function wpmind_get_cache_stats(): array {
|
||||
if (!class_exists('WPMind\API\PublicAPI')) {
|
||||
return [
|
||||
'enabled' => false,
|
||||
'hits' => 0,
|
||||
'misses' => 0,
|
||||
'writes' => 0,
|
||||
'hit_rate' => 0,
|
||||
'entries' => 0,
|
||||
'max_entries' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return \WPMind\API\PublicAPI::instance()->get_exact_cache_stats();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 对话
|
||||
*
|
||||
|
|
@ -146,6 +173,50 @@ if (!function_exists('wpmind_translate')) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将中文转换为语义化拼音
|
||||
*
|
||||
* 与普通拼音不同,语义化拼音按词语分隔而非按字分隔。
|
||||
* 例如 "你好世界" 会转换为 "nihao-shijie" 而不是 "ni-hao-shi-jie"。
|
||||
*
|
||||
* @since 2.5.0
|
||||
* @param string $text 要转换的中文文本
|
||||
* @param array $options {
|
||||
* 可选参数
|
||||
* @type string $context 上下文标识,默认 'pinyin_conversion'
|
||||
* @type int $cache_ttl 缓存时间(秒),默认 604800(7天)
|
||||
* }
|
||||
* @return string|WP_Error 成功返回拼音字符串,失败返回 WP_Error
|
||||
*
|
||||
* @example
|
||||
* $pinyin = wpmind_pinyin('你好世界');
|
||||
* // 返回: "nihao-shijie"
|
||||
*
|
||||
* @example
|
||||
* $pinyin = wpmind_pinyin('WordPress性能优化指南');
|
||||
* // 返回: "WordPress-xingneng-youhua-zhinan"
|
||||
*/
|
||||
if (!function_exists('wpmind_pinyin')) {
|
||||
function wpmind_pinyin(string $text, array $options = []) {
|
||||
if (!class_exists('WPMind\\API\\PublicAPI')) {
|
||||
return new WP_Error(
|
||||
'wpmind_not_available',
|
||||
__('WPMind 插件未激活', 'wpmind')
|
||||
);
|
||||
}
|
||||
|
||||
// 设置 format 为 pinyin
|
||||
$options = wp_parse_args($options, [
|
||||
'context' => 'pinyin_conversion',
|
||||
'format' => 'pinyin',
|
||||
'cache_ttl' => 604800, // 7 天
|
||||
]);
|
||||
|
||||
// 调用 translate 方法,但 format=pinyin 会触发拼音转换逻辑
|
||||
return \WPMind\API\PublicAPI::instance()->translate($text, 'zh', 'zh', $options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成图像
|
||||
*
|
||||
|
|
@ -207,3 +278,263 @@ add_filter('wpmind_translate', function($default, $text, $from = 'auto', $to = '
|
|||
}
|
||||
return $default;
|
||||
}, 10, 5);
|
||||
|
||||
// ============================================
|
||||
// v4.3.0 Vision API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* AI 图像理解(Vision)
|
||||
*
|
||||
* 利用多模态 chat 能力分析图片内容。
|
||||
*
|
||||
* @since 4.3.0
|
||||
* @param string $image_url 图片 URL 或 base64 data URI
|
||||
* @param string $prompt 提示词,描述需要对图片做什么
|
||||
* @param array $options {
|
||||
* 可选参数
|
||||
* @type string $system 系统提示词
|
||||
* @type int $max_tokens 最大 token 数,默认 300
|
||||
* @type float $temperature 温度,默认 0.3
|
||||
* @type string $provider 服务商,默认 'auto'(自动选择支持 vision 的)
|
||||
* @type string $language 语言,默认根据 locale 自动判断
|
||||
* @type string $context 上下文标识
|
||||
* }
|
||||
* @return array|WP_Error
|
||||
*
|
||||
* @example
|
||||
* $result = wpmind_vision(
|
||||
* 'https://example.com/photo.jpg',
|
||||
* '为这张图片生成简洁的 alt text'
|
||||
* );
|
||||
* echo $result['content'];
|
||||
*/
|
||||
if ( ! function_exists( 'wpmind_vision' ) ) {
|
||||
function wpmind_vision( string $image_url, string $prompt = '', array $options = [] ) {
|
||||
if ( ! class_exists( 'WPMind\\API\\PublicAPI' ) ) {
|
||||
return new WP_Error(
|
||||
'wpmind_not_available',
|
||||
__( 'WPMind 插件未激活', 'wpmind' )
|
||||
);
|
||||
}
|
||||
return \WPMind\API\PublicAPI::instance()->vision( $image_url, $prompt, $options );
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// v2.6.0 增强 API 全局函数
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 流式输出
|
||||
*
|
||||
* @since 2.6.0
|
||||
* @param array|string $messages 消息
|
||||
* @param callable $callback 回调函数,每收到一个 chunk 调用一次
|
||||
* @param array $options 选项
|
||||
* @return bool|WP_Error
|
||||
*
|
||||
* @example
|
||||
* wpmind_stream('写一个故事', function($chunk, $json) {
|
||||
* echo $chunk;
|
||||
* flush();
|
||||
* });
|
||||
*/
|
||||
if (!function_exists('wpmind_stream')) {
|
||||
function wpmind_stream($messages, callable $callback, array $options = []) {
|
||||
if (!class_exists('WPMind\\API\\PublicAPI')) {
|
||||
return new WP_Error('wpmind_not_available', __('WPMind 插件未激活', 'wpmind'));
|
||||
}
|
||||
return \WPMind\API\PublicAPI::instance()->stream($messages, $callback, $options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结构化输出(JSON Schema)
|
||||
*
|
||||
* @since 2.6.0
|
||||
* @param array|string $messages 消息
|
||||
* @param array $schema JSON Schema 定义
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*
|
||||
* @example
|
||||
* $result = wpmind_structured('提取这段文本的关键信息:...', [
|
||||
* 'type' => 'object',
|
||||
* 'required' => ['title', 'date', 'summary'],
|
||||
* 'properties' => [
|
||||
* 'title' => ['type' => 'string'],
|
||||
* 'date' => ['type' => 'string'],
|
||||
* 'summary' => ['type' => 'string'],
|
||||
* ],
|
||||
* ]);
|
||||
* // 返回: ['data' => ['title' => '...', 'date' => '...', 'summary' => '...'], ...]
|
||||
*/
|
||||
if (!function_exists('wpmind_structured')) {
|
||||
function wpmind_structured($messages, array $schema, array $options = []) {
|
||||
if (!class_exists('WPMind\\API\\PublicAPI')) {
|
||||
return new WP_Error('wpmind_not_available', __('WPMind 插件未激活', 'wpmind'));
|
||||
}
|
||||
return \WPMind\API\PublicAPI::instance()->structured($messages, $schema, $options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量处理
|
||||
*
|
||||
* @since 2.6.0
|
||||
* @param array $items 要处理的项目数组
|
||||
* @param string $prompt_template Prompt 模板,{{item}} 为占位符
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*
|
||||
* @example
|
||||
* $titles = ['标题1', '标题2', '标题3'];
|
||||
* $result = wpmind_batch($titles, '将这个标题翻译成英文:{{item}}');
|
||||
* // 返回: ['results' => [...], 'total_items' => 3, ...]
|
||||
*/
|
||||
if (!function_exists('wpmind_batch')) {
|
||||
function wpmind_batch(array $items, string $prompt_template, array $options = []) {
|
||||
if (!class_exists('WPMind\\API\\PublicAPI')) {
|
||||
return new WP_Error('wpmind_not_available', __('WPMind 插件未激活', 'wpmind'));
|
||||
}
|
||||
return \WPMind\API\PublicAPI::instance()->batch($items, $prompt_template, $options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本嵌入向量
|
||||
*
|
||||
* @since 2.6.0
|
||||
* @param string|array $texts 要嵌入的文本
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*
|
||||
* @example
|
||||
* $result = wpmind_embed('WordPress 是一个开源 CMS');
|
||||
* // 返回: ['embeddings' => [[0.123, 0.456, ...]], 'dimensions' => 1536, ...]
|
||||
*/
|
||||
if (!function_exists('wpmind_embed')) {
|
||||
function wpmind_embed($texts, array $options = []) {
|
||||
if (!class_exists('WPMind\\API\\PublicAPI')) {
|
||||
return new WP_Error('wpmind_not_available', __('WPMind 插件未激活', 'wpmind'));
|
||||
}
|
||||
return \WPMind\API\PublicAPI::instance()->embed($texts, $options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算 Token 数量
|
||||
*
|
||||
* @since 2.6.0
|
||||
* @param string|array $content 文本或消息数组
|
||||
* @return int 估算的 token 数量
|
||||
*
|
||||
* @example
|
||||
* $tokens = wpmind_count_tokens('这是一段中文文本');
|
||||
* // 返回: 约 8
|
||||
*/
|
||||
if (!function_exists('wpmind_count_tokens')) {
|
||||
function wpmind_count_tokens($content): int {
|
||||
if (!class_exists('WPMind\\API\\PublicAPI')) {
|
||||
// 简易估算
|
||||
$text = is_array($content) ? json_encode($content) : $content;
|
||||
return max(1, (int)(mb_strlen($text) / 3));
|
||||
}
|
||||
return \WPMind\API\PublicAPI::instance()->count_tokens($content);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// v2.7.0 专用 API 全局函数
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 文本摘要
|
||||
*
|
||||
* @since 2.7.0
|
||||
* @param string $text 要摘要的文本
|
||||
* @param array $options 选项
|
||||
* @return string|WP_Error
|
||||
*
|
||||
* @example
|
||||
* $summary = wpmind_summarize('这是一篇很长的文章...', [
|
||||
* 'style' => 'bullet', // paragraph/bullet/title
|
||||
* 'max_length' => 100,
|
||||
* ]);
|
||||
*/
|
||||
if (!function_exists('wpmind_summarize')) {
|
||||
function wpmind_summarize(string $text, array $options = []) {
|
||||
if (!class_exists('WPMind\\API\\PublicAPI')) {
|
||||
return new WP_Error('wpmind_not_available', __('WPMind 插件未激活', 'wpmind'));
|
||||
}
|
||||
return \WPMind\API\PublicAPI::instance()->summarize($text, $options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容审核
|
||||
*
|
||||
* @since 2.7.0
|
||||
* @param string $content 要审核的内容
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*
|
||||
* @example
|
||||
* $result = wpmind_moderate('用户提交的评论...');
|
||||
* if (!$result['safe']) {
|
||||
* // 内容不安全
|
||||
* }
|
||||
*/
|
||||
if (!function_exists('wpmind_moderate')) {
|
||||
function wpmind_moderate(string $content, array $options = []) {
|
||||
if (!class_exists('WPMind\\API\\PublicAPI')) {
|
||||
return new WP_Error('wpmind_not_available', __('WPMind 插件未激活', 'wpmind'));
|
||||
}
|
||||
return \WPMind\API\PublicAPI::instance()->moderate($content, $options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频转录(语音转文字)
|
||||
*
|
||||
* @since 2.7.0
|
||||
* @param string $audio_file 音频文件路径或 URL
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*
|
||||
* @example
|
||||
* $result = wpmind_transcribe('/path/to/audio.mp3');
|
||||
* echo $result['text'];
|
||||
*/
|
||||
if (!function_exists('wpmind_transcribe')) {
|
||||
function wpmind_transcribe(string $audio_file, array $options = []) {
|
||||
if (!class_exists('WPMind\\API\\PublicAPI')) {
|
||||
return new WP_Error('wpmind_not_available', __('WPMind 插件未激活', 'wpmind'));
|
||||
}
|
||||
return \WPMind\API\PublicAPI::instance()->transcribe($audio_file, $options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本转语音
|
||||
*
|
||||
* @since 2.7.0
|
||||
* @param string $text 要转换的文本
|
||||
* @param array $options 选项
|
||||
* @return array|WP_Error
|
||||
*
|
||||
* @example
|
||||
* $result = wpmind_speech('欢迎使用 WordPress', [
|
||||
* 'voice' => 'nova',
|
||||
* ]);
|
||||
* echo $result['url']; // 音频 URL
|
||||
*/
|
||||
if (!function_exists('wpmind_speech')) {
|
||||
function wpmind_speech(string $text, array $options = []) {
|
||||
if (!class_exists('WPMind\\API\\PublicAPI')) {
|
||||
return new WP_Error('wpmind_not_available', __('WPMind 插件未激活', 'wpmind'));
|
||||
}
|
||||
return \WPMind\API\PublicAPI::instance()->speech($text, $options);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
215
includes/Admin/AdminAssets.php
Normal file
215
includes/Admin/AdminAssets.php
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
<?php
|
||||
/**
|
||||
* Admin assets loader.
|
||||
*
|
||||
* @package WPMind\Admin
|
||||
* @since 3.3.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Admin;
|
||||
|
||||
/**
|
||||
* Class AdminAssets
|
||||
*/
|
||||
final class AdminAssets {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var AdminAssets|null
|
||||
*/
|
||||
private static ?AdminAssets $instance = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @return AdminAssets
|
||||
*/
|
||||
public static function instance(): AdminAssets {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* 加载管理后台资源
|
||||
*
|
||||
* @param string $hook_suffix 当前页面钩子后缀
|
||||
* @since 1.1.0
|
||||
*/
|
||||
public function enqueue_admin_assets( string $hook_suffix ): void {
|
||||
// 一级菜单的 hook suffix 是 toplevel_page_{menu_slug}
|
||||
if ( 'toplevel_page_wpmind' !== $hook_suffix ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remixicon 图标库
|
||||
wp_enqueue_style(
|
||||
'remixicon',
|
||||
'https://cdn.jsdelivr.net/npm/remixicon@4.9.1/fonts/remixicon.min.css',
|
||||
[],
|
||||
'4.9.1'
|
||||
);
|
||||
|
||||
wp_enqueue_style(
|
||||
'wpmind-admin',
|
||||
WPMIND_PLUGIN_URL . 'assets/css/admin.css',
|
||||
[ 'remixicon' ],
|
||||
WPMIND_VERSION
|
||||
);
|
||||
|
||||
wp_enqueue_style(
|
||||
'wpmind-modules',
|
||||
WPMIND_PLUGIN_URL . 'assets/css/modules.css',
|
||||
[ 'wpmind-admin' ],
|
||||
WPMIND_VERSION
|
||||
);
|
||||
|
||||
wp_enqueue_style(
|
||||
'wpmind-overview',
|
||||
WPMIND_PLUGIN_URL . 'assets/css/overview.css',
|
||||
[ 'wpmind-admin' ],
|
||||
WPMIND_VERSION
|
||||
);
|
||||
|
||||
wp_enqueue_style(
|
||||
'wpmind-panels',
|
||||
WPMIND_PLUGIN_URL . 'assets/css/panels.css',
|
||||
[ 'wpmind-admin' ],
|
||||
WPMIND_VERSION
|
||||
);
|
||||
|
||||
wp_enqueue_style(
|
||||
'wpmind-routing',
|
||||
WPMIND_PLUGIN_URL . 'assets/css/pages/routing.css',
|
||||
[ 'wpmind-panels' ],
|
||||
WPMIND_VERSION
|
||||
);
|
||||
|
||||
wp_enqueue_style(
|
||||
'wpmind-module-layout',
|
||||
WPMIND_PLUGIN_URL . 'assets/css/components/module-layout.css',
|
||||
[ 'wpmind-admin' ],
|
||||
WPMIND_VERSION
|
||||
);
|
||||
|
||||
wp_enqueue_style(
|
||||
'wpmind-responsive',
|
||||
WPMIND_PLUGIN_URL . 'assets/css/responsive.css',
|
||||
[ 'wpmind-panels', 'wpmind-routing' ],
|
||||
WPMIND_VERSION
|
||||
);
|
||||
|
||||
// Chart.js 图表库(本地优先,CDN 兜底)
|
||||
wp_register_script(
|
||||
'chartjs',
|
||||
WPMIND_PLUGIN_URL . 'assets/js/vendor/chartjs/chart.umd.min.js',
|
||||
[],
|
||||
'4.5.0',
|
||||
true
|
||||
);
|
||||
$chartjs_cdn = 'https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js';
|
||||
wp_add_inline_script(
|
||||
'chartjs',
|
||||
"if (typeof Chart === 'undefined' && !document.querySelector('script[data-wpmind-fallback=\"chartjs-cdn\"]')) {" .
|
||||
"var wpmindChartJsCdn = document.createElement('script');" .
|
||||
"wpmindChartJsCdn.src = '{$chartjs_cdn}';" .
|
||||
"wpmindChartJsCdn.defer = true;" .
|
||||
"wpmindChartJsCdn.setAttribute('data-wpmind-fallback', 'chartjs-cdn');" .
|
||||
"document.head.appendChild(wpmindChartJsCdn);" .
|
||||
"}",
|
||||
'after'
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'wpmind-admin-ui',
|
||||
WPMIND_PLUGIN_URL . 'assets/js/admin-ui.js',
|
||||
[ 'jquery' ],
|
||||
WPMIND_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'wpmind-admin-boot',
|
||||
WPMIND_PLUGIN_URL . 'assets/js/admin-boot.js',
|
||||
[ 'wpmind-admin-ui' ],
|
||||
WPMIND_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'wpmind-admin-endpoints',
|
||||
WPMIND_PLUGIN_URL . 'assets/js/admin-endpoints.js',
|
||||
[ 'wpmind-admin-boot' ],
|
||||
WPMIND_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'wpmind-admin-routing',
|
||||
WPMIND_PLUGIN_URL . 'assets/js/admin-routing.js',
|
||||
[ 'wpmind-admin-boot', 'jquery-ui-sortable' ],
|
||||
WPMIND_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'wpmind-admin-analytics',
|
||||
WPMIND_PLUGIN_URL . 'assets/js/admin-analytics.js',
|
||||
[ 'wpmind-admin-boot', 'chartjs' ],
|
||||
WPMIND_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'wpmind-admin-budget',
|
||||
WPMIND_PLUGIN_URL . 'assets/js/admin-budget.js',
|
||||
[ 'wpmind-admin-boot' ],
|
||||
WPMIND_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'wpmind-admin-geo',
|
||||
WPMIND_PLUGIN_URL . 'assets/js/admin-geo.js',
|
||||
[ 'wpmind-admin-boot' ],
|
||||
WPMIND_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'wpmind-admin-modules',
|
||||
WPMIND_PLUGIN_URL . 'assets/js/admin-modules.js',
|
||||
[ 'wpmind-admin-boot' ],
|
||||
WPMIND_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
// 完整的国际化字符串
|
||||
wp_localize_script( 'wpmind-admin-boot', 'wpmindL10n', [
|
||||
'testSuccess' => __( '连接成功!', 'wpmind' ),
|
||||
'testFailed' => __( '连接失败:', 'wpmind' ),
|
||||
'testing' => __( '测试中...', 'wpmind' ),
|
||||
'enabled' => __( '已启用', 'wpmind' ),
|
||||
'apiKeyRequired' => __( '请为已启用的服务填写 API Key', 'wpmind' ),
|
||||
'apiKeySet' => __( '已设置', 'wpmind' ),
|
||||
'apiKeyCleared' => __( 'API Key 将被清除', 'wpmind' ),
|
||||
] );
|
||||
|
||||
// 为 AJAX 添加数据
|
||||
wp_localize_script( 'wpmind-admin-boot', 'wpmindData', [
|
||||
'nonce' => wp_create_nonce( 'wpmind_ajax' ),
|
||||
'ajaxurl' => admin_url( 'admin-ajax.php' ),
|
||||
'version' => WPMIND_VERSION,
|
||||
'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG,
|
||||
] );
|
||||
}
|
||||
}
|
||||
75
includes/Admin/AdminBoot.php
Normal file
75
includes/Admin/AdminBoot.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
/**
|
||||
* Admin bootstrapping.
|
||||
*
|
||||
* @package WPMind\Admin
|
||||
* @since 3.3.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Admin;
|
||||
|
||||
/**
|
||||
* Class AdminBoot
|
||||
*/
|
||||
final class AdminBoot {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var AdminBoot|null
|
||||
*/
|
||||
private static ?AdminBoot $instance = null;
|
||||
|
||||
/**
|
||||
* Whether hooks are registered.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private bool $initialized = false;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @return AdminBoot
|
||||
*/
|
||||
public static function instance(): AdminBoot {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* Register admin hooks.
|
||||
*/
|
||||
public function init(): void {
|
||||
if ( $this->initialized ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$assets = AdminAssets::instance();
|
||||
$page = AdminPage::instance();
|
||||
$ajax = AjaxController::instance();
|
||||
|
||||
add_action( 'admin_menu', [ $page, 'add_admin_menu' ] );
|
||||
add_action( 'admin_init', [ $page, 'register_settings' ] );
|
||||
add_action( 'admin_enqueue_scripts', [ $assets, 'enqueue_admin_assets' ] );
|
||||
|
||||
$ajax->register_hooks();
|
||||
|
||||
add_filter(
|
||||
'plugin_action_links_' . plugin_basename( WPMIND_PLUGIN_FILE ),
|
||||
[ $page, 'plugin_action_links' ]
|
||||
);
|
||||
add_filter( 'plugin_row_meta', [ $page, 'plugin_row_meta' ], 10, 2 );
|
||||
|
||||
$this->initialized = true;
|
||||
}
|
||||
}
|
||||
394
includes/Admin/AdminPage.php
Normal file
394
includes/Admin/AdminPage.php
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
<?php
|
||||
/**
|
||||
* Admin page rendering and settings.
|
||||
*
|
||||
* @package WPMind\Admin
|
||||
* @since 3.3.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Admin;
|
||||
|
||||
use WPMind\WPMind;
|
||||
|
||||
/**
|
||||
* Class AdminPage
|
||||
*/
|
||||
final class AdminPage {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var AdminPage|null
|
||||
*/
|
||||
private static ?AdminPage $instance = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @return AdminPage
|
||||
*/
|
||||
public static function instance(): AdminPage {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* 添加管理菜单
|
||||
*/
|
||||
public function add_admin_menu(): void {
|
||||
add_menu_page(
|
||||
__( '心思设置', 'wpmind' ),
|
||||
__( '心思', 'wpmind' ),
|
||||
'manage_options',
|
||||
'wpmind',
|
||||
[ $this, 'render_settings_page' ],
|
||||
'dashicons-heart',
|
||||
30
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册设置
|
||||
*/
|
||||
public function register_settings(): void {
|
||||
register_setting(
|
||||
'wpmind_settings',
|
||||
'wpmind_custom_endpoints',
|
||||
[
|
||||
'type' => 'array',
|
||||
'sanitize_callback' => [ $this, 'sanitize_endpoints' ],
|
||||
'default' => [],
|
||||
]
|
||||
);
|
||||
|
||||
register_setting(
|
||||
'wpmind_settings',
|
||||
'wpmind_request_timeout',
|
||||
[
|
||||
'type' => 'integer',
|
||||
'sanitize_callback' => [ $this, 'sanitize_timeout' ],
|
||||
'default' => 60,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
register_setting(
|
||||
'wpmind_settings',
|
||||
'wpmind_exact_cache_enabled',
|
||||
[
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => [ $this, 'sanitize_exact_cache_enabled' ],
|
||||
'default' => '1',
|
||||
]
|
||||
);
|
||||
|
||||
register_setting(
|
||||
'wpmind_settings',
|
||||
'wpmind_exact_cache_default_ttl',
|
||||
[
|
||||
'type' => 'integer',
|
||||
'sanitize_callback' => [ $this, 'sanitize_exact_cache_ttl' ],
|
||||
'default' => 900,
|
||||
]
|
||||
);
|
||||
|
||||
register_setting(
|
||||
'wpmind_settings',
|
||||
'wpmind_exact_cache_max_entries',
|
||||
[
|
||||
'type' => 'integer',
|
||||
'sanitize_callback' => [ $this, 'sanitize_exact_cache_max_entries' ],
|
||||
'default' => 500,
|
||||
]
|
||||
);
|
||||
|
||||
register_setting(
|
||||
'wpmind_settings',
|
||||
'wpmind_default_provider',
|
||||
[
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => [ $this, 'sanitize_default_provider' ],
|
||||
'default' => '',
|
||||
]
|
||||
);
|
||||
|
||||
// 图像服务设置
|
||||
register_setting(
|
||||
'wpmind_image_settings',
|
||||
'wpmind_image_endpoints',
|
||||
[
|
||||
'type' => 'array',
|
||||
'sanitize_callback' => [ $this, 'sanitize_image_endpoints' ],
|
||||
'default' => [],
|
||||
]
|
||||
);
|
||||
|
||||
register_setting(
|
||||
'wpmind_image_settings',
|
||||
'wpmind_default_image_provider',
|
||||
[
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'default' => '',
|
||||
]
|
||||
);
|
||||
|
||||
// GEO settings are managed by GeoModule via AJAX.
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理默认提供者(允许列表校验)
|
||||
*
|
||||
* @param mixed $input 输入值
|
||||
* @return string 清理后的值
|
||||
* @since 1.2.0
|
||||
*/
|
||||
public function sanitize_default_provider( $input ): string {
|
||||
$key = sanitize_key( $input );
|
||||
|
||||
// 允许空值
|
||||
if ( empty( $key ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 只允许已定义的端点
|
||||
$allowed = array_keys( WPMind::instance()->get_default_endpoints() );
|
||||
return in_array( $key, $allowed, true ) ? $key : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理端点配置
|
||||
*
|
||||
* @param mixed $input 输入数据
|
||||
* @return array 清理后的数据
|
||||
* @since 1.1.0
|
||||
*/
|
||||
public function sanitize_endpoints( $input ): array {
|
||||
if ( ! is_array( $input ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$defaults = WPMind::instance()->get_default_endpoints();
|
||||
$sanitized = [];
|
||||
|
||||
foreach ( $defaults as $key => $default ) {
|
||||
// 强制使用默认的 name, base_url, models(忽略用户提交的值,防止篡改)
|
||||
$sanitized[ $key ] = [
|
||||
'name' => $default['name'],
|
||||
'base_url' => $default['base_url'],
|
||||
'models' => $default['models'],
|
||||
'enabled' => ! empty( $input[ $key ]['enabled'] ),
|
||||
'api_key' => $this->sanitize_api_key(
|
||||
$input[ $key ] ?? [],
|
||||
$key
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 API Key(支持清除功能)
|
||||
*
|
||||
* @param array $endpoint_input 端点输入数据
|
||||
* @param string $endpoint_key 端点标识
|
||||
* @return string 处理后的 API Key
|
||||
* @since 1.2.0
|
||||
*/
|
||||
private function sanitize_api_key( array $endpoint_input, string $endpoint_key ): string {
|
||||
// 如果勾选了清除,返回空字符串
|
||||
if ( ! empty( $endpoint_input['clear_api_key'] ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$api_key = trim( (string) ( $endpoint_input['api_key'] ?? '' ) );
|
||||
|
||||
// 如果为空,保留原有值
|
||||
if ( $api_key === '' ) {
|
||||
$existing = get_option( 'wpmind_custom_endpoints', [] );
|
||||
return $existing[ $endpoint_key ]['api_key'] ?? '';
|
||||
}
|
||||
|
||||
return sanitize_text_field( $api_key );
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理图像端点配置
|
||||
*
|
||||
* @param mixed $input 输入数据
|
||||
* @return array 清理后的图像端点配置
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public function sanitize_image_endpoints( $input ): array {
|
||||
if ( ! is_array( $input ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sanitized = [];
|
||||
$available_providers = [
|
||||
'openai_gpt_image',
|
||||
'google_gemini_image',
|
||||
'tencent_hunyuan',
|
||||
'bytedance_doubao',
|
||||
'flux',
|
||||
'qwen_image',
|
||||
];
|
||||
|
||||
foreach ( $available_providers as $key ) {
|
||||
if ( ! isset( $input[ $key ] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$provider_input = $input[ $key ];
|
||||
|
||||
// 处理清除 API Key
|
||||
$api_key = $this->sanitize_image_api_key( $provider_input, $key );
|
||||
if ( ! empty( $provider_input['clear_api_key'] ) ) {
|
||||
$api_key = '';
|
||||
}
|
||||
|
||||
$sanitized[ $key ] = [
|
||||
'enabled' => ! empty( $provider_input['enabled'] ),
|
||||
'api_key' => $api_key,
|
||||
'custom_base_url' => esc_url_raw( $provider_input['custom_base_url'] ?? '' ),
|
||||
];
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理图像服务 API Key
|
||||
*
|
||||
* @param array $provider_input 服务商输入数据
|
||||
* @param string $provider_key 服务商标识
|
||||
* @return string 处理后的 API Key
|
||||
* @since 2.4.0
|
||||
*/
|
||||
private function sanitize_image_api_key( array $provider_input, string $provider_key ): string {
|
||||
$api_key = trim( (string) ( $provider_input['api_key'] ?? '' ) );
|
||||
|
||||
// 如果是掩码值(********),保留原有值
|
||||
if ( $api_key === '' || $api_key === '********' ) {
|
||||
$existing = get_option( 'wpmind_image_endpoints', [] );
|
||||
return $existing[ $provider_key ]['api_key'] ?? '';
|
||||
}
|
||||
|
||||
return sanitize_text_field( $api_key );
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理超时设置
|
||||
*
|
||||
* @param mixed $input 输入值
|
||||
* @return int 清理后的超时值
|
||||
* @since 1.1.0
|
||||
*/
|
||||
public function sanitize_timeout( $input ): int {
|
||||
$timeout = absint( $input );
|
||||
return max( 10, min( 300, $timeout ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 Exact Cache 开关
|
||||
*
|
||||
* @param mixed $input 输入值
|
||||
* @return string
|
||||
*/
|
||||
public function sanitize_exact_cache_enabled( $input ): string {
|
||||
if ( ! empty( $input ) && in_array( (string) $input, [ '1', 'true', 'on', 'yes' ], true ) ) {
|
||||
return '1';
|
||||
}
|
||||
|
||||
return '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 Exact Cache 默认 TTL
|
||||
*
|
||||
* @param mixed $input 输入值
|
||||
* @return int
|
||||
*/
|
||||
public function sanitize_exact_cache_ttl( $input ): int {
|
||||
$ttl = (int) $input;
|
||||
return max( 0, min( 86400, $ttl ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 Exact Cache 最大条目
|
||||
*
|
||||
* @param mixed $input 输入值
|
||||
* @return int
|
||||
*/
|
||||
public function sanitize_exact_cache_max_entries( $input ): int {
|
||||
$entries = (int) $input;
|
||||
|
||||
if ( $entries <= 0 ) {
|
||||
return 500;
|
||||
}
|
||||
|
||||
return min( 50000, max( 100, $entries ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染设置页面
|
||||
*/
|
||||
public function render_settings_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示保存成功消息
|
||||
settings_errors( 'wpmind_messages' );
|
||||
|
||||
include WPMIND_PLUGIN_DIR . 'templates/settings-page.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件操作链接
|
||||
*
|
||||
* @param array $links 现有链接
|
||||
* @return array 修改后的链接
|
||||
*/
|
||||
public function plugin_action_links( array $links ): array {
|
||||
$settings_link = sprintf(
|
||||
'<a href="%s">%s</a>',
|
||||
esc_url( admin_url( 'admin.php?page=wpmind' ) ),
|
||||
esc_html__( '设置', 'wpmind' )
|
||||
);
|
||||
array_unshift( $links, $settings_link );
|
||||
return $links;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件行元信息
|
||||
*
|
||||
* @param array $links 现有链接
|
||||
* @param string $file 插件文件
|
||||
* @return array 修改后的链接
|
||||
* @since 1.1.0
|
||||
*/
|
||||
public function plugin_row_meta( array $links, string $file ): array {
|
||||
if ( plugin_basename( WPMIND_PLUGIN_FILE ) !== $file ) {
|
||||
return $links;
|
||||
}
|
||||
|
||||
$links[] = sprintf(
|
||||
'<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
|
||||
esc_url( 'https://linuxjoy.com/plugins/wpmind/docs' ),
|
||||
esc_html__( '文档', 'wpmind' )
|
||||
);
|
||||
|
||||
return $links;
|
||||
}
|
||||
}
|
||||
613
includes/Admin/AjaxController.php
Normal file
613
includes/Admin/AjaxController.php
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
<?php
|
||||
/**
|
||||
* Admin AJAX controller.
|
||||
*
|
||||
* @package WPMind\Admin
|
||||
* @since 3.3.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Admin;
|
||||
|
||||
use WPMind\WPMind;
|
||||
use WPMind\Core\ModuleLoader;
|
||||
use WPMind\ErrorHandler;
|
||||
use WPMind\Failover\FailoverManager;
|
||||
use WPMind\Routing\IntelligentRouter;
|
||||
use WPMind\Routing\RoutingContext;
|
||||
use WPMind\Modules\CostControl\UsageTracker;
|
||||
use WPMind\Modules\CostControl\BudgetManager;
|
||||
use WPMind\Modules\CostControl\BudgetChecker;
|
||||
use WPMind\Modules\Analytics\AnalyticsManager;
|
||||
|
||||
/**
|
||||
* Class AjaxController
|
||||
*/
|
||||
final class AjaxController {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var AjaxController|null
|
||||
*/
|
||||
private static ?AjaxController $instance = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @return AjaxController
|
||||
*/
|
||||
public static function instance(): AjaxController {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* Register AJAX hooks.
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_action( 'wp_ajax_wpmind_test_connection', [ $this, 'ajax_test_connection' ] );
|
||||
add_action( 'wp_ajax_wpmind_test_image_connection', [ $this, 'ajax_test_image_connection' ] );
|
||||
add_action( 'wp_ajax_wpmind_get_provider_status', [ $this, 'ajax_get_provider_status' ] );
|
||||
add_action( 'wp_ajax_wpmind_reset_circuit_breaker', [ $this, 'ajax_reset_circuit_breaker' ] );
|
||||
add_action( 'wp_ajax_wpmind_get_usage_stats', [ $this, 'ajax_get_usage_stats' ] );
|
||||
// wpmind_clear_usage_stats is handled by CostControlModule.
|
||||
add_action( 'wp_ajax_wpmind_save_budget_settings', [ $this, 'ajax_save_budget_settings' ] );
|
||||
add_action( 'wp_ajax_wpmind_get_budget_status', [ $this, 'ajax_get_budget_status' ] );
|
||||
add_action( 'wp_ajax_wpmind_get_analytics_data', [ $this, 'ajax_get_analytics_data' ] );
|
||||
add_action( 'wp_ajax_wpmind_get_routing_status', [ $this, 'ajax_get_routing_status' ] );
|
||||
add_action( 'wp_ajax_wpmind_set_routing_strategy', [ $this, 'ajax_set_routing_strategy' ] );
|
||||
add_action( 'wp_ajax_wpmind_route_request', [ $this, 'ajax_route_request' ] );
|
||||
add_action( 'wp_ajax_wpmind_set_provider_priority', [ $this, 'ajax_set_provider_priority' ] );
|
||||
// GEO settings are handled by GeoModule.
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 测试连接
|
||||
*
|
||||
* @since 1.4.0
|
||||
*/
|
||||
public function ajax_test_connection(): void {
|
||||
// 验证 nonce
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
// 验证权限
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$provider = sanitize_text_field( $_POST['provider'] ?? '' );
|
||||
$api_key = sanitize_text_field( $_POST['api_key'] ?? '' );
|
||||
$custom_url = esc_url_raw( $_POST['custom_url'] ?? '' );
|
||||
|
||||
if ( empty( $provider ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '缺少服务标识', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
// 获取端点配置
|
||||
$wpmind = WPMind::instance();
|
||||
$endpoints = $wpmind->get_custom_endpoints();
|
||||
if ( ! isset( $endpoints[ $provider ] ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '服务不存在', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$endpoint = $endpoints[ $provider ];
|
||||
|
||||
// 如果没有提供 API Key,尝试从已保存的配置中获取
|
||||
if ( empty( $api_key ) ) {
|
||||
$api_key = $wpmind->get_api_key( $provider );
|
||||
}
|
||||
|
||||
if ( empty( $api_key ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '请先配置 API Key', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
// 确定使用的 Base URL
|
||||
$base_url = ! empty( $custom_url ) ? $custom_url : $endpoint['base_url'];
|
||||
|
||||
// 测试连接(带重试)
|
||||
$test_url = trailingslashit( $base_url ) . 'models';
|
||||
$max_retries = 2;
|
||||
$last_status_code = 0;
|
||||
$start_time = microtime( true );
|
||||
$response = null;
|
||||
|
||||
for ( $attempt = 1; $attempt <= $max_retries; $attempt++ ) {
|
||||
$response = wp_remote_get( $test_url, [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'timeout' => 15,
|
||||
'_wpmind_skip_tracking' => true, // 标记:跳过 http_api_debug 追踪,避免双重计数
|
||||
] );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
// 检查是否应该重试
|
||||
if ( $attempt < $max_retries ) {
|
||||
usleep( ErrorHandler::get_retry_delay( $attempt ) * 1000 );
|
||||
continue;
|
||||
}
|
||||
|
||||
// 记录失败到健康追踪
|
||||
$latency_ms = (int) ( ( microtime( true ) - $start_time ) * 1000 );
|
||||
FailoverManager::instance()->record_result( $provider, false, $latency_ms );
|
||||
|
||||
// 使用 ErrorHandler 获取友好的错误消息
|
||||
wp_send_json_error( [
|
||||
'message' => ErrorHandler::get_wp_error_message( $response, $provider ),
|
||||
'details' => $response->get_error_message(),
|
||||
'retried' => $attempt > 1,
|
||||
] );
|
||||
}
|
||||
|
||||
$last_status_code = wp_remote_retrieve_response_code( $response );
|
||||
$latency_ms = (int) ( ( microtime( true ) - $start_time ) * 1000 );
|
||||
|
||||
// 成功
|
||||
if ( $last_status_code === 200 ) {
|
||||
// 记录成功到健康追踪
|
||||
FailoverManager::instance()->record_result( $provider, true, $latency_ms );
|
||||
|
||||
wp_send_json_success( [
|
||||
'message' => __( '连接成功', 'wpmind' ),
|
||||
'retried' => $attempt > 1,
|
||||
'latency' => $latency_ms,
|
||||
] );
|
||||
}
|
||||
|
||||
// 不可重试的错误
|
||||
if ( ! ErrorHandler::should_retry( $last_status_code ) ) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 可重试的错误,等待后重试
|
||||
if ( $attempt < $max_retries ) {
|
||||
usleep( ErrorHandler::get_retry_delay( $attempt ) * 1000 );
|
||||
}
|
||||
}
|
||||
|
||||
// 记录失败到健康追踪
|
||||
$latency_ms = (int) ( ( microtime( true ) - $start_time ) * 1000 );
|
||||
FailoverManager::instance()->record_result( $provider, false, $latency_ms );
|
||||
|
||||
// 获取响应体以提取更详细的错误信息
|
||||
$response_body = $response ? wp_remote_retrieve_body( $response ) : '';
|
||||
|
||||
wp_send_json_error( [
|
||||
'message' => ErrorHandler::get_error_message( $last_status_code, $provider, $response_body ),
|
||||
'code' => $last_status_code,
|
||||
'retried' => $max_retries > 1,
|
||||
] );
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 测试图像服务连接
|
||||
*
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public function ajax_test_image_connection(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$provider = sanitize_text_field( $_POST['provider'] ?? '' );
|
||||
|
||||
if ( empty( $provider ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '缺少服务标识', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
// 获取图像端点配置
|
||||
$image_endpoints = get_option( 'wpmind_image_endpoints', [] );
|
||||
|
||||
if ( ! isset( $image_endpoints[ $provider ] ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '服务未配置', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$config = $image_endpoints[ $provider ];
|
||||
|
||||
if ( empty( $config['api_key'] ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '请先配置 API Key', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
// 根据不同的服务商进行测试
|
||||
$result = $this->test_image_provider_connection( $provider, $config );
|
||||
|
||||
if ( $result['success'] ) {
|
||||
wp_send_json_success( [
|
||||
'message' => __( '连接成功', 'wpmind' ),
|
||||
] );
|
||||
} else {
|
||||
wp_send_json_error( [
|
||||
'message' => $result['message'] ?? __( '连接失败', 'wpmind' ),
|
||||
] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 获取 Provider 状态
|
||||
*
|
||||
* @since 1.5.0
|
||||
*/
|
||||
public function ajax_get_provider_status(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$failover = FailoverManager::instance();
|
||||
$status = $failover->get_status_summary();
|
||||
|
||||
wp_send_json_success( [
|
||||
'providers' => $status,
|
||||
'available' => $failover->get_available_providers(),
|
||||
] );
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 重置熔断器
|
||||
*
|
||||
* @since 1.5.0
|
||||
*/
|
||||
public function ajax_reset_circuit_breaker(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$provider = sanitize_text_field( $_POST['provider'] ?? '' );
|
||||
$failover = FailoverManager::instance();
|
||||
|
||||
if ( empty( $provider ) || $provider === 'all' ) {
|
||||
$failover->reset_all();
|
||||
wp_send_json_success( [ 'message' => __( '所有熔断器已重置', 'wpmind' ) ] );
|
||||
} else {
|
||||
$failover->reset_provider( $provider );
|
||||
wp_send_json_success( [ 'message' => __( '熔断器已重置', 'wpmind' ) ] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 获取用量统计
|
||||
*
|
||||
* @since 1.6.0
|
||||
*/
|
||||
public function ajax_get_usage_stats(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$stats = UsageTracker::get_stats();
|
||||
$today = UsageTracker::get_today_stats();
|
||||
$month = UsageTracker::get_month_stats();
|
||||
$history = UsageTracker::get_history( 20 );
|
||||
|
||||
wp_send_json_success( [
|
||||
'stats' => $stats,
|
||||
'today' => $today,
|
||||
'month' => $month,
|
||||
'history' => $history,
|
||||
] );
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 保存预算设置
|
||||
*
|
||||
* @since 1.7.0
|
||||
*/
|
||||
public function ajax_save_budget_settings(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
// 检查 Cost Control 模块是否启用
|
||||
$module_loader = ModuleLoader::instance();
|
||||
if ( ! $module_loader->is_module_enabled( 'cost-control' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( 'Cost Control 模块未启用', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
// 解析 JSON 数据
|
||||
$json_input = isset( $_POST['settings'] ) ? wp_unslash( $_POST['settings'] ) : '';
|
||||
$input = json_decode( $json_input, true );
|
||||
|
||||
if ( ! is_array( $input ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '无效的数据格式', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
// 构建设置数组
|
||||
$settings = [];
|
||||
$settings['enabled'] = ! empty( $input['enabled'] );
|
||||
|
||||
$settings['global'] = [
|
||||
'daily_limit_usd' => (float) ( $input['global']['daily_limit_usd'] ?? 0 ),
|
||||
'daily_limit_cny' => (float) ( $input['global']['daily_limit_cny'] ?? 0 ),
|
||||
'monthly_limit_usd' => (float) ( $input['global']['monthly_limit_usd'] ?? 0 ),
|
||||
'monthly_limit_cny' => (float) ( $input['global']['monthly_limit_cny'] ?? 0 ),
|
||||
'alert_threshold' => (int) ( $input['global']['alert_threshold'] ?? 80 ),
|
||||
];
|
||||
|
||||
$settings['enforcement_mode'] = sanitize_text_field( $input['enforcement_mode'] ?? 'alert' );
|
||||
|
||||
$settings['notifications'] = [
|
||||
'admin_notice' => ! empty( $input['notifications']['admin_notice'] ),
|
||||
'email_alert' => ! empty( $input['notifications']['email_alert'] ),
|
||||
'email_address' => sanitize_email( $input['notifications']['email_address'] ?? '' ),
|
||||
];
|
||||
|
||||
// 按服务商设置
|
||||
$settings['providers'] = [];
|
||||
if ( ! empty( $input['providers'] ) && is_array( $input['providers'] ) ) {
|
||||
foreach ( $input['providers'] as $provider => $limits ) {
|
||||
$provider = sanitize_key( $provider );
|
||||
$settings['providers'][ $provider ] = [
|
||||
'daily_limit' => (float) ( $limits['daily_limit'] ?? 0 ),
|
||||
'monthly_limit' => (float) ( $limits['monthly_limit'] ?? 0 ),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$manager = BudgetManager::instance();
|
||||
$result = $manager->save_settings( $settings );
|
||||
|
||||
if ( $result ) {
|
||||
wp_send_json_success( [ 'message' => __( '预算设置已保存', 'wpmind' ) ] );
|
||||
} else {
|
||||
wp_send_json_error( [ 'message' => __( '保存失败', 'wpmind' ) ] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 获取预算状态
|
||||
*
|
||||
* @since 1.7.0
|
||||
*/
|
||||
public function ajax_get_budget_status(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
// 检查 Cost Control 模块是否启用
|
||||
$module_loader = ModuleLoader::instance();
|
||||
if ( ! $module_loader->is_module_enabled( 'cost-control' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( 'Cost Control 模块未启用', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$checker = BudgetChecker::instance();
|
||||
$summary = $checker->get_summary();
|
||||
|
||||
wp_send_json_success( $summary );
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 获取分析数据
|
||||
*
|
||||
* @since 1.8.0
|
||||
*/
|
||||
public function ajax_get_analytics_data(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$range = isset( $_POST['range'] ) ? sanitize_text_field( $_POST['range'] ) : '7d';
|
||||
|
||||
if ( ! class_exists( AnalyticsManager::class ) ) {
|
||||
wp_send_json_error( [ 'message' => __( 'Analytics 模块未启用', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$analytics = AnalyticsManager::instance();
|
||||
$data = $analytics->get_analytics_data( $range );
|
||||
|
||||
wp_send_json_success( $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 获取路由状态
|
||||
*
|
||||
* @since 1.9.0
|
||||
*/
|
||||
public function ajax_get_routing_status(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$router = IntelligentRouter::instance();
|
||||
$status = $router->get_status_summary();
|
||||
|
||||
wp_send_json_success( $status );
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 设置路由策略
|
||||
*
|
||||
* @since 1.9.0
|
||||
*/
|
||||
public function ajax_set_routing_strategy(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$strategy = sanitize_text_field( $_POST['strategy'] ?? '' );
|
||||
|
||||
if ( empty( $strategy ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '请选择路由策略', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$router = IntelligentRouter::instance();
|
||||
$result = $router->set_strategy( $strategy );
|
||||
|
||||
if ( $result ) {
|
||||
wp_send_json_success( [
|
||||
'message' => __( '路由策略已更新', 'wpmind' ),
|
||||
'strategy' => $strategy,
|
||||
] );
|
||||
} else {
|
||||
wp_send_json_error( [ 'message' => __( '无效的路由策略', 'wpmind' ) ] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 设置 Provider 优先级
|
||||
*
|
||||
* @since 2.3.0
|
||||
*/
|
||||
public function ajax_set_provider_priority(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$priority = isset( $_POST['priority'] ) ? array_map( 'sanitize_text_field', (array) $_POST['priority'] ) : [];
|
||||
$clear = ! empty( $_POST['clear'] );
|
||||
|
||||
$router = IntelligentRouter::instance();
|
||||
|
||||
if ( $clear ) {
|
||||
$result = $router->clear_manual_priority();
|
||||
$message = __( '已清除手动优先级设置', 'wpmind' );
|
||||
} else {
|
||||
$result = $router->set_manual_priority( $priority );
|
||||
$message = __( 'Provider 优先级已更新', 'wpmind' );
|
||||
}
|
||||
|
||||
if ( $result ) {
|
||||
// 刷新 FailoverManager
|
||||
FailoverManager::instance()->refresh();
|
||||
|
||||
wp_send_json_success( [
|
||||
'message' => $message,
|
||||
'priority' => $router->get_manual_priority(),
|
||||
] );
|
||||
} else {
|
||||
wp_send_json_error( [ 'message' => __( '保存失败', 'wpmind' ) ] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 路由请求(获取推荐 Provider)
|
||||
*
|
||||
* @since 1.9.0
|
||||
*/
|
||||
public function ajax_route_request(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$preferred = sanitize_text_field( $_POST['preferred'] ?? '' );
|
||||
$excluded = isset( $_POST['excluded'] ) ? array_slice( array_map( 'sanitize_text_field', (array) $_POST['excluded'] ), 0, 50 ) : [];
|
||||
$input_tokens = absint( $_POST['input_tokens'] ?? 0 );
|
||||
$output_tokens = absint( $_POST['output_tokens'] ?? 0 );
|
||||
|
||||
$context = RoutingContext::create()
|
||||
->with_preferred_provider( $preferred ?: null )
|
||||
->with_excluded_providers( $excluded )
|
||||
->with_estimated_tokens( $input_tokens, $output_tokens );
|
||||
|
||||
$router = IntelligentRouter::instance();
|
||||
$selected = $router->route( $context );
|
||||
$failoverChain = $router->get_failover_chain( $context );
|
||||
$scores = $router->get_provider_scores( $context );
|
||||
|
||||
wp_send_json_success( [
|
||||
'selected' => $selected,
|
||||
'failover_chain' => $failoverChain,
|
||||
'scores' => $scores,
|
||||
] );
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试图像服务商连接
|
||||
*
|
||||
* @param string $provider 服务商 ID
|
||||
* @param array $config 配置
|
||||
* @return array
|
||||
* @since 2.4.0
|
||||
*/
|
||||
private function test_image_provider_connection( string $provider, array $config ): array {
|
||||
$api_key = $config['api_key'];
|
||||
$custom_url = $config['custom_base_url'] ?? '';
|
||||
|
||||
// 服务商测试端点映射(已通过 Gemini CLI 核实 2026-02-01)
|
||||
$test_endpoints = [
|
||||
'openai_gpt_image' => 'https://api.openai.com/v1/models',
|
||||
'google_gemini_image' => 'https://generativelanguage.googleapis.com/v1beta/models',
|
||||
'tencent_hunyuan' => 'https://hunyuan.tencentcloudapi.com/',
|
||||
'bytedance_doubao' => 'https://ark.cn-beijing.volces.com/api/v3/models',
|
||||
'flux' => 'https://fal.run/fal-ai/flux/dev',
|
||||
'qwen_image' => 'https://dashscope.aliyuncs.com/api/v1/models',
|
||||
];
|
||||
|
||||
$test_url = ! empty( $custom_url )
|
||||
? rtrim( $custom_url, '/' ) . '/models'
|
||||
: ( $test_endpoints[ $provider ] ?? '' );
|
||||
|
||||
if ( empty( $test_url ) ) {
|
||||
return [ 'success' => false, 'message' => '未知的服务商' ];
|
||||
}
|
||||
|
||||
// 特殊处理:部分服务商使用不同的认证方式
|
||||
$headers = [
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
|
||||
// Google Gemini 使用 API Key 作为查询参数
|
||||
if ( $provider === 'google_gemini_image' ) {
|
||||
$test_url .= '?key=' . $api_key;
|
||||
unset( $headers['Authorization'] );
|
||||
}
|
||||
|
||||
$response = wp_remote_get( $test_url, [
|
||||
'headers' => $headers,
|
||||
'timeout' => 30,
|
||||
] );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => $response->get_error_message(),
|
||||
];
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code( $response );
|
||||
|
||||
if ( $status_code === 200 ) {
|
||||
return [ 'success' => true, 'message' => '连接成功' ];
|
||||
}
|
||||
|
||||
if ( $status_code === 401 || $status_code === 403 ) {
|
||||
return [ 'success' => false, 'message' => 'API Key 无效或无权限' ];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => '连接失败 (HTTP ' . $status_code . ')',
|
||||
];
|
||||
}
|
||||
}
|
||||
540
includes/Cache/ExactCache.php
Normal file
540
includes/Cache/ExactCache.php
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
<?php
|
||||
/**
|
||||
* Exact Cache Manager
|
||||
*
|
||||
* 为 AI 请求提供精确匹配缓存能力(请求哈希命中)。
|
||||
*
|
||||
* @package WPMind
|
||||
* @subpackage Cache
|
||||
* @since 4.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Cache;
|
||||
|
||||
/**
|
||||
* Exact Cache 管理器
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
final class ExactCache {
|
||||
|
||||
private const OPTION_ENABLED = 'wpmind_exact_cache_enabled';
|
||||
private const OPTION_DEFAULT_TTL = 'wpmind_exact_cache_default_ttl';
|
||||
private const OPTION_MAX_ENTRIES = 'wpmind_exact_cache_max_entries';
|
||||
private const OPTION_INDEX = 'wpmind_exact_cache_index';
|
||||
private const OPTION_STATS = 'wpmind_exact_cache_stats';
|
||||
private const KEY_PREFIX = 'wpmind_ec_';
|
||||
private const DEFAULT_MAX_ENTRIES = 500;
|
||||
|
||||
private static ?ExactCache $instance = null;
|
||||
|
||||
/**
|
||||
* 内存中累积的统计增量,shutdown 时批量写入。
|
||||
*
|
||||
* @var array{hits:int,misses:int,writes:int,last_hit_at:int,last_miss_at:int,last_write_at:int,last_key:string}
|
||||
*/
|
||||
private array $pending_stats = [];
|
||||
|
||||
/**
|
||||
* 内存中的索引快照(懒加载),shutdown 时批量写入。
|
||||
*
|
||||
* @var array<string,int>|null null 表示尚未加载
|
||||
*/
|
||||
private ?array $pending_index = null;
|
||||
|
||||
/**
|
||||
* 索引是否有变更需要写入。
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private bool $index_dirty = false;
|
||||
|
||||
/**
|
||||
* shutdown hook 是否已注册。
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private bool $shutdown_registered = false;
|
||||
|
||||
/**
|
||||
* 获取单例
|
||||
*
|
||||
* @return ExactCache
|
||||
*/
|
||||
public static function instance(): ExactCache {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁止外部实例化
|
||||
*/
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* 缓存是否启用
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_enabled(): bool {
|
||||
$raw_value = get_option(self::OPTION_ENABLED, '1');
|
||||
$disabled_values = [false, 0, '0', 'false', 'no', 'off'];
|
||||
$enabled = !in_array($raw_value, $disabled_values, true);
|
||||
|
||||
return (bool) apply_filters('wpmind_exact_cache_enabled', $enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最大缓存条目限制
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_max_entries(): int {
|
||||
$max_entries = (int) get_option(self::OPTION_MAX_ENTRIES, self::DEFAULT_MAX_ENTRIES);
|
||||
if ($max_entries <= 0) {
|
||||
$max_entries = self::DEFAULT_MAX_ENTRIES;
|
||||
}
|
||||
|
||||
return (int) apply_filters('wpmind_exact_cache_max_entries', $max_entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认缓存 TTL
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_default_ttl(): int {
|
||||
$default_ttl = (int) get_option(self::OPTION_DEFAULT_TTL, 900);
|
||||
$default_ttl = max(0, min(86400, $default_ttl));
|
||||
|
||||
return (int) apply_filters('wpmind_exact_cache_default_ttl', $default_ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成精确缓存键
|
||||
*
|
||||
* @param string $type 请求类型
|
||||
* @param array $args 请求参数
|
||||
* @param string $provider Provider
|
||||
* @param string $model 模型
|
||||
* @return string
|
||||
*/
|
||||
public function build_key(string $type, array $args, string $provider = '', string $model = ''): string {
|
||||
$key_data = [
|
||||
'v' => 1,
|
||||
'type' => $type,
|
||||
'provider' => $provider !== '' ? $provider : 'auto',
|
||||
'model' => $model !== '' ? $model : 'auto',
|
||||
'scope' => $this->build_scope(),
|
||||
'args' => $this->normalize_for_hash($args),
|
||||
];
|
||||
|
||||
$json = wp_json_encode($key_data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($json) || $json === '') {
|
||||
$json = serialize($key_data);
|
||||
}
|
||||
|
||||
$key = self::KEY_PREFIX . substr(hash('sha256', $json), 0, 40);
|
||||
|
||||
return (string) apply_filters('wpmind_exact_cache_key', $key, $key_data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取缓存
|
||||
*
|
||||
* @param string $cache_key 缓存键
|
||||
* @return mixed|null
|
||||
*/
|
||||
public function get(string $cache_key) {
|
||||
if (!$this->is_enabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cached = get_transient($cache_key);
|
||||
if ($cached === false || !is_array($cached) || !array_key_exists('payload', $cached)) {
|
||||
$this->buffer_stat('misses', $cache_key);
|
||||
if ($this->index_has($cache_key)) {
|
||||
$this->remove_from_index($cache_key);
|
||||
}
|
||||
do_action('wpmind_exact_cache_miss', $cache_key);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->buffer_stat('hits', $cache_key);
|
||||
do_action('wpmind_exact_cache_hit', $cache_key, $cached['meta'] ?? []);
|
||||
|
||||
return $cached['payload'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入缓存
|
||||
*
|
||||
* @param string $cache_key 缓存键
|
||||
* @param mixed $value 缓存值
|
||||
* @param int $ttl TTL 秒数
|
||||
* @param array $meta 元数据
|
||||
* @return bool
|
||||
*/
|
||||
public function set(string $cache_key, $value, int $ttl, array $meta = []): bool {
|
||||
if (!$this->is_enabled() || $ttl <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stored_payload = [
|
||||
'payload' => $value,
|
||||
'meta' => $meta,
|
||||
'stored_at' => time(),
|
||||
'expires_in' => $ttl,
|
||||
];
|
||||
|
||||
$saved = set_transient($cache_key, $stored_payload, $ttl);
|
||||
if (!$saved) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->touch_index($cache_key);
|
||||
$this->enforce_max_entries();
|
||||
$this->buffer_stat('writes', $cache_key);
|
||||
do_action('wpmind_exact_cache_store', $cache_key, $meta, $ttl);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单个缓存
|
||||
*
|
||||
* @param string $cache_key 缓存键
|
||||
* @return void
|
||||
*/
|
||||
public function delete(string $cache_key): void {
|
||||
delete_transient($cache_key);
|
||||
$this->remove_from_index($cache_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有精确缓存(按索引)
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function flush(): void {
|
||||
$index = $this->load_index();
|
||||
|
||||
foreach (array_keys($index) as $cache_key) {
|
||||
delete_transient($cache_key);
|
||||
}
|
||||
|
||||
$this->pending_index = [];
|
||||
$this->index_dirty = false;
|
||||
$this->pending_stats = [];
|
||||
|
||||
delete_option(self::OPTION_INDEX);
|
||||
update_option(self::OPTION_STATS, $this->get_default_stats(), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_stats(): array {
|
||||
$stats = get_option(self::OPTION_STATS, []);
|
||||
$stats = wp_parse_args(is_array($stats) ? $stats : [], $this->get_default_stats());
|
||||
|
||||
// 合并尚未写入的内存增量
|
||||
foreach (['hits', 'misses', 'writes'] as $metric) {
|
||||
if (isset($this->pending_stats[$metric])) {
|
||||
$stats[$metric] = (int) $stats[$metric] + (int) $this->pending_stats[$metric];
|
||||
}
|
||||
}
|
||||
|
||||
$total_requests = (int) $stats['hits'] + (int) $stats['misses'];
|
||||
$hit_rate = $total_requests > 0
|
||||
? round(((int) $stats['hits'] / $total_requests) * 100, 2)
|
||||
: 0.0;
|
||||
|
||||
$stats['enabled'] = $this->is_enabled();
|
||||
$stats['hit_rate'] = $hit_rate;
|
||||
$stats['entries'] = count($this->load_index());
|
||||
$stats['max_entries'] = $this->get_max_entries();
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取索引条目列表(供管理界面使用)
|
||||
*
|
||||
* @return array<int, array{key: string, last_access: int}>
|
||||
*/
|
||||
public function get_entries(): array {
|
||||
$index = $this->load_index();
|
||||
arsort($index, SORT_NUMERIC);
|
||||
$entries = [];
|
||||
foreach ($index as $key => $timestamp) {
|
||||
$entries[] = [
|
||||
'key' => $key,
|
||||
'last_access' => (int) $timestamp,
|
||||
];
|
||||
}
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建缓存作用域(避免跨站点/跨角色污染)
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function build_scope(): array {
|
||||
$scope = [
|
||||
'blog_id' => function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0,
|
||||
'locale' => function_exists('get_locale') ? (string) get_locale() : '',
|
||||
];
|
||||
|
||||
$scope_mode = (string) apply_filters('wpmind_exact_cache_scope_mode', 'role');
|
||||
if ($scope_mode === 'user') {
|
||||
$scope['user_id'] = get_current_user_id();
|
||||
return $scope;
|
||||
}
|
||||
|
||||
if ($scope_mode === 'none') {
|
||||
$scope['segment'] = 'global';
|
||||
return $scope;
|
||||
}
|
||||
|
||||
$user = function_exists('wp_get_current_user') ? wp_get_current_user() : null;
|
||||
$roles = [];
|
||||
if ($user && !empty($user->roles) && is_array($user->roles)) {
|
||||
$roles = array_values($user->roles);
|
||||
sort($roles);
|
||||
}
|
||||
|
||||
$scope['roles'] = $roles;
|
||||
|
||||
return $scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化数组/对象,保证哈希稳定
|
||||
*
|
||||
* @param mixed $value 原始值
|
||||
* @return mixed
|
||||
*/
|
||||
private function normalize_for_hash($value) {
|
||||
if (is_array($value)) {
|
||||
if ($this->is_assoc_array($value)) {
|
||||
ksort($value);
|
||||
}
|
||||
|
||||
foreach ($value as $key => $child) {
|
||||
$value[$key] = $this->normalize_for_hash($child);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
return $this->normalize_for_hash(get_object_vars($value));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断数组是否为关联数组
|
||||
*
|
||||
* @param array $array 数组
|
||||
* @return bool
|
||||
*/
|
||||
private function is_assoc_array(array $array): bool {
|
||||
if ($array === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array_keys($array) !== range(0, count($array) - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 懒加载索引到内存
|
||||
*
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private function load_index(): array {
|
||||
if ($this->pending_index === null) {
|
||||
$index = get_option(self::OPTION_INDEX, []);
|
||||
$this->pending_index = is_array($index) ? $index : [];
|
||||
}
|
||||
|
||||
return $this->pending_index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查索引中是否存在指定键
|
||||
*
|
||||
* @param string $cache_key 缓存键
|
||||
* @return bool
|
||||
*/
|
||||
private function index_has(string $cache_key): bool {
|
||||
$index = $this->load_index();
|
||||
return isset($index[$cache_key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新索引访问时间(内存操作,shutdown 时写入)
|
||||
*
|
||||
* @param string $cache_key 缓存键
|
||||
* @return void
|
||||
*/
|
||||
private function touch_index(string $cache_key): void {
|
||||
$this->load_index();
|
||||
$this->pending_index[$cache_key] = time();
|
||||
$this->index_dirty = true;
|
||||
$this->register_shutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从索引中移除键(内存操作,shutdown 时写入)
|
||||
*
|
||||
* @param string $cache_key 缓存键
|
||||
* @return void
|
||||
*/
|
||||
private function remove_from_index(string $cache_key): void {
|
||||
$this->load_index();
|
||||
if (!isset($this->pending_index[$cache_key])) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($this->pending_index[$cache_key]);
|
||||
$this->index_dirty = true;
|
||||
$this->register_shutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制执行容量上限(LRU 近似:最早写入优先淘汰)
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function enforce_max_entries(): void {
|
||||
$max_entries = $this->get_max_entries();
|
||||
$index = $this->load_index();
|
||||
$current_count = count($index);
|
||||
|
||||
if ($current_count <= $max_entries) {
|
||||
return;
|
||||
}
|
||||
|
||||
asort($index, SORT_NUMERIC);
|
||||
$remove_count = $current_count - $max_entries;
|
||||
$expired_keys = array_slice(array_keys($index), 0, $remove_count);
|
||||
|
||||
foreach ($expired_keys as $cache_key) {
|
||||
delete_transient($cache_key);
|
||||
unset($index[$cache_key]);
|
||||
}
|
||||
|
||||
$this->pending_index = $index;
|
||||
$this->index_dirty = true;
|
||||
$this->register_shutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 累积统计增量到内存(shutdown 时批量写入)
|
||||
*
|
||||
* @param string $metric 命中项(hits/misses/writes)
|
||||
* @param string|null $cache_key 缓存键
|
||||
* @return void
|
||||
*/
|
||||
private function buffer_stat(string $metric, ?string $cache_key = null): void {
|
||||
if (!isset($this->pending_stats[$metric])) {
|
||||
$this->pending_stats[$metric] = 0;
|
||||
}
|
||||
$this->pending_stats[$metric]++;
|
||||
|
||||
$timestamp_fields = [
|
||||
'hits' => 'last_hit_at',
|
||||
'misses' => 'last_miss_at',
|
||||
'writes' => 'last_write_at',
|
||||
];
|
||||
|
||||
if (isset($timestamp_fields[$metric])) {
|
||||
$this->pending_stats[$timestamp_fields[$metric]] = time();
|
||||
}
|
||||
|
||||
if ($cache_key !== null) {
|
||||
$this->pending_stats['last_key'] = $cache_key;
|
||||
}
|
||||
|
||||
$this->register_shutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 shutdown hook(仅一次)
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function register_shutdown(): void {
|
||||
if ($this->shutdown_registered) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_action('shutdown', [$this, 'flush_pending']);
|
||||
$this->shutdown_registered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将内存中的统计和索引批量写入数据库。
|
||||
*
|
||||
* 由 shutdown hook 调用,每次请求最多写入 2 次 DB(stats + index)。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function flush_pending(): void {
|
||||
// 写入统计增量
|
||||
if (!empty($this->pending_stats)) {
|
||||
$stats = get_option(self::OPTION_STATS, []);
|
||||
$stats = wp_parse_args(is_array($stats) ? $stats : [], $this->get_default_stats());
|
||||
|
||||
foreach (['hits', 'misses', 'writes'] as $metric) {
|
||||
if (isset($this->pending_stats[$metric])) {
|
||||
$stats[$metric] = (int) $stats[$metric] + (int) $this->pending_stats[$metric];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['last_hit_at', 'last_miss_at', 'last_write_at', 'last_key'] as $field) {
|
||||
if (isset($this->pending_stats[$field])) {
|
||||
$stats[$field] = $this->pending_stats[$field];
|
||||
}
|
||||
}
|
||||
|
||||
update_option(self::OPTION_STATS, $stats, false);
|
||||
$this->pending_stats = [];
|
||||
}
|
||||
|
||||
// 写入索引变更
|
||||
if ($this->index_dirty && $this->pending_index !== null) {
|
||||
update_option(self::OPTION_INDEX, $this->pending_index, false);
|
||||
$this->index_dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认统计结构
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function get_default_stats(): array {
|
||||
return [
|
||||
'hits' => 0,
|
||||
'misses' => 0,
|
||||
'writes' => 0,
|
||||
'last_hit_at' => 0,
|
||||
'last_miss_at' => 0,
|
||||
'last_write_at' => 0,
|
||||
'last_key' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
70
includes/Core/ModuleInterface.php
Normal file
70
includes/Core/ModuleInterface.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
/**
|
||||
* Module Interface
|
||||
*
|
||||
* Interface for WPMind modules.
|
||||
*
|
||||
* @package WPMind\Core
|
||||
* @since 3.2.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Core;
|
||||
|
||||
/**
|
||||
* Interface ModuleInterface
|
||||
*
|
||||
* All WPMind modules must implement this interface.
|
||||
*/
|
||||
interface ModuleInterface {
|
||||
|
||||
/**
|
||||
* Get module ID.
|
||||
*
|
||||
* @return string Unique module identifier.
|
||||
*/
|
||||
public function get_id(): string;
|
||||
|
||||
/**
|
||||
* Get module name.
|
||||
*
|
||||
* @return string Human-readable module name.
|
||||
*/
|
||||
public function get_name(): string;
|
||||
|
||||
/**
|
||||
* Get module description.
|
||||
*
|
||||
* @return string Module description.
|
||||
*/
|
||||
public function get_description(): string;
|
||||
|
||||
/**
|
||||
* Get module version.
|
||||
*
|
||||
* @return string Module version.
|
||||
*/
|
||||
public function get_version(): string;
|
||||
|
||||
/**
|
||||
* Initialize the module.
|
||||
*
|
||||
* Called when the module is loaded and enabled.
|
||||
*/
|
||||
public function init(): void;
|
||||
|
||||
/**
|
||||
* Check if module dependencies are met.
|
||||
*
|
||||
* @return bool True if dependencies are satisfied.
|
||||
*/
|
||||
public function check_dependencies(): bool;
|
||||
|
||||
/**
|
||||
* Get module settings tab slug.
|
||||
*
|
||||
* @return string|null Settings tab slug or null if no settings.
|
||||
*/
|
||||
public function get_settings_tab(): ?string;
|
||||
}
|
||||
456
includes/Core/ModuleLoader.php
Normal file
456
includes/Core/ModuleLoader.php
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
<?php
|
||||
/**
|
||||
* Module Loader
|
||||
*
|
||||
* Discovers, loads, and manages WPMind modules.
|
||||
*
|
||||
* @package WPMind\Core
|
||||
* @since 3.2.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Core;
|
||||
|
||||
/**
|
||||
* Class ModuleLoader
|
||||
*
|
||||
* Handles module discovery, loading, and lifecycle management.
|
||||
*/
|
||||
class ModuleLoader {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var ModuleLoader|null
|
||||
*/
|
||||
private static ?ModuleLoader $instance = null;
|
||||
|
||||
/**
|
||||
* Registered modules.
|
||||
*
|
||||
* @var array<string, array>
|
||||
*/
|
||||
private array $modules = [];
|
||||
|
||||
/**
|
||||
* Loaded module instances.
|
||||
*
|
||||
* @var array<string, ModuleInterface>
|
||||
*/
|
||||
private array $instances = [];
|
||||
|
||||
/**
|
||||
* Modules directory path.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $modules_dir;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @return ModuleLoader
|
||||
*/
|
||||
public static function instance(): ModuleLoader {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
private function __construct() {
|
||||
$this->modules_dir = WPMIND_PATH . 'modules/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module loader.
|
||||
*/
|
||||
public function init(): void {
|
||||
$this->discover_modules();
|
||||
$this->load_enabled_modules();
|
||||
|
||||
// Register AJAX handlers.
|
||||
add_action( 'wp_ajax_wpmind_toggle_module', array( $this, 'ajax_toggle_module' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover available modules.
|
||||
*/
|
||||
private function discover_modules(): void {
|
||||
if ( ! is_dir( $this->modules_dir ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dirs = glob( $this->modules_dir . '*', GLOB_ONLYDIR );
|
||||
|
||||
foreach ( $dirs as $dir ) {
|
||||
$module_id = basename( $dir );
|
||||
$module_file = $dir . '/module.json';
|
||||
$class_file = $dir . '/' . $this->get_module_class_filename( $module_id );
|
||||
|
||||
if ( ! file_exists( $module_file ) || ! file_exists( $class_file ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$meta = json_decode( file_get_contents( $module_file ), true );
|
||||
|
||||
if ( ! $meta || ! isset( $meta['id'], $meta['name'], $meta['version'] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->modules[ $module_id ] = array(
|
||||
'id' => $meta['id'],
|
||||
'name' => $meta['name'],
|
||||
'description' => $meta['description'] ?? '',
|
||||
'version' => $meta['version'],
|
||||
'author' => $meta['author'] ?? '',
|
||||
'icon' => $meta['icon'] ?? 'dashicons-admin-plugins',
|
||||
'class' => $meta['class'] ?? $this->get_module_class_name( $module_id ),
|
||||
'class_file' => $class_file,
|
||||
'path' => $dir,
|
||||
'enabled' => $this->is_module_enabled( $module_id ),
|
||||
'can_disable' => $meta['can_disable'] ?? true,
|
||||
'settings_tab' => $meta['settings_tab'] ?? '',
|
||||
'requires' => $meta['requires'] ?? [],
|
||||
'features' => $meta['features'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter discovered modules.
|
||||
*
|
||||
* @param array $modules Discovered modules.
|
||||
*/
|
||||
// Enforce non-disableable modules are always enabled.
|
||||
foreach ( $this->modules as $module_id => &$module_data ) {
|
||||
if ( ! $module_data['can_disable'] && ! $module_data['enabled'] ) {
|
||||
$module_data['enabled'] = true;
|
||||
update_option( "wpmind_module_{$module_id}_enabled", '1', false );
|
||||
}
|
||||
}
|
||||
unset( $module_data );
|
||||
|
||||
$this->modules = apply_filters( 'wpmind_discovered_modules', $this->modules );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module class filename from module ID.
|
||||
*
|
||||
* @param string $module_id Module ID.
|
||||
* @return string Class filename.
|
||||
*/
|
||||
private function get_module_class_filename( string $module_id ): string {
|
||||
// geo -> GeoModule.php
|
||||
$parts = explode( '-', $module_id );
|
||||
$name = implode( '', array_map( 'ucfirst', $parts ) );
|
||||
return $name . 'Module.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module class name from module ID.
|
||||
*
|
||||
* @param string $module_id Module ID.
|
||||
* @return string Fully qualified class name.
|
||||
*/
|
||||
private function get_module_class_name( string $module_id ): string {
|
||||
$parts = explode( '-', $module_id );
|
||||
$name = implode( '', array_map( 'ucfirst', $parts ) );
|
||||
return "WPMind\\Modules\\{$name}\\{$name}Module";
|
||||
}
|
||||
|
||||
/**
|
||||
* Load enabled modules.
|
||||
*
|
||||
* Resolves module dependencies and loads in correct order.
|
||||
*/
|
||||
private function load_enabled_modules(): void {
|
||||
// Build dependency-ordered load list.
|
||||
$load_order = $this->resolve_load_order();
|
||||
|
||||
foreach ( $load_order as $module_id ) {
|
||||
if ( ! $this->modules[ $module_id ]['enabled'] ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->load_module( $module_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires after all enabled modules are loaded.
|
||||
*
|
||||
* @param array $instances Loaded module instances.
|
||||
*/
|
||||
do_action( 'wpmind_modules_loaded', $this->instances );
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific module.
|
||||
*
|
||||
* @param string $module_id Module ID.
|
||||
* @return bool True if loaded successfully.
|
||||
*/
|
||||
public function load_module( string $module_id ): bool {
|
||||
if ( ! isset( $this->modules[ $module_id ] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( isset( $this->instances[ $module_id ] ) ) {
|
||||
return true; // Already loaded.
|
||||
}
|
||||
|
||||
$module = $this->modules[ $module_id ];
|
||||
|
||||
// Load class file.
|
||||
require_once $module['class_file'];
|
||||
|
||||
$class = $module['class'];
|
||||
|
||||
// Validate class namespace for security.
|
||||
if ( strpos( $class, 'WPMind\\' ) !== 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! class_exists( $class ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Instantiate module.
|
||||
$instance = new $class();
|
||||
|
||||
if ( ! $instance instanceof ModuleInterface ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check dependencies.
|
||||
if ( ! $instance->check_dependencies() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize module.
|
||||
$instance->init();
|
||||
|
||||
$this->instances[ $module_id ] = $instance;
|
||||
|
||||
/**
|
||||
* Fires when a module is loaded.
|
||||
*
|
||||
* @param string $module_id Module ID.
|
||||
* @param ModuleInterface $instance Module instance.
|
||||
*/
|
||||
do_action( 'wpmind_module_loaded', $module_id, $instance );
|
||||
do_action( "wpmind_module_{$module_id}_loaded", $instance );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve module load order based on dependencies.
|
||||
*
|
||||
* Ensures modules with dependencies are loaded after their requirements.
|
||||
* Only considers array-type requires (module dependencies), not object-type
|
||||
* requires (system requirements like PHP/WordPress versions).
|
||||
*
|
||||
* @return array<string> Ordered list of module IDs.
|
||||
*/
|
||||
private function resolve_load_order(): array {
|
||||
$ordered = [];
|
||||
$resolved = [];
|
||||
|
||||
foreach ( $this->modules as $module_id => $module ) {
|
||||
$this->resolve_module_deps( $module_id, $ordered, $resolved );
|
||||
}
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively resolve a module's dependencies.
|
||||
*
|
||||
* @param string $module_id Module ID.
|
||||
* @param array $ordered Ordered output list (by reference).
|
||||
* @param array $resolved Already resolved modules (by reference).
|
||||
*/
|
||||
private function resolve_module_deps( string $module_id, array &$ordered, array &$resolved ): void {
|
||||
if ( isset( $resolved[ $module_id ] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$resolved[ $module_id ] = true;
|
||||
|
||||
$requires = $this->modules[ $module_id ]['requires'] ?? [];
|
||||
|
||||
// Only process array-type requires (module dependencies).
|
||||
// Object/associative arrays like {"php": "8.1"} are system requirements.
|
||||
if ( is_array( $requires ) && ! empty( $requires ) && array_is_list( $requires ) ) {
|
||||
foreach ( $requires as $dep_id ) {
|
||||
if ( isset( $this->modules[ $dep_id ] ) && ! isset( $resolved[ $dep_id ] ) ) {
|
||||
$this->resolve_module_deps( $dep_id, $ordered, $resolved );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ordered[] = $module_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a module is enabled.
|
||||
*
|
||||
* Uses string '1'/'0' instead of boolean to avoid WordPress update_option() issues
|
||||
* where false values may be stored inconsistently or cause option deletion.
|
||||
*
|
||||
* @param string $module_id Module ID.
|
||||
* @return bool True if enabled.
|
||||
*/
|
||||
public function is_module_enabled( string $module_id ): bool {
|
||||
$value = get_option( "wpmind_module_{$module_id}_enabled", '1' );
|
||||
// Handle both legacy boolean and new string format.
|
||||
return $value === '1' || $value === true || $value === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a module.
|
||||
*
|
||||
* @param string $module_id Module ID.
|
||||
* @return bool True if enabled successfully.
|
||||
*/
|
||||
public function enable_module( string $module_id ): bool {
|
||||
if ( ! isset( $this->modules[ $module_id ] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use string '1' instead of boolean true for reliable storage.
|
||||
update_option( "wpmind_module_{$module_id}_enabled", '1', false );
|
||||
$this->modules[ $module_id ]['enabled'] = true;
|
||||
|
||||
// Flush rewrite rules to register module routes.
|
||||
flush_rewrite_rules();
|
||||
|
||||
/**
|
||||
* Fires when a module is enabled.
|
||||
*
|
||||
* @param string $module_id Module ID.
|
||||
*/
|
||||
do_action( 'wpmind_module_enabled', $module_id );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a module.
|
||||
*
|
||||
* @param string $module_id Module ID.
|
||||
* @return bool True if disabled successfully.
|
||||
*/
|
||||
public function disable_module( string $module_id ): bool {
|
||||
if ( ! isset( $this->modules[ $module_id ] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! $this->modules[ $module_id ]['can_disable'] ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use string '0' instead of boolean false for reliable storage.
|
||||
// WordPress update_option() can behave inconsistently with boolean false.
|
||||
update_option( "wpmind_module_{$module_id}_enabled", '0', false );
|
||||
$this->modules[ $module_id ]['enabled'] = false;
|
||||
|
||||
// Flush rewrite rules to remove module routes.
|
||||
flush_rewrite_rules();
|
||||
|
||||
/**
|
||||
* Fires when a module is disabled.
|
||||
*
|
||||
* @param string $module_id Module ID.
|
||||
*/
|
||||
do_action( 'wpmind_module_disabled', $module_id );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered modules.
|
||||
*
|
||||
* @return array<string, array> Modules array.
|
||||
*/
|
||||
public function get_modules(): array {
|
||||
return $this->modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific module info.
|
||||
*
|
||||
* @param string $module_id Module ID.
|
||||
* @return array|null Module info or null.
|
||||
*/
|
||||
public function get_module( string $module_id ): ?array {
|
||||
return $this->modules[ $module_id ] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a loaded module instance.
|
||||
*
|
||||
* @param string $module_id Module ID.
|
||||
* @return ModuleInterface|null Module instance or null.
|
||||
*/
|
||||
public function get_instance( string $module_id ): ?ModuleInterface {
|
||||
return $this->instances[ $module_id ] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all loaded module instances.
|
||||
*
|
||||
* @return array<string, ModuleInterface> Module instances.
|
||||
*/
|
||||
public function get_instances(): array {
|
||||
return $this->instances;
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler for toggling module status.
|
||||
*/
|
||||
public function ajax_toggle_module(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( '权限不足', 'wpmind' ) ) );
|
||||
}
|
||||
|
||||
$module_id = sanitize_key( $_POST['module_id'] ?? '' );
|
||||
$enable = filter_var( $_POST['enable'] ?? false, FILTER_VALIDATE_BOOLEAN );
|
||||
|
||||
if ( empty( $module_id ) ) {
|
||||
wp_send_json_error( array( 'message' => __( '无效的模块 ID', 'wpmind' ) ) );
|
||||
}
|
||||
|
||||
$module = $this->get_module( $module_id );
|
||||
|
||||
if ( ! $module ) {
|
||||
wp_send_json_error( array( 'message' => __( '模块不存在', 'wpmind' ) ) );
|
||||
}
|
||||
|
||||
if ( ! $enable && ! $module['can_disable'] ) {
|
||||
wp_send_json_error( array( 'message' => __( '此模块不能被禁用', 'wpmind' ) ) );
|
||||
}
|
||||
|
||||
if ( $enable ) {
|
||||
$this->enable_module( $module_id );
|
||||
$message = sprintf( __( '模块 %s 已启用', 'wpmind' ), $module['name'] );
|
||||
} else {
|
||||
$this->disable_module( $module_id );
|
||||
$message = sprintf( __( '模块 %s 已禁用', 'wpmind' ), $module['name'] );
|
||||
}
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'message' => $message,
|
||||
'enabled' => $enable,
|
||||
'reload' => true,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -91,7 +91,7 @@ class ErrorHandler
|
|||
* @param string|null $raw_error 原始错误消息
|
||||
* @return string 用户友好的错误消息
|
||||
*/
|
||||
public static function getErrorMessage(int $http_code, string $provider = '', ?string $raw_error = null): string
|
||||
public static function get_error_message(int $http_code, string $provider = '', ?string $raw_error = null): string
|
||||
{
|
||||
// 检查 Provider 特定的错误消息
|
||||
if (!empty($provider) && isset(self::PROVIDER_ERROR_HINTS[$provider][$http_code])) {
|
||||
|
|
@ -105,7 +105,7 @@ class ErrorHandler
|
|||
|
||||
// 尝试从原始错误中提取有用信息
|
||||
if (!empty($raw_error)) {
|
||||
return self::parseRawError($raw_error);
|
||||
return self::parse_raw_error($raw_error);
|
||||
}
|
||||
|
||||
// 默认消息
|
||||
|
|
@ -119,7 +119,7 @@ class ErrorHandler
|
|||
* @param string $provider Provider ID
|
||||
* @return string 用户友好的错误消息
|
||||
*/
|
||||
public static function getWpErrorMessage(\WP_Error $error, string $provider = ''): string
|
||||
public static function get_wp_error_message(\WP_Error $error, string $provider = ''): string
|
||||
{
|
||||
$error_code = $error->get_error_code();
|
||||
$error_message = $error->get_error_message();
|
||||
|
|
@ -148,22 +148,22 @@ class ErrorHandler
|
|||
* @param string $raw_error 原始错误消息
|
||||
* @return string 解析后的错误消息
|
||||
*/
|
||||
private static function parseRawError(string $raw_error): string
|
||||
private static function parse_raw_error(string $raw_error): string
|
||||
{
|
||||
// 尝试解析 JSON 错误响应
|
||||
$decoded = json_decode($raw_error, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
// OpenAI 格式
|
||||
if (isset($decoded['error']['message'])) {
|
||||
return self::translateErrorMessage($decoded['error']['message']);
|
||||
return self::translate_error_message($decoded['error']['message']);
|
||||
}
|
||||
// Anthropic 格式
|
||||
if (isset($decoded['error']['type'])) {
|
||||
return self::translateErrorMessage($decoded['error']['type']);
|
||||
return self::translate_error_message($decoded['error']['type']);
|
||||
}
|
||||
// 通用格式
|
||||
if (isset($decoded['message'])) {
|
||||
return self::translateErrorMessage($decoded['message']);
|
||||
return self::translate_error_message($decoded['message']);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,7 +176,7 @@ class ErrorHandler
|
|||
* @param string $message 英文错误消息
|
||||
* @return string 翻译后的消息
|
||||
*/
|
||||
private static function translateErrorMessage(string $message): string
|
||||
private static function translate_error_message(string $message): string
|
||||
{
|
||||
$translations = [
|
||||
'invalid_api_key' => 'API Key 无效',
|
||||
|
|
@ -206,7 +206,7 @@ class ErrorHandler
|
|||
* @param string $error_type 错误类型
|
||||
* @return string 错误消息
|
||||
*/
|
||||
public static function getErrorTypeMessage(string $error_type): string
|
||||
public static function get_error_type_message(string $error_type): string
|
||||
{
|
||||
return self::ERROR_TYPE_MESSAGES[$error_type] ?? __('未知错误', 'wpmind');
|
||||
}
|
||||
|
|
@ -217,7 +217,7 @@ class ErrorHandler
|
|||
* @param int $http_code HTTP 状态码
|
||||
* @return bool 是否应该重试
|
||||
*/
|
||||
public static function shouldRetry(int $http_code): bool
|
||||
public static function should_retry(int $http_code): bool
|
||||
{
|
||||
// 可重试的状态码
|
||||
$retryable_codes = [408, 429, 500, 502, 503, 504];
|
||||
|
|
@ -230,7 +230,7 @@ class ErrorHandler
|
|||
* @param int $attempt 当前尝试次数(从 1 开始)
|
||||
* @return int 延迟时间(毫秒)
|
||||
*/
|
||||
public static function getRetryDelay(int $attempt): int
|
||||
public static function get_retry_delay(int $attempt): int
|
||||
{
|
||||
// 指数退避:1s, 2s, 4s...
|
||||
return min(1000 * pow(2, $attempt - 1), 8000);
|
||||
|
|
|
|||
|
|
@ -39,9 +39,9 @@ class CircuitBreaker
|
|||
/**
|
||||
* 获取当前状态
|
||||
*/
|
||||
public function getState(): string
|
||||
public function get_state(): string
|
||||
{
|
||||
$data = $this->getData();
|
||||
$data = $this->get_data();
|
||||
return $data['state'] ?? self::STATE_CLOSED;
|
||||
}
|
||||
|
||||
|
|
@ -50,18 +50,18 @@ class CircuitBreaker
|
|||
*
|
||||
* @param bool $allowTransition 是否允许状态转换(默认 true)
|
||||
*/
|
||||
public function isAvailable(bool $allowTransition = true): bool
|
||||
public function is_available(bool $allowTransition = true): bool
|
||||
{
|
||||
$state = $this->getState();
|
||||
$state = $this->get_state();
|
||||
|
||||
if ($state === self::STATE_CLOSED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($state === self::STATE_OPEN) {
|
||||
if ($this->shouldTransitionToHalfOpen()) {
|
||||
if ($this->should_transition_to_half_open()) {
|
||||
if ($allowTransition) {
|
||||
$this->transitionTo(self::STATE_HALF_OPEN);
|
||||
$this->transition_to(self::STATE_HALF_OPEN);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -69,7 +69,7 @@ class CircuitBreaker
|
|||
}
|
||||
|
||||
// 半开状态:检查是否还有测试配额
|
||||
return $this->canAllowHalfOpenRequest();
|
||||
return $this->can_allow_half_open_request();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -77,70 +77,86 @@ class CircuitBreaker
|
|||
*
|
||||
* 用于状态查询,不会修改熔断器状态
|
||||
*/
|
||||
public function isAvailableReadOnly(): bool
|
||||
public function is_available_read_only(): bool
|
||||
{
|
||||
return $this->isAvailable(false);
|
||||
return $this->is_available(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录成功请求
|
||||
*/
|
||||
public function recordSuccess(): void
|
||||
public function record_success(): void
|
||||
{
|
||||
$data = $this->getData();
|
||||
$data = $this->get_data();
|
||||
$now = time();
|
||||
$state = $data['state'] ?? self::STATE_CLOSED;
|
||||
|
||||
// 开启状态下,如果恢复时间已过,先转换到半开状态
|
||||
if ($state === self::STATE_OPEN && $this->should_transition_to_half_open()) {
|
||||
$this->transition_to(self::STATE_HALF_OPEN);
|
||||
$data = $this->get_data(); // 重新获取转换后的数据
|
||||
$state = self::STATE_HALF_OPEN;
|
||||
}
|
||||
|
||||
// 记录带时间戳的请求
|
||||
$data['requests'][] = ['success' => true, 'time' => $now];
|
||||
$data['requests'] = $this->filterRecentRequests($data['requests'] ?? [], $now);
|
||||
$data['requests'] = $this->filter_recent_requests($data['requests'] ?? [], $now);
|
||||
|
||||
$data['successes'] = ($data['successes'] ?? 0) + 1;
|
||||
$data['last_success'] = $now;
|
||||
$data['consecutive_failures'] = 0;
|
||||
|
||||
// 半开状态下成功次数达标,恢复到关闭状态
|
||||
if (($data['state'] ?? self::STATE_CLOSED) === self::STATE_HALF_OPEN) {
|
||||
if ($state === self::STATE_HALF_OPEN) {
|
||||
$data['half_open_successes'] = ($data['half_open_successes'] ?? 0) + 1;
|
||||
if ($data['half_open_successes'] >= self::HALF_OPEN_REQUESTS) {
|
||||
$this->transitionTo(self::STATE_CLOSED);
|
||||
$this->transition_to(self::STATE_CLOSED);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->saveData($data);
|
||||
$this->save_data($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录失败请求
|
||||
*/
|
||||
public function recordFailure(): void
|
||||
public function record_failure(): void
|
||||
{
|
||||
$data = $this->getData();
|
||||
$data = $this->get_data();
|
||||
$now = time();
|
||||
$state = $data['state'] ?? self::STATE_CLOSED;
|
||||
|
||||
// 开启状态下,如果恢复时间已过,先转换到半开状态
|
||||
if ($state === self::STATE_OPEN && $this->should_transition_to_half_open()) {
|
||||
$this->transition_to(self::STATE_HALF_OPEN);
|
||||
$data = $this->get_data();
|
||||
$state = self::STATE_HALF_OPEN;
|
||||
}
|
||||
|
||||
// 记录带时间戳的请求
|
||||
$data['requests'][] = ['success' => false, 'time' => $now];
|
||||
$data['requests'] = $this->filterRecentRequests($data['requests'] ?? [], $now);
|
||||
$data['requests'] = $this->filter_recent_requests($data['requests'] ?? [], $now);
|
||||
|
||||
$data['failures'] = ($data['failures'] ?? 0) + 1;
|
||||
$data['consecutive_failures'] = ($data['consecutive_failures'] ?? 0) + 1;
|
||||
$data['last_failure'] = $now;
|
||||
|
||||
// 半开状态下失败,立即回到开启状态
|
||||
if (($data['state'] ?? self::STATE_CLOSED) === self::STATE_HALF_OPEN) {
|
||||
if ($state === self::STATE_HALF_OPEN) {
|
||||
$data['half_open_failures'] = ($data['half_open_failures'] ?? 0) + 1;
|
||||
$this->saveData($data);
|
||||
$this->transitionTo(self::STATE_OPEN);
|
||||
$this->save_data($data);
|
||||
$this->transition_to(self::STATE_OPEN);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否应该熔断
|
||||
if ($this->shouldTrip($data)) {
|
||||
$this->transitionTo(self::STATE_OPEN);
|
||||
if ($this->should_trip($data)) {
|
||||
$this->transition_to(self::STATE_OPEN);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->saveData($data);
|
||||
$this->save_data($data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -154,29 +170,29 @@ class CircuitBreaker
|
|||
/**
|
||||
* 获取状态详情
|
||||
*/
|
||||
public function getStatusDetails(): array
|
||||
public function get_status_details(): array
|
||||
{
|
||||
$data = $this->getData();
|
||||
$data = $this->get_data();
|
||||
$state = $data['state'] ?? self::STATE_CLOSED;
|
||||
|
||||
return [
|
||||
'provider_id' => $this->providerId,
|
||||
'state' => $state,
|
||||
'state_label' => $this->getStateLabel($state),
|
||||
'state_label' => $this->get_state_label($state),
|
||||
'failures' => $data['failures'] ?? 0,
|
||||
'successes' => $data['successes'] ?? 0,
|
||||
'consecutive_failures' => $data['consecutive_failures'] ?? 0,
|
||||
'last_failure' => $data['last_failure'] ?? null,
|
||||
'last_success' => $data['last_success'] ?? null,
|
||||
'transitioned_at' => $data['transitioned'] ?? null,
|
||||
'recovery_in' => $this->getRecoveryTimeRemaining($data),
|
||||
'recovery_in' => $this->get_recovery_time_remaining($data),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该触发熔断
|
||||
*/
|
||||
private function shouldTrip(array $data): bool
|
||||
private function should_trip(array $data): bool
|
||||
{
|
||||
// 连续失败次数超过阈值
|
||||
$consecutiveFailures = $data['consecutive_failures'] ?? 0;
|
||||
|
|
@ -200,7 +216,7 @@ class CircuitBreaker
|
|||
/**
|
||||
* 过滤出时间窗口内的请求
|
||||
*/
|
||||
private function filterRecentRequests(array $requests, int $now): array
|
||||
private function filter_recent_requests(array $requests, int $now): array
|
||||
{
|
||||
$cutoff = $now - self::WINDOW_SIZE;
|
||||
return array_values(array_filter(
|
||||
|
|
@ -212,9 +228,9 @@ class CircuitBreaker
|
|||
/**
|
||||
* 检查是否应该从开启转为半开
|
||||
*/
|
||||
private function shouldTransitionToHalfOpen(): bool
|
||||
private function should_transition_to_half_open(): bool
|
||||
{
|
||||
$data = $this->getData();
|
||||
$data = $this->get_data();
|
||||
$transitioned = $data['transitioned'] ?? 0;
|
||||
return (time() - $transitioned) >= self::RECOVERY_TIME;
|
||||
}
|
||||
|
|
@ -222,9 +238,9 @@ class CircuitBreaker
|
|||
/**
|
||||
* 检查半开状态是否还能接受请求
|
||||
*/
|
||||
private function canAllowHalfOpenRequest(): bool
|
||||
private function can_allow_half_open_request(): bool
|
||||
{
|
||||
$data = $this->getData();
|
||||
$data = $this->get_data();
|
||||
$halfOpenRequests = ($data['half_open_successes'] ?? 0) + ($data['half_open_failures'] ?? 0);
|
||||
return $halfOpenRequests < self::HALF_OPEN_REQUESTS;
|
||||
}
|
||||
|
|
@ -232,7 +248,7 @@ class CircuitBreaker
|
|||
/**
|
||||
* 状态转换
|
||||
*/
|
||||
private function transitionTo(string $newState): void
|
||||
private function transition_to(string $newState): void
|
||||
{
|
||||
$data = [
|
||||
'state' => $newState,
|
||||
|
|
@ -247,13 +263,13 @@ class CircuitBreaker
|
|||
$data['half_open_failures'] = 0;
|
||||
}
|
||||
|
||||
$this->saveData($data);
|
||||
$this->save_data($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剩余恢复时间
|
||||
*/
|
||||
private function getRecoveryTimeRemaining(array $data): ?int
|
||||
private function get_recovery_time_remaining(array $data): ?int
|
||||
{
|
||||
if (($data['state'] ?? self::STATE_CLOSED) !== self::STATE_OPEN) {
|
||||
return null;
|
||||
|
|
@ -267,7 +283,7 @@ class CircuitBreaker
|
|||
/**
|
||||
* 获取状态标签
|
||||
*/
|
||||
private function getStateLabel(string $state): string
|
||||
private function get_state_label(string $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
self::STATE_CLOSED => __('正常', 'wpmind'),
|
||||
|
|
@ -277,13 +293,13 @@ class CircuitBreaker
|
|||
};
|
||||
}
|
||||
|
||||
private function getData(): array
|
||||
private function get_data(): array
|
||||
{
|
||||
$data = get_transient($this->transientKey);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
private function saveData(array $data): void
|
||||
private function save_data(array $data): void
|
||||
{
|
||||
// TTL 必须大于 RECOVERY_TIME,否则状态会过早重置
|
||||
set_transient($this->transientKey, $data, self::RECOVERY_TIME * 2);
|
||||
|
|
|
|||
|
|
@ -57,9 +57,9 @@ class FailoverManager
|
|||
* @param string|null $preferredProvider 首选 Provider ID
|
||||
* @return string|null 选中的 Provider ID
|
||||
*/
|
||||
public function selectProvider(?string $preferredProvider = null): ?string
|
||||
public function select_provider(?string $preferredProvider = null): ?string
|
||||
{
|
||||
$available = $this->getAvailableProviders();
|
||||
$available = $this->get_available_providers();
|
||||
|
||||
if (empty($available)) {
|
||||
return null;
|
||||
|
|
@ -72,13 +72,13 @@ class FailoverManager
|
|||
|
||||
// 按健康分数排序
|
||||
usort($available, function ($a, $b) {
|
||||
$scoreA = ProviderHealthTracker::getHealthScore($a);
|
||||
$scoreB = ProviderHealthTracker::getHealthScore($b);
|
||||
$scoreA = ProviderHealthTracker::get_health_score($a);
|
||||
$scoreB = ProviderHealthTracker::get_health_score($b);
|
||||
|
||||
// 分数相同时,按延迟排序
|
||||
if ($scoreA === $scoreB) {
|
||||
$latencyA = ProviderHealthTracker::getAverageLatency($a);
|
||||
$latencyB = ProviderHealthTracker::getAverageLatency($b);
|
||||
$latencyA = ProviderHealthTracker::get_average_latency($a);
|
||||
$latencyB = ProviderHealthTracker::get_average_latency($b);
|
||||
return $latencyA - $latencyB;
|
||||
}
|
||||
|
||||
|
|
@ -93,12 +93,12 @@ class FailoverManager
|
|||
*
|
||||
* @return array<string> 可用的 Provider ID 列表
|
||||
*/
|
||||
public function getAvailableProviders(): array
|
||||
public function get_available_providers(): array
|
||||
{
|
||||
$available = [];
|
||||
|
||||
foreach ($this->circuitBreakers as $providerId => $breaker) {
|
||||
if ($breaker->isAvailable()) {
|
||||
if ($breaker->is_available()) {
|
||||
$available[] = $providerId;
|
||||
}
|
||||
}
|
||||
|
|
@ -114,18 +114,42 @@ class FailoverManager
|
|||
* @param string|null $preferredProvider 首选 Provider ID
|
||||
* @return array<string> Provider ID 列表
|
||||
*/
|
||||
public function getFailoverChain(?string $preferredProvider = null): array
|
||||
public function get_failover_chain(?string $preferredProvider = null): array
|
||||
{
|
||||
$available = $this->getAvailableProviders();
|
||||
$available = $this->get_available_providers();
|
||||
|
||||
if (empty($available)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 检查是否有手动优先级设置
|
||||
$manual_priority = [];
|
||||
if (class_exists('\\WPMind\\Routing\\IntelligentRouter')) {
|
||||
$router = \WPMind\Routing\IntelligentRouter::instance();
|
||||
$manual_priority = $router->get_manual_priority();
|
||||
}
|
||||
|
||||
if (!empty($manual_priority)) {
|
||||
// 使用手动优先级排序
|
||||
$sorted = [];
|
||||
foreach ($manual_priority as $providerId) {
|
||||
if (in_array($providerId, $available, true)) {
|
||||
$sorted[] = $providerId;
|
||||
}
|
||||
}
|
||||
// 添加未在手动列表中的可用 Provider
|
||||
foreach ($available as $providerId) {
|
||||
if (!in_array($providerId, $sorted, true)) {
|
||||
$sorted[] = $providerId;
|
||||
}
|
||||
}
|
||||
$available = $sorted;
|
||||
} else {
|
||||
// 按健康分数排序
|
||||
usort($available, function ($a, $b) {
|
||||
return ProviderHealthTracker::getHealthScore($b) - ProviderHealthTracker::getHealthScore($a);
|
||||
return ProviderHealthTracker::get_health_score($b) - ProviderHealthTracker::get_health_score($a);
|
||||
});
|
||||
}
|
||||
|
||||
// 首选 Provider 放在最前面
|
||||
if ($preferredProvider && in_array($preferredProvider, $available, true)) {
|
||||
|
|
@ -143,14 +167,14 @@ class FailoverManager
|
|||
* @param bool $success 是否成功
|
||||
* @param int $latencyMs 延迟(毫秒)
|
||||
*/
|
||||
public function recordResult(string $providerId, bool $success, int $latencyMs = 0): void
|
||||
public function record_result(string $providerId, bool $success, int $latencyMs = 0): void
|
||||
{
|
||||
// 更新熔断器状态
|
||||
if (isset($this->circuitBreakers[$providerId])) {
|
||||
if ($success) {
|
||||
$this->circuitBreakers[$providerId]->recordSuccess();
|
||||
$this->circuitBreakers[$providerId]->record_success();
|
||||
} else {
|
||||
$this->circuitBreakers[$providerId]->recordFailure();
|
||||
$this->circuitBreakers[$providerId]->record_failure();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,20 +187,20 @@ class FailoverManager
|
|||
*
|
||||
* @return array Provider 状态信息
|
||||
*/
|
||||
public function getStatusSummary(): array
|
||||
public function get_status_summary(): array
|
||||
{
|
||||
$summary = [];
|
||||
|
||||
foreach ($this->circuitBreakers as $providerId => $breaker) {
|
||||
$cbStatus = $breaker->getStatusDetails();
|
||||
$healthStatus = ProviderHealthTracker::getProviderStatus($providerId);
|
||||
$cbStatus = $breaker->get_status_details();
|
||||
$healthStatus = ProviderHealthTracker::get_provider_status($providerId);
|
||||
|
||||
$summary[$providerId] = [
|
||||
'name' => $this->providers[$providerId]['name'] ?? $providerId,
|
||||
'display_name' => $this->providers[$providerId]['display_name'] ?? $providerId,
|
||||
'state' => $cbStatus['state'],
|
||||
'state_label' => $cbStatus['state_label'],
|
||||
'available' => $breaker->isAvailableReadOnly(),
|
||||
'available' => $breaker->is_available_read_only(),
|
||||
'health_score' => $healthStatus['health_score'],
|
||||
'avg_latency' => $healthStatus['avg_latency'],
|
||||
'success_rate' => $healthStatus['success_rate'],
|
||||
|
|
@ -192,9 +216,9 @@ class FailoverManager
|
|||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasAvailableProvider(): bool
|
||||
public function has_available_provider(): bool
|
||||
{
|
||||
return !empty($this->getAvailableProviders());
|
||||
return !empty($this->get_available_providers());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -202,23 +226,23 @@ class FailoverManager
|
|||
*
|
||||
* @param string $providerId Provider ID
|
||||
*/
|
||||
public function resetProvider(string $providerId): void
|
||||
public function reset_provider(string $providerId): void
|
||||
{
|
||||
if (isset($this->circuitBreakers[$providerId])) {
|
||||
$this->circuitBreakers[$providerId]->reset();
|
||||
}
|
||||
ProviderHealthTracker::clearProvider($providerId);
|
||||
ProviderHealthTracker::clear_provider($providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有熔断器
|
||||
*/
|
||||
public function resetAll(): void
|
||||
public function reset_all(): void
|
||||
{
|
||||
foreach ($this->circuitBreakers as $breaker) {
|
||||
$breaker->reset();
|
||||
}
|
||||
ProviderHealthTracker::clearAll();
|
||||
ProviderHealthTracker::clear_all();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -227,7 +251,7 @@ class FailoverManager
|
|||
* @param string $providerId Provider ID
|
||||
* @return CircuitBreaker|null
|
||||
*/
|
||||
public function getCircuitBreaker(string $providerId): ?CircuitBreaker
|
||||
public function get_circuit_breaker(string $providerId): ?CircuitBreaker
|
||||
{
|
||||
return $this->circuitBreakers[$providerId] ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class ProviderHealthTracker
|
|||
*/
|
||||
public static function record(string $providerId, bool $success, int $latencyMs = 0): void
|
||||
{
|
||||
$health = self::getAllHealth();
|
||||
$health = self::get_all_health();
|
||||
|
||||
if (!isset($health[$providerId])) {
|
||||
$health[$providerId] = [
|
||||
|
|
@ -87,9 +87,9 @@ class ProviderHealthTracker
|
|||
* @param string $providerId Provider ID
|
||||
* @return int 健康分数
|
||||
*/
|
||||
public static function getHealthScore(string $providerId): int
|
||||
public static function get_health_score(string $providerId): int
|
||||
{
|
||||
$health = self::getAllHealth();
|
||||
$health = self::get_all_health();
|
||||
|
||||
if (!isset($health[$providerId]) || empty($health[$providerId]['history'])) {
|
||||
return 100;
|
||||
|
|
@ -108,9 +108,9 @@ class ProviderHealthTracker
|
|||
* @param string $providerId Provider ID
|
||||
* @return int 平均延迟(毫秒)
|
||||
*/
|
||||
public static function getAverageLatency(string $providerId): int
|
||||
public static function get_average_latency(string $providerId): int
|
||||
{
|
||||
$health = self::getAllHealth();
|
||||
$health = self::get_all_health();
|
||||
return $health[$providerId]['avg_latency'] ?? 0;
|
||||
}
|
||||
|
||||
|
|
@ -120,9 +120,9 @@ class ProviderHealthTracker
|
|||
* @param string $providerId Provider ID
|
||||
* @return array 状态详情
|
||||
*/
|
||||
public static function getProviderStatus(string $providerId): array
|
||||
public static function get_provider_status(string $providerId): array
|
||||
{
|
||||
$health = self::getAllHealth();
|
||||
$health = self::get_all_health();
|
||||
|
||||
if (!isset($health[$providerId])) {
|
||||
return [
|
||||
|
|
@ -139,7 +139,7 @@ class ProviderHealthTracker
|
|||
$successes = count(array_filter($provider['history'], fn($h) => $h['success']));
|
||||
|
||||
return [
|
||||
'health_score' => self::getHealthScore($providerId),
|
||||
'health_score' => self::get_health_score($providerId),
|
||||
'avg_latency' => $provider['avg_latency'] ?? 0,
|
||||
'total' => $provider['total'] ?? 0,
|
||||
'failures' => $provider['failures'] ?? 0,
|
||||
|
|
@ -153,7 +153,7 @@ class ProviderHealthTracker
|
|||
*
|
||||
* @return array 所有 Provider 的健康数据
|
||||
*/
|
||||
public static function getAllHealth(): array
|
||||
public static function get_all_health(): array
|
||||
{
|
||||
$data = get_transient(self::TRANSIENT_KEY);
|
||||
return is_array($data) ? $data : [];
|
||||
|
|
@ -162,7 +162,7 @@ class ProviderHealthTracker
|
|||
/**
|
||||
* 清除所有健康数据
|
||||
*/
|
||||
public static function clearAll(): void
|
||||
public static function clear_all(): void
|
||||
{
|
||||
delete_transient(self::TRANSIENT_KEY);
|
||||
}
|
||||
|
|
@ -172,9 +172,9 @@ class ProviderHealthTracker
|
|||
*
|
||||
* @param string $providerId Provider ID
|
||||
*/
|
||||
public static function clearProvider(string $providerId): void
|
||||
public static function clear_provider(string $providerId): void
|
||||
{
|
||||
$health = self::getAllHealth();
|
||||
$health = self::get_all_health();
|
||||
unset($health[$providerId]);
|
||||
set_transient(self::TRANSIENT_KEY, $health, self::CACHE_TTL);
|
||||
}
|
||||
|
|
|
|||
688
includes/MCP/Gateway.php
Normal file
688
includes/MCP/Gateway.php
Normal file
|
|
@ -0,0 +1,688 @@
|
|||
<?php
|
||||
/**
|
||||
* MCP Gateway bootstrap and Ability registration.
|
||||
*
|
||||
* @package WPMind\MCP
|
||||
* @since 4.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\MCP;
|
||||
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* Class Gateway
|
||||
*/
|
||||
final class Gateway {
|
||||
|
||||
/**
|
||||
* Ability category slug.
|
||||
*/
|
||||
private const ABILITY_CATEGORY = 'wpmind-ai-gateway';
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var Gateway|null
|
||||
*/
|
||||
private static ?Gateway $instance = null;
|
||||
|
||||
/**
|
||||
* Whether gateway hooks are initialized.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private bool $initialized = false;
|
||||
|
||||
/**
|
||||
* Registered ability names.
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
private array $ability_names = [];
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @return Gateway
|
||||
*/
|
||||
public static function instance(): Gateway {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* Register MCP Gateway hooks.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function init(): void {
|
||||
if ( $this->initialized ) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_filter( 'mcp_adapter_default_server_config', [ $this, 'filter_server_config' ] );
|
||||
add_action( 'wp_abilities_api_categories_init', [ $this, 'register_ability_categories' ] );
|
||||
add_action( 'wp_abilities_api_init', [ $this, 'register_abilities' ] );
|
||||
add_action( 'mcp_adapter_init', [ $this, 'register_server' ] );
|
||||
|
||||
$this->initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter MCP server config.
|
||||
*
|
||||
* @param array $config Server config.
|
||||
* @return array
|
||||
*/
|
||||
public function filter_server_config( array $config ): array {
|
||||
$config['name'] = 'wpmind-mcp';
|
||||
$config['version'] = WPMIND_VERSION;
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register gateway ability category.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_ability_categories(): void {
|
||||
if ( ! function_exists( 'wp_register_ability_category' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( function_exists( 'wp_has_ability_category' ) && wp_has_ability_category( self::ABILITY_CATEGORY ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_register_ability_category(
|
||||
self::ABILITY_CATEGORY,
|
||||
[
|
||||
'label' => __( 'WPMind AI Gateway', 'wpmind' ),
|
||||
'description' => __( 'AI routing, provider health, usage and budget control abilities.', 'wpmind' ),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WPMind abilities when Abilities API is available.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_abilities(): void {
|
||||
if ( ! $this->is_abilities_api_available() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$definitions = $this->get_ability_definitions();
|
||||
$this->ability_names = [];
|
||||
|
||||
foreach ( $definitions as $ability_name => $definition ) {
|
||||
if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $ability_name ) ) {
|
||||
$this->ability_names[] = $ability_name;
|
||||
continue;
|
||||
}
|
||||
|
||||
$ability = wp_register_ability( $ability_name, $definition );
|
||||
if ( null === $ability ) {
|
||||
do_action( 'wpmind_mcp_gateway_ability_registration_failed', $ability_name, $definition );
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->ability_names[] = $ability_name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register MCP server when adapter is initialized.
|
||||
*
|
||||
* @param mixed $adapter MCP adapter instance.
|
||||
* @return void
|
||||
*/
|
||||
public function register_server( $adapter ): void {
|
||||
if ( ! $this->is_abilities_api_available() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! is_object( $adapter ) || ! method_exists( $adapter, 'create_server' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( class_exists( '\WP_Abilities_Registry' ) ) {
|
||||
\WP_Abilities_Registry::get_instance();
|
||||
}
|
||||
|
||||
if ( empty( $this->ability_names ) ) {
|
||||
$this->ability_names = $this->get_registered_ability_names();
|
||||
}
|
||||
|
||||
if ( empty( $this->ability_names ) ) {
|
||||
do_action( 'wpmind_mcp_gateway_registration_failed', 'No abilities registered for gateway.' );
|
||||
return;
|
||||
}
|
||||
|
||||
$transports = [];
|
||||
if ( class_exists( '\WP\MCP\Transport\HttpTransport' ) ) {
|
||||
$transports[] = \WP\MCP\Transport\HttpTransport::class;
|
||||
}
|
||||
|
||||
try {
|
||||
$adapter->create_server(
|
||||
'wpmind-ai-gateway',
|
||||
'wpmind',
|
||||
'mcp',
|
||||
'WPMind AI Gateway',
|
||||
'Intelligent AI routing with multi-provider support',
|
||||
WPMIND_VERSION,
|
||||
$transports,
|
||||
null,
|
||||
$this->ability_names
|
||||
);
|
||||
|
||||
do_action( 'wpmind_mcp_gateway_registered', $this->ability_names );
|
||||
return;
|
||||
} catch ( \ArgumentCountError $error ) {
|
||||
// Backward-compatible fallback for older adapter signatures.
|
||||
do_action( 'wpmind_mcp_gateway_adapter_fallback', $error->getMessage() );
|
||||
} catch ( \Throwable $error ) {
|
||||
do_action( 'wpmind_mcp_gateway_registration_failed', $error->getMessage() );
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$adapter->create_server(
|
||||
'wpmind-ai-gateway',
|
||||
'wpmind',
|
||||
'mcp'
|
||||
);
|
||||
|
||||
do_action( 'wpmind_mcp_gateway_registered', $this->ability_names );
|
||||
} catch ( \Throwable $error ) {
|
||||
do_action( 'wpmind_mcp_gateway_registration_failed', $error->getMessage() );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ability: mind/chat
|
||||
*
|
||||
* @param mixed ...$args Callback args.
|
||||
* @return array
|
||||
*/
|
||||
public function execute_chat_ability( ...$args ): array {
|
||||
$input = $this->normalize_input( $args[0] ?? [] );
|
||||
|
||||
$messages = $input['messages'] ?? ( $input['prompt'] ?? '' );
|
||||
$options = isset( $input['options'] ) && is_array( $input['options'] ) ? $input['options'] : [];
|
||||
|
||||
if ( '' === $messages || [] === $messages ) {
|
||||
return $this->error_result(
|
||||
'wpmind_mcp_chat_missing_prompt',
|
||||
__( 'Missing prompt/messages for mind/chat ability.', 'wpmind' )
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'wpmind_chat' ) ) {
|
||||
return $this->error_result(
|
||||
'wpmind_mcp_api_unavailable',
|
||||
__( 'WPMind public API is not available.', 'wpmind' )
|
||||
);
|
||||
}
|
||||
|
||||
$result = wpmind_chat( $messages, $options );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $this->error_from_wp_error( $result );
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ability: mind/get-providers
|
||||
*
|
||||
* @param mixed ...$args Callback args.
|
||||
* @return array
|
||||
*/
|
||||
public function execute_get_providers_ability( ...$args ): array {
|
||||
unset( $args );
|
||||
|
||||
$providers = [];
|
||||
if ( function_exists( 'WPMind\\wpmind' ) ) {
|
||||
$endpoints = \WPMind\wpmind()->get_custom_endpoints();
|
||||
foreach ( $endpoints as $id => $endpoint ) {
|
||||
if ( empty( $endpoint['enabled'] ) || empty( $endpoint['api_key'] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$providers[] = [
|
||||
'id' => $id,
|
||||
'name' => $endpoint['display_name'] ?? $endpoint['name'] ?? $id,
|
||||
'base_url' => $endpoint['custom_base_url'] ?? $endpoint['base_url'] ?? '',
|
||||
'model_count' => is_array( $endpoint['models'] ?? null ) ? count( $endpoint['models'] ) : 0,
|
||||
'is_official' => ! empty( $endpoint['is_official'] ),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$router_status = class_exists( '\WPMind\\Routing\\IntelligentRouter' )
|
||||
? \WPMind\Routing\IntelligentRouter::instance()->get_status_summary()
|
||||
: [];
|
||||
|
||||
$failover_status = class_exists( '\WPMind\\Failover\\FailoverManager' )
|
||||
? \WPMind\Failover\FailoverManager::instance()->get_status_summary()
|
||||
: [];
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'providers' => $providers,
|
||||
'routing' => $router_status,
|
||||
'failover' => $failover_status,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ability: mind/get-usage-stats
|
||||
*
|
||||
* @param mixed ...$args Callback args.
|
||||
* @return array
|
||||
*/
|
||||
public function execute_get_usage_stats_ability( ...$args ): array {
|
||||
unset( $args );
|
||||
|
||||
$data = [
|
||||
'today' => [],
|
||||
'month' => [],
|
||||
'total' => [],
|
||||
'status' => [],
|
||||
'cache' => [],
|
||||
];
|
||||
|
||||
if ( class_exists( '\WPMind\\Modules\\CostControl\\UsageTracker' ) ) {
|
||||
$data['today'] = \WPMind\Modules\CostControl\UsageTracker::get_today_stats();
|
||||
$data['month'] = \WPMind\Modules\CostControl\UsageTracker::get_month_stats();
|
||||
$data['total'] = \WPMind\Modules\CostControl\UsageTracker::get_stats();
|
||||
}
|
||||
|
||||
if ( function_exists( 'wpmind_get_status' ) ) {
|
||||
$data['status'] = wpmind_get_status();
|
||||
}
|
||||
|
||||
if ( function_exists( 'wpmind_get_cache_stats' ) ) {
|
||||
$data['cache'] = wpmind_get_cache_stats();
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ability: mind/get-budget-status
|
||||
*
|
||||
* @param mixed ...$args Callback args.
|
||||
* @return array
|
||||
*/
|
||||
public function execute_get_budget_status_ability( ...$args ): array {
|
||||
unset( $args );
|
||||
|
||||
if ( ! class_exists( '\WPMind\\Modules\\CostControl\\BudgetChecker' ) ) {
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'enabled' => false,
|
||||
'global' => null,
|
||||
'providers' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$summary = \WPMind\Modules\CostControl\BudgetChecker::instance()->get_summary();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ability: mind/switch-strategy
|
||||
*
|
||||
* @param mixed ...$args Callback args.
|
||||
* @return array
|
||||
*/
|
||||
public function execute_switch_strategy_ability( ...$args ): array {
|
||||
$input = $this->normalize_input( $args[0] ?? [] );
|
||||
$strategy = sanitize_key( (string) ( $input['strategy'] ?? '' ) );
|
||||
|
||||
if ( '' === $strategy ) {
|
||||
return $this->error_result(
|
||||
'wpmind_mcp_switch_strategy_missing',
|
||||
__( 'Missing strategy for mind/switch-strategy ability.', 'wpmind' )
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! class_exists( '\WPMind\\Routing\\IntelligentRouter' ) ) {
|
||||
return $this->error_result(
|
||||
'wpmind_router_unavailable',
|
||||
__( 'Routing engine is unavailable.', 'wpmind' )
|
||||
);
|
||||
}
|
||||
|
||||
$router = \WPMind\Routing\IntelligentRouter::instance();
|
||||
$switched = $router->set_strategy( $strategy );
|
||||
|
||||
if ( ! $switched ) {
|
||||
return $this->error_result(
|
||||
'wpmind_invalid_strategy',
|
||||
__( 'Invalid routing strategy.', 'wpmind' ),
|
||||
[
|
||||
'available' => array_keys( $router->get_available_strategies() ),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'strategy' => $router->get_current_strategy(),
|
||||
'available' => $router->get_available_strategies(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission callback for read-only MCP abilities.
|
||||
*
|
||||
* @param mixed ...$args Callback args.
|
||||
* @return bool
|
||||
*/
|
||||
public function can_read_gateway_data( ...$args ): bool {
|
||||
unset( $args );
|
||||
|
||||
return current_user_can( 'manage_options' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission callback for write MCP abilities.
|
||||
*
|
||||
* @param mixed ...$args Callback args.
|
||||
* @return bool
|
||||
*/
|
||||
public function can_manage_gateway( ...$args ): bool {
|
||||
unset( $args );
|
||||
|
||||
return current_user_can( 'manage_options' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WordPress Abilities API is available.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function is_abilities_api_available(): bool {
|
||||
return function_exists( 'wp_register_ability' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ability registration definitions.
|
||||
*
|
||||
* @return array<string,array>
|
||||
*/
|
||||
private function get_ability_definitions(): array {
|
||||
$read_annotations = [
|
||||
'readonly' => true,
|
||||
'destructive' => false,
|
||||
'idempotent' => true,
|
||||
];
|
||||
|
||||
$definitions = [
|
||||
'mind/chat' => [
|
||||
'label' => __( 'WPMind Chat', 'wpmind' ),
|
||||
'description' => __( 'Execute routed chat completions via WPMind.', 'wpmind' ),
|
||||
'category' => self::ABILITY_CATEGORY,
|
||||
'input_schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'messages' => [
|
||||
'description' => __( 'Prompt string or chat message array.', 'wpmind' ),
|
||||
],
|
||||
'prompt' => [
|
||||
'type' => 'string',
|
||||
'description' => __( 'Shortcut prompt string.', 'wpmind' ),
|
||||
],
|
||||
'options' => [
|
||||
'type' => 'object',
|
||||
'description' => __( 'Chat options forwarded to wpmind_chat().', 'wpmind' ),
|
||||
],
|
||||
],
|
||||
],
|
||||
'output_schema' => [
|
||||
'type' => 'object',
|
||||
'description' => __( 'Chat execution result payload.', 'wpmind' ),
|
||||
],
|
||||
'permission_callback' => [ $this, 'can_read_gateway_data' ],
|
||||
'execute_callback' => [ $this, 'execute_chat_ability' ],
|
||||
'meta' => [
|
||||
'annotations' => $read_annotations,
|
||||
'show_in_rest' => true,
|
||||
'mcp' => [
|
||||
'public' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
'mind/get-providers' => [
|
||||
'label' => __( 'WPMind Get Providers', 'wpmind' ),
|
||||
'description' => __( 'Get provider availability, routing and failover status.', 'wpmind' ),
|
||||
'category' => self::ABILITY_CATEGORY,
|
||||
'input_schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [],
|
||||
],
|
||||
'output_schema' => [
|
||||
'type' => 'object',
|
||||
'description' => __( 'Provider status payload.', 'wpmind' ),
|
||||
],
|
||||
'permission_callback' => [ $this, 'can_read_gateway_data' ],
|
||||
'execute_callback' => [ $this, 'execute_get_providers_ability' ],
|
||||
'meta' => [
|
||||
'annotations' => $read_annotations,
|
||||
'show_in_rest' => true,
|
||||
'mcp' => [
|
||||
'public' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
'mind/get-usage-stats' => [
|
||||
'label' => __( 'WPMind Get Usage Stats', 'wpmind' ),
|
||||
'description' => __( 'Get usage, cost, and cache statistics.', 'wpmind' ),
|
||||
'category' => self::ABILITY_CATEGORY,
|
||||
'input_schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [],
|
||||
],
|
||||
'output_schema' => [
|
||||
'type' => 'object',
|
||||
'description' => __( 'Usage statistics payload.', 'wpmind' ),
|
||||
],
|
||||
'permission_callback' => [ $this, 'can_read_gateway_data' ],
|
||||
'execute_callback' => [ $this, 'execute_get_usage_stats_ability' ],
|
||||
'meta' => [
|
||||
'annotations' => $read_annotations,
|
||||
'show_in_rest' => true,
|
||||
'mcp' => [
|
||||
'public' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
'mind/get-budget-status' => [
|
||||
'label' => __( 'WPMind Get Budget Status', 'wpmind' ),
|
||||
'description' => __( 'Get budget guardrail status and thresholds.', 'wpmind' ),
|
||||
'category' => self::ABILITY_CATEGORY,
|
||||
'input_schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [],
|
||||
],
|
||||
'output_schema' => [
|
||||
'type' => 'object',
|
||||
'description' => __( 'Budget summary payload.', 'wpmind' ),
|
||||
],
|
||||
'permission_callback' => [ $this, 'can_read_gateway_data' ],
|
||||
'execute_callback' => [ $this, 'execute_get_budget_status_ability' ],
|
||||
'meta' => [
|
||||
'annotations' => $read_annotations,
|
||||
'show_in_rest' => true,
|
||||
'mcp' => [
|
||||
'public' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
'mind/switch-strategy' => [
|
||||
'label' => __( 'WPMind Switch Strategy', 'wpmind' ),
|
||||
'description' => __( 'Switch active routing strategy.', 'wpmind' ),
|
||||
'category' => self::ABILITY_CATEGORY,
|
||||
'input_schema' => [
|
||||
'type' => 'object',
|
||||
'required' => [ 'strategy' ],
|
||||
'properties' => [
|
||||
'strategy' => [
|
||||
'type' => 'string',
|
||||
'description' => __( 'Routing strategy slug.', 'wpmind' ),
|
||||
],
|
||||
],
|
||||
],
|
||||
'output_schema' => [
|
||||
'type' => 'object',
|
||||
'description' => __( 'Routing strategy switch result payload.', 'wpmind' ),
|
||||
],
|
||||
'permission_callback' => [ $this, 'can_manage_gateway' ],
|
||||
'execute_callback' => [ $this, 'execute_switch_strategy_ability' ],
|
||||
'meta' => [
|
||||
'annotations' => [
|
||||
'readonly' => false,
|
||||
'destructive' => false,
|
||||
'idempotent' => false,
|
||||
],
|
||||
'show_in_rest' => true,
|
||||
'mcp' => [
|
||||
'public' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Filter MCP ability definitions.
|
||||
*
|
||||
* @since 4.0.0
|
||||
* @param array<string,array> $definitions Ability definitions.
|
||||
*/
|
||||
return apply_filters( 'wpmind_mcp_gateway_ability_definitions', $definitions );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ability names that are already registered.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
private function get_registered_ability_names(): array {
|
||||
$names = array_keys( $this->get_ability_definitions() );
|
||||
|
||||
if ( ! function_exists( 'wp_has_ability' ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(
|
||||
array_filter(
|
||||
$names,
|
||||
static fn( string $name ): bool => wp_has_ability( $name )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize callback input payload.
|
||||
*
|
||||
* @param mixed $input Raw callback input.
|
||||
* @return array
|
||||
*/
|
||||
private function normalize_input( $input ): array {
|
||||
if ( is_array( $input ) ) {
|
||||
return $input;
|
||||
}
|
||||
|
||||
if ( is_object( $input ) ) {
|
||||
if ( method_exists( $input, 'get_params' ) ) {
|
||||
$params = $input->get_params();
|
||||
if ( is_array( $params ) ) {
|
||||
return $params;
|
||||
}
|
||||
}
|
||||
|
||||
if ( method_exists( $input, 'to_array' ) ) {
|
||||
$params = $input->to_array();
|
||||
if ( is_array( $params ) ) {
|
||||
return $params;
|
||||
}
|
||||
}
|
||||
|
||||
$vars = get_object_vars( $input );
|
||||
if ( is_array( $vars ) ) {
|
||||
return $vars;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a normalized error payload.
|
||||
*
|
||||
* @param string $code Error code.
|
||||
* @param string $message Error message.
|
||||
* @param array $data Extra data.
|
||||
* @return array
|
||||
*/
|
||||
private function error_result( string $code, string $message, array $data = [] ): array {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert WP_Error to MCP payload.
|
||||
*
|
||||
* @param WP_Error $error WP error instance.
|
||||
* @return array
|
||||
*/
|
||||
private function error_from_wp_error( WP_Error $error ): array {
|
||||
return $this->error_result(
|
||||
$error->get_error_code(),
|
||||
$error->get_error_message(),
|
||||
[
|
||||
'details' => $error->get_error_data(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
* @since 2.4.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Providers\Image;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
* @since 2.4.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Providers\Image;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
* @since 2.4.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Providers\Image;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
* @since 2.4.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Providers\Image;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
* @since 2.4.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Providers\Image;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
* @since 2.4.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Providers\Image;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
* @since 2.4.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Providers\Image;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
* @since 2.4.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Providers\Image;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
* @since 2.4.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Providers\Image;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
* @since 2.4.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Providers\Image;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
* @since 2.4.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Providers\Image;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
|
|
|||
|
|
@ -12,14 +12,6 @@ namespace WPMind\Providers;
|
|||
|
||||
use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication;
|
||||
use WordPress\AiClient\Providers\ProviderRegistry;
|
||||
use WPMind\Providers\DeepSeek\DeepSeekProvider;
|
||||
use WPMind\Providers\Qwen\QwenProvider;
|
||||
use WPMind\Providers\Zhipu\ZhipuProvider;
|
||||
use WPMind\Providers\Moonshot\MoonshotProvider;
|
||||
use WPMind\Providers\Doubao\DoubaoProvider;
|
||||
use WPMind\Providers\SiliconFlow\SiliconFlowProvider;
|
||||
use WPMind\Providers\Baidu\BaiduProvider;
|
||||
use WPMind\Providers\MiniMax\MiniMaxProvider;
|
||||
|
||||
/**
|
||||
* Provider 注册器
|
||||
|
|
@ -27,19 +19,27 @@ use WPMind\Providers\MiniMax\MiniMaxProvider;
|
|||
class ProviderRegistrar
|
||||
{
|
||||
private const PROVIDER_MAP = [
|
||||
'deepseek' => DeepSeekProvider::class,
|
||||
'qwen' => QwenProvider::class,
|
||||
'zhipu' => ZhipuProvider::class,
|
||||
'moonshot' => MoonshotProvider::class,
|
||||
'doubao' => DoubaoProvider::class,
|
||||
'siliconflow' => SiliconFlowProvider::class,
|
||||
'baidu' => BaiduProvider::class,
|
||||
'minimax' => MiniMaxProvider::class,
|
||||
'deepseek' => 'WPMind\\Providers\\DeepSeek\\DeepSeekProvider',
|
||||
'qwen' => 'WPMind\\Providers\\Qwen\\QwenProvider',
|
||||
'zhipu' => 'WPMind\\Providers\\Zhipu\\ZhipuProvider',
|
||||
'moonshot' => 'WPMind\\Providers\\Moonshot\\MoonshotProvider',
|
||||
'doubao' => 'WPMind\\Providers\\Doubao\\DoubaoProvider',
|
||||
'siliconflow' => 'WPMind\\Providers\\SiliconFlow\\SiliconFlowProvider',
|
||||
'baidu' => 'WPMind\\Providers\\Baidu\\BaiduProvider',
|
||||
'minimax' => 'WPMind\\Providers\\MiniMax\\MiniMaxProvider',
|
||||
];
|
||||
|
||||
public static function registerProviders(ProviderRegistry $registry, array $endpoints): void
|
||||
{
|
||||
foreach (self::PROVIDER_MAP as $key => $providerClass) {
|
||||
/**
|
||||
* 过滤 Provider 映射表,允许第三方注册自定义 Provider
|
||||
*
|
||||
* @since 3.7.0
|
||||
* @param array<string, string> $map Provider ID => FQCN 映射
|
||||
*/
|
||||
$map = apply_filters('wpmind_provider_map', self::PROVIDER_MAP);
|
||||
|
||||
foreach ($map as $key => $providerClass) {
|
||||
if (empty($endpoints[$key]['enabled']) || empty($endpoints[$key]['api_key'])) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -57,11 +57,13 @@ class ProviderRegistrar
|
|||
|
||||
public static function getSupportedProviderIds(): array
|
||||
{
|
||||
return array_keys(self::PROVIDER_MAP);
|
||||
$map = apply_filters('wpmind_provider_map', self::PROVIDER_MAP);
|
||||
return array_keys($map);
|
||||
}
|
||||
|
||||
public static function getProviderClass(string $providerId): ?string
|
||||
{
|
||||
return self::PROVIDER_MAP[$providerId] ?? null;
|
||||
$map = apply_filters('wpmind_provider_map', self::PROVIDER_MAP);
|
||||
return $map[$providerId] ?? null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,20 +74,6 @@ function register_wpmind_providers(): void {
|
|||
$registeredIds = $registry->getRegisteredProviderIds();
|
||||
debug_log( 'Registered provider IDs: ' . implode( ', ', $registeredIds ) );
|
||||
|
||||
// 调试:检查每个 WPMind Provider 的配置状态
|
||||
foreach ( $endpoints as $key => $endpoint ) {
|
||||
if ( ! empty( $endpoint['enabled'] ) && ! empty( $endpoint['api_key'] ) ) {
|
||||
$providerClass = ProviderRegistrar::getProviderClass( $key );
|
||||
if ( $providerClass && $registry->hasProvider( $providerClass ) ) {
|
||||
try {
|
||||
$isConfigured = $registry->isProviderConfigured( $providerClass );
|
||||
debug_log( "Provider $key ($providerClass) isConfigured: " . ( $isConfigured ? 'true' : 'false' ) );
|
||||
} catch ( \Exception $e ) {
|
||||
debug_log( "Provider $key check failed: " . $e->getMessage() );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 在 init 钩子以优先级 5 注册 Provider
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ abstract class AbstractStrategy implements RoutingStrategyInterface
|
|||
*
|
||||
* 默认实现:返回排名第一的 Provider
|
||||
*/
|
||||
public function selectProvider(RoutingContext $context, array $providers): ?string
|
||||
public function select_provider(RoutingContext $context, array $providers): ?string
|
||||
{
|
||||
$ranked = $this->rankProviders($context, $providers);
|
||||
$ranked = $this->rank_providers($context, $providers);
|
||||
return $ranked[0] ?? null;
|
||||
}
|
||||
|
||||
|
|
@ -30,12 +30,12 @@ abstract class AbstractStrategy implements RoutingStrategyInterface
|
|||
*
|
||||
* 默认实现:按得分降序排列
|
||||
*/
|
||||
public function rankProviders(RoutingContext $context, array $providers): array
|
||||
public function rank_providers(RoutingContext $context, array $providers): array
|
||||
{
|
||||
// 过滤掉被排除的 Provider
|
||||
$available = array_filter(
|
||||
array_keys($providers),
|
||||
fn($id) => !$context->isExcluded($id)
|
||||
fn($id) => !$context->is_excluded($id)
|
||||
);
|
||||
|
||||
if (empty($available)) {
|
||||
|
|
@ -45,7 +45,7 @@ abstract class AbstractStrategy implements RoutingStrategyInterface
|
|||
// 计算每个 Provider 的得分
|
||||
$scores = [];
|
||||
foreach ($available as $providerId) {
|
||||
$scores[$providerId] = $this->calculateScore($providerId, $context);
|
||||
$scores[$providerId] = $this->calculate_score($providerId, $context);
|
||||
}
|
||||
|
||||
// 按得分降序排序
|
||||
|
|
@ -61,11 +61,11 @@ abstract class AbstractStrategy implements RoutingStrategyInterface
|
|||
* @param array<string, array> $providers Provider 列表
|
||||
* @return array<string> 可用的 Provider ID 列表
|
||||
*/
|
||||
protected function filterAvailable(RoutingContext $context, array $providers): array
|
||||
protected function filter_available(RoutingContext $context, array $providers): array
|
||||
{
|
||||
return array_filter(
|
||||
array_keys($providers),
|
||||
fn($id) => !$context->isExcluded($id)
|
||||
fn($id) => !$context->is_excluded($id)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ abstract class AbstractStrategy implements RoutingStrategyInterface
|
|||
* @param bool $inverse 是否反转(值越小得分越高)
|
||||
* @return float 归一化后的得分
|
||||
*/
|
||||
protected function normalizeScore(float $value, float $min, float $max, bool $inverse = false): float
|
||||
protected function normalize_score(float $value, float $min, float $max, bool $inverse = false): float
|
||||
{
|
||||
if ($max <= $min) {
|
||||
return 50.0;
|
||||
|
|
|
|||
|
|
@ -44,29 +44,37 @@ class IntelligentRouter
|
|||
|
||||
private function __construct()
|
||||
{
|
||||
$this->registerDefaultStrategies();
|
||||
$this->loadProviders();
|
||||
$this->loadSettings();
|
||||
$this->register_default_strategies();
|
||||
$this->load_providers();
|
||||
$this->load_settings();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册默认策略
|
||||
*/
|
||||
private function registerDefaultStrategies(): void
|
||||
private function register_default_strategies(): void
|
||||
{
|
||||
// 复合策略(推荐)
|
||||
$this->registerStrategy(CompositeStrategy::createBalanced()); // 平衡策略
|
||||
$this->registerStrategy(CompositeStrategy::createPerformance()); // 性能优先
|
||||
$this->registerStrategy(CompositeStrategy::createEconomic()); // 经济策略
|
||||
$this->register_strategy(CompositeStrategy::create_balanced()); // 平衡策略
|
||||
$this->register_strategy(CompositeStrategy::create_performance()); // 性能优先
|
||||
$this->register_strategy(CompositeStrategy::create_economic()); // 经济策略
|
||||
|
||||
// 基础策略
|
||||
$this->registerStrategy(new LoadBalancedStrategy()); // 负载均衡
|
||||
$this->register_strategy(new LoadBalancedStrategy()); // 负载均衡
|
||||
|
||||
/**
|
||||
* 允许第三方注册自定义路由策略
|
||||
*
|
||||
* @since 3.7.0
|
||||
* @param IntelligentRouter $router 路由器实例
|
||||
*/
|
||||
do_action('wpmind_register_routing_strategies', $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 Provider 配置
|
||||
*/
|
||||
private function loadProviders(): void
|
||||
private function load_providers(): void
|
||||
{
|
||||
if (function_exists('WPMind\\wpmind')) {
|
||||
$endpoints = \WPMind\wpmind()->get_custom_endpoints();
|
||||
|
|
@ -81,7 +89,7 @@ class IntelligentRouter
|
|||
/**
|
||||
* 加载路由设置
|
||||
*/
|
||||
private function loadSettings(): void
|
||||
private function load_settings(): void
|
||||
{
|
||||
$settings = get_option('wpmind_routing_settings', array());
|
||||
$strategy = $settings['strategy'] ?? 'balanced';
|
||||
|
|
@ -104,9 +112,9 @@ class IntelligentRouter
|
|||
*
|
||||
* @param RoutingStrategyInterface $strategy 策略实例
|
||||
*/
|
||||
public function registerStrategy(RoutingStrategyInterface $strategy): void
|
||||
public function register_strategy(RoutingStrategyInterface $strategy): void
|
||||
{
|
||||
$this->strategies[$strategy->getName()] = $strategy;
|
||||
$this->strategies[$strategy->get_name()] = $strategy;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -114,14 +122,14 @@ class IntelligentRouter
|
|||
*
|
||||
* @return array<string, array{name: string, display_name: string, description: string}>
|
||||
*/
|
||||
public function getAvailableStrategies(): array
|
||||
public function get_available_strategies(): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($this->strategies as $name => $strategy) {
|
||||
$result[$name] = [
|
||||
'name' => $strategy->getName(),
|
||||
'display_name' => $strategy->getDisplayName(),
|
||||
'description' => $strategy->getDescription(),
|
||||
'name' => $strategy->get_name(),
|
||||
'display_name' => $strategy->get_display_name(),
|
||||
'description' => $strategy->get_description(),
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
|
|
@ -133,7 +141,7 @@ class IntelligentRouter
|
|||
* @param string $strategyName 策略名称
|
||||
* @return bool 是否设置成功
|
||||
*/
|
||||
public function setStrategy(string $strategyName): bool
|
||||
public function set_strategy(string $strategyName): bool
|
||||
{
|
||||
if (!isset($this->strategies[$strategyName])) {
|
||||
return false;
|
||||
|
|
@ -152,7 +160,7 @@ class IntelligentRouter
|
|||
/**
|
||||
* 获取当前策略名称
|
||||
*/
|
||||
public function getCurrentStrategy(): string
|
||||
public function get_current_strategy(): string
|
||||
{
|
||||
return $this->activeStrategy;
|
||||
}
|
||||
|
|
@ -160,7 +168,7 @@ class IntelligentRouter
|
|||
/**
|
||||
* 获取当前策略实例
|
||||
*/
|
||||
public function getStrategy(?string $name = null): ?RoutingStrategyInterface
|
||||
public function get_strategy(?string $name = null): ?RoutingStrategyInterface
|
||||
{
|
||||
$name = $name ?? $this->activeStrategy;
|
||||
|
||||
|
|
@ -186,7 +194,7 @@ class IntelligentRouter
|
|||
public function route(?RoutingContext $context = null): ?string
|
||||
{
|
||||
$context = $context ?? RoutingContext::create();
|
||||
$strategy = $this->getStrategy();
|
||||
$strategy = $this->get_strategy();
|
||||
|
||||
if ($strategy === null) {
|
||||
// 无策略时,返回第一个可用的 Provider
|
||||
|
|
@ -194,14 +202,14 @@ class IntelligentRouter
|
|||
}
|
||||
|
||||
// 如果有首选 Provider 且可用,优先使用
|
||||
$preferred = $context->getPreferredProvider();
|
||||
$preferred = $context->get_preferred_provider();
|
||||
if ($preferred !== null && isset($this->providers[$preferred])) {
|
||||
if (!$context->isExcluded($preferred)) {
|
||||
if (!$context->is_excluded($preferred)) {
|
||||
return $preferred;
|
||||
}
|
||||
}
|
||||
|
||||
return $strategy->selectProvider($context, $this->providers);
|
||||
return $strategy->select_provider($context, $this->providers);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -212,19 +220,19 @@ class IntelligentRouter
|
|||
* @param RoutingContext|null $context 路由上下文
|
||||
* @return array<string> Provider ID 列表
|
||||
*/
|
||||
public function getFailoverChain(?RoutingContext $context = null): array
|
||||
public function get_failover_chain(?RoutingContext $context = null): array
|
||||
{
|
||||
$context = $context ?? RoutingContext::create();
|
||||
$strategy = $this->getStrategy();
|
||||
$strategy = $this->get_strategy();
|
||||
|
||||
if ($strategy === null) {
|
||||
return array_keys($this->providers);
|
||||
}
|
||||
|
||||
$ranked = $strategy->rankProviders($context, $this->providers);
|
||||
$ranked = $strategy->rank_providers($context, $this->providers);
|
||||
|
||||
// 如果有首选 Provider,放在最前面
|
||||
$preferred = $context->getPreferredProvider();
|
||||
$preferred = $context->get_preferred_provider();
|
||||
if ($preferred !== null && in_array($preferred, $ranked, true)) {
|
||||
$ranked = array_values(array_diff($ranked, [$preferred]));
|
||||
array_unshift($ranked, $preferred);
|
||||
|
|
@ -239,10 +247,10 @@ class IntelligentRouter
|
|||
* @param RoutingContext|null $context 路由上下文
|
||||
* @return array<string, array{score: float, rank: int}>
|
||||
*/
|
||||
public function getProviderScores(?RoutingContext $context = null): array
|
||||
public function get_provider_scores(?RoutingContext $context = null): array
|
||||
{
|
||||
$context = $context ?? RoutingContext::create();
|
||||
$strategy = $this->getStrategy();
|
||||
$strategy = $this->get_strategy();
|
||||
|
||||
if ($strategy === null) {
|
||||
return [];
|
||||
|
|
@ -251,7 +259,7 @@ class IntelligentRouter
|
|||
$scores = [];
|
||||
foreach ($this->providers as $providerId => $config) {
|
||||
$scores[$providerId] = [
|
||||
'score' => $strategy->calculateScore($providerId, $context),
|
||||
'score' => $strategy->calculate_score($providerId, $context),
|
||||
'name' => $config['display_name'] ?? $providerId,
|
||||
];
|
||||
}
|
||||
|
|
@ -272,22 +280,22 @@ class IntelligentRouter
|
|||
*
|
||||
* @return array 状态信息
|
||||
*/
|
||||
public function getStatusSummary(): array
|
||||
public function get_status_summary(): array
|
||||
{
|
||||
$context = RoutingContext::create();
|
||||
$strategy = $this->getStrategy();
|
||||
$strategy = $this->get_strategy();
|
||||
|
||||
return [
|
||||
'active_strategy' => [
|
||||
'name' => $this->activeStrategy,
|
||||
'display_name' => $strategy?->getDisplayName() ?? '未知',
|
||||
'description' => $strategy?->getDescription() ?? '',
|
||||
'display_name' => $strategy?->get_display_name() ?? '未知',
|
||||
'description' => $strategy?->get_description() ?? '',
|
||||
],
|
||||
'available_strategies' => $this->getAvailableStrategies(),
|
||||
'available_strategies' => $this->get_available_strategies(),
|
||||
'provider_count' => count($this->providers),
|
||||
'provider_scores' => $this->getProviderScores($context),
|
||||
'provider_scores' => $this->get_provider_scores($context),
|
||||
'recommended' => $this->route($context),
|
||||
'failover_chain' => $this->getFailoverChain($context),
|
||||
'failover_chain' => $this->get_failover_chain($context),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -299,4 +307,57 @@ class IntelligentRouter
|
|||
self::$instance = null;
|
||||
self::instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取手动设置的 Provider 优先级
|
||||
*
|
||||
* @return array<string> Provider ID 列表,按优先级排序
|
||||
*/
|
||||
public function get_manual_priority(): array
|
||||
{
|
||||
$settings = get_option('wpmind_routing_settings', []);
|
||||
return $settings['provider_priority'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置手动 Provider 优先级
|
||||
*
|
||||
* @param array<string> $priority Provider ID 列表,按优先级排序
|
||||
* @return bool 是否设置成功
|
||||
*/
|
||||
public function set_manual_priority(array $priority): bool
|
||||
{
|
||||
// 验证所有 Provider ID 都有效
|
||||
$valid_providers = array_keys($this->providers);
|
||||
$priority = array_filter($priority, fn($id) => in_array($id, $valid_providers, true));
|
||||
|
||||
$settings = get_option('wpmind_routing_settings', []);
|
||||
$settings['provider_priority'] = array_values($priority);
|
||||
|
||||
return update_option('wpmind_routing_settings', $settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除手动优先级设置
|
||||
*
|
||||
* @return bool 是否清除成功
|
||||
*/
|
||||
public function clear_manual_priority(): bool
|
||||
{
|
||||
$settings = get_option('wpmind_routing_settings', []);
|
||||
unset($settings['provider_priority']);
|
||||
|
||||
return update_option('wpmind_routing_settings', $settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否启用了手动优先级
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function has_manual_priority(): bool
|
||||
{
|
||||
$priority = $this->get_manual_priority();
|
||||
return !empty($priority);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ declare(strict_types=1);
|
|||
namespace WPMind\Routing;
|
||||
|
||||
use WPMind\Failover\ProviderHealthTracker;
|
||||
use WPMind\Usage\UsageTracker;
|
||||
use WPMind\Modules\CostControl\UsageTracker;
|
||||
|
||||
class RoutingContext
|
||||
{
|
||||
|
|
@ -52,7 +52,7 @@ class RoutingContext
|
|||
/**
|
||||
* 设置模型类型
|
||||
*/
|
||||
public function withModelType(string $type): self
|
||||
public function with_model_type(string $type): self
|
||||
{
|
||||
$this->modelType = $type;
|
||||
return $this;
|
||||
|
|
@ -61,7 +61,7 @@ class RoutingContext
|
|||
/**
|
||||
* 设置预估 token 数
|
||||
*/
|
||||
public function withEstimatedTokens(int $input, int $output = 0): self
|
||||
public function with_estimated_tokens(int $input, int $output = 0): self
|
||||
{
|
||||
$this->estimatedInputTokens = max(0, $input);
|
||||
$this->estimatedOutputTokens = max(0, $output);
|
||||
|
|
@ -71,7 +71,7 @@ class RoutingContext
|
|||
/**
|
||||
* 设置首选 Provider
|
||||
*/
|
||||
public function withPreferredProvider(?string $providerId): self
|
||||
public function with_preferred_provider(?string $providerId): self
|
||||
{
|
||||
$this->preferredProvider = $providerId;
|
||||
return $this;
|
||||
|
|
@ -80,7 +80,7 @@ class RoutingContext
|
|||
/**
|
||||
* 添加排除的 Provider
|
||||
*/
|
||||
public function withExcludedProvider(string $providerId): self
|
||||
public function with_excluded_provider(string $providerId): self
|
||||
{
|
||||
if (!in_array($providerId, $this->excludedProviders, true)) {
|
||||
$this->excludedProviders[] = $providerId;
|
||||
|
|
@ -91,7 +91,7 @@ class RoutingContext
|
|||
/**
|
||||
* 设置排除的 Provider 列表
|
||||
*/
|
||||
public function withExcludedProviders(array $providerIds): self
|
||||
public function with_excluded_providers(array $providerIds): self
|
||||
{
|
||||
$this->excludedProviders = array_values(array_unique($providerIds));
|
||||
return $this;
|
||||
|
|
@ -100,7 +100,7 @@ class RoutingContext
|
|||
/**
|
||||
* 添加元数据
|
||||
*/
|
||||
public function withMetadata(string $key, mixed $value): self
|
||||
public function with_metadata(string $key, mixed $value): self
|
||||
{
|
||||
$this->metadata[$key] = $value;
|
||||
return $this;
|
||||
|
|
@ -108,47 +108,47 @@ class RoutingContext
|
|||
|
||||
// Getters
|
||||
|
||||
public function getModelType(): ?string
|
||||
public function get_model_type(): ?string
|
||||
{
|
||||
return $this->modelType;
|
||||
}
|
||||
|
||||
public function getEstimatedInputTokens(): int
|
||||
public function get_estimated_input_tokens(): int
|
||||
{
|
||||
return $this->estimatedInputTokens;
|
||||
}
|
||||
|
||||
public function getEstimatedOutputTokens(): int
|
||||
public function get_estimated_output_tokens(): int
|
||||
{
|
||||
return $this->estimatedOutputTokens;
|
||||
}
|
||||
|
||||
public function getEstimatedTotalTokens(): int
|
||||
public function get_estimated_total_tokens(): int
|
||||
{
|
||||
return $this->estimatedInputTokens + $this->estimatedOutputTokens;
|
||||
}
|
||||
|
||||
public function getPreferredProvider(): ?string
|
||||
public function get_preferred_provider(): ?string
|
||||
{
|
||||
return $this->preferredProvider;
|
||||
}
|
||||
|
||||
public function getExcludedProviders(): array
|
||||
public function get_excluded_providers(): array
|
||||
{
|
||||
return $this->excludedProviders;
|
||||
}
|
||||
|
||||
public function isExcluded(string $providerId): bool
|
||||
public function is_excluded(string $providerId): bool
|
||||
{
|
||||
return in_array($providerId, $this->excludedProviders, true);
|
||||
}
|
||||
|
||||
public function getMetadata(string $key, mixed $default = null): mixed
|
||||
public function get_metadata(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->metadata[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function getAllMetadata(): array
|
||||
public function get_all_metadata(): array
|
||||
{
|
||||
return $this->metadata;
|
||||
}
|
||||
|
|
@ -156,10 +156,10 @@ class RoutingContext
|
|||
/**
|
||||
* 获取 Provider 健康数据(带缓存)
|
||||
*/
|
||||
public function getHealthData(): array
|
||||
public function get_health_data(): array
|
||||
{
|
||||
if ($this->healthData === null) {
|
||||
$this->healthData = ProviderHealthTracker::getAllHealth();
|
||||
$this->healthData = ProviderHealthTracker::get_all_health();
|
||||
}
|
||||
return $this->healthData;
|
||||
}
|
||||
|
|
@ -167,9 +167,9 @@ class RoutingContext
|
|||
/**
|
||||
* 获取指定 Provider 的健康分数(使用缓存)
|
||||
*/
|
||||
public function getHealthScore(string $providerId): int
|
||||
public function get_health_score(string $providerId): int
|
||||
{
|
||||
$healthData = $this->getHealthData();
|
||||
$healthData = $this->get_health_data();
|
||||
if (!isset($healthData[$providerId]) || empty($healthData[$providerId]['history'])) {
|
||||
return 100;
|
||||
}
|
||||
|
|
@ -181,19 +181,19 @@ class RoutingContext
|
|||
/**
|
||||
* 获取指定 Provider 的平均延迟(使用缓存)
|
||||
*/
|
||||
public function getAverageLatency(string $providerId): int
|
||||
public function get_average_latency(string $providerId): int
|
||||
{
|
||||
$healthData = $this->getHealthData();
|
||||
$healthData = $this->get_health_data();
|
||||
return $healthData[$providerId]['avg_latency'] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取使用统计(带缓存)
|
||||
*/
|
||||
public function getUsageStats(): array
|
||||
public function get_usage_stats(): array
|
||||
{
|
||||
if ($this->usageStats === null) {
|
||||
$this->usageStats = UsageTracker::getStats();
|
||||
$this->usageStats = UsageTracker::get_stats();
|
||||
}
|
||||
return $this->usageStats;
|
||||
}
|
||||
|
|
@ -201,9 +201,9 @@ class RoutingContext
|
|||
/**
|
||||
* 获取指定 Provider 的使用统计
|
||||
*/
|
||||
public function getProviderUsageStats(string $providerId): array
|
||||
public function get_provider_usage_stats(string $providerId): array
|
||||
{
|
||||
$stats = $this->getUsageStats();
|
||||
$stats = $this->get_usage_stats();
|
||||
return $stats['providers'][$providerId] ?? [
|
||||
'total_input_tokens' => 0,
|
||||
'total_output_tokens' => 0,
|
||||
|
|
@ -215,9 +215,9 @@ class RoutingContext
|
|||
/**
|
||||
* 计算指定 Provider 的预估成本
|
||||
*/
|
||||
public function estimateCost(string $providerId, string $model = 'default'): float
|
||||
public function estimate_cost(string $providerId, string $model = 'default'): float
|
||||
{
|
||||
return UsageTracker::calculateCost(
|
||||
return UsageTracker::calculate_cost(
|
||||
$providerId,
|
||||
$model,
|
||||
$this->estimatedInputTokens,
|
||||
|
|
|
|||
218
includes/Routing/RoutingHooks.php
Normal file
218
includes/Routing/RoutingHooks.php
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<?php
|
||||
/**
|
||||
* Routing Hooks - 路由钩子集成
|
||||
*
|
||||
* 将 IntelligentRouter 集成到 WordPress 过滤器系统
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 3.2.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Routing;
|
||||
|
||||
/**
|
||||
* 路由钩子类
|
||||
*
|
||||
* 负责将智能路由器连接到 wpmind_select_provider 过滤器
|
||||
*/
|
||||
class RoutingHooks
|
||||
{
|
||||
private static ?RoutingHooks $instance = null;
|
||||
|
||||
/** @var bool 是否启用智能路由 */
|
||||
private bool $enabled = true;
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
public static function instance(): RoutingHooks
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
$this->load_settings();
|
||||
$this->register_hooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载设置
|
||||
*/
|
||||
private function load_settings(): void
|
||||
{
|
||||
$settings = get_option('wpmind_routing_settings', []);
|
||||
$this->enabled = $settings['enabled'] ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册钩子
|
||||
*/
|
||||
private function register_hooks(): void
|
||||
{
|
||||
// 只有启用时才注册过滤器
|
||||
if ($this->enabled) {
|
||||
// 优先级 10,允许其他插件在之前或之后修改
|
||||
add_filter('wpmind_select_provider', [$this, 'select_provider'], 10, 2);
|
||||
}
|
||||
|
||||
// 注册设置变更钩子
|
||||
add_action('update_option_wpmind_routing_settings', [$this, 'on_settings_update'], 10, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择 Provider 过滤器回调
|
||||
*
|
||||
* @param string $provider 当前选择的 Provider
|
||||
* @param string $context 请求上下文标识
|
||||
* @return string 选择的 Provider ID
|
||||
*/
|
||||
public function select_provider(string $provider, string $context): string
|
||||
{
|
||||
// 如果明确指定了 Provider(非 auto),尊重用户选择
|
||||
if ($provider !== 'auto' && !empty($provider)) {
|
||||
// 但仍然检查该 Provider 是否可用
|
||||
$router = IntelligentRouter::instance();
|
||||
$routingContext = $this->build_routing_context($context, $provider);
|
||||
|
||||
// 如果首选 Provider 可用,直接返回
|
||||
$selected = $router->route($routingContext);
|
||||
if ($selected === $provider) {
|
||||
return $provider;
|
||||
}
|
||||
|
||||
// 首选不可用时,记录日志并使用路由结果
|
||||
do_action('wpmind_routing_fallback', $provider, $selected, $context);
|
||||
return $selected ?? $provider;
|
||||
}
|
||||
|
||||
// auto 模式:使用智能路由
|
||||
$router = IntelligentRouter::instance();
|
||||
$routingContext = $this->build_routing_context($context);
|
||||
|
||||
$selected = $router->route($routingContext);
|
||||
|
||||
if ($selected !== null) {
|
||||
// 记录路由决策
|
||||
do_action('wpmind_routing_decision', $selected, $router->get_current_strategy(), $context);
|
||||
return $selected;
|
||||
}
|
||||
|
||||
// 路由失败,返回默认 Provider
|
||||
return get_option('wpmind_default_provider', 'deepseek');
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建路由上下文
|
||||
*
|
||||
* @param string $context 请求上下文标识
|
||||
* @param string|null $preferredProvider 首选 Provider
|
||||
* @return RoutingContext
|
||||
*/
|
||||
private function build_routing_context(string $context, ?string $preferredProvider = null): RoutingContext
|
||||
{
|
||||
$routingContext = RoutingContext::create();
|
||||
|
||||
// 设置首选 Provider
|
||||
if ($preferredProvider !== null && $preferredProvider !== 'auto') {
|
||||
$routingContext->with_preferred_provider($preferredProvider);
|
||||
}
|
||||
|
||||
// 根据上下文设置模型类型
|
||||
$modelType = $this->infer_model_type($context);
|
||||
if ($modelType !== null) {
|
||||
$routingContext->with_model_type($modelType);
|
||||
}
|
||||
|
||||
// 添加上下文元数据
|
||||
$routingContext->with_metadata('context', $context);
|
||||
$routingContext->with_metadata('timestamp', time());
|
||||
|
||||
return $routingContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从上下文推断模型类型
|
||||
*
|
||||
* @param string $context 上下文标识
|
||||
* @return string|null
|
||||
*/
|
||||
private function infer_model_type(string $context): ?string
|
||||
{
|
||||
// 根据上下文关键词推断模型类型
|
||||
$contextLower = strtolower($context);
|
||||
|
||||
if (str_contains($contextLower, 'embed') || str_contains($contextLower, 'vector')) {
|
||||
return 'embedding';
|
||||
}
|
||||
|
||||
if (str_contains($contextLower, 'image') || str_contains($contextLower, 'vision')) {
|
||||
return 'vision';
|
||||
}
|
||||
|
||||
if (str_contains($contextLower, 'code') || str_contains($contextLower, 'completion')) {
|
||||
return 'completion';
|
||||
}
|
||||
|
||||
// 默认为 chat
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置更新回调
|
||||
*
|
||||
* @param mixed $old_value 旧值
|
||||
* @param mixed $new_value 新值
|
||||
*/
|
||||
public function on_settings_update($old_value, $new_value): void
|
||||
{
|
||||
// 刷新路由器
|
||||
IntelligentRouter::instance()->refresh();
|
||||
|
||||
// 更新启用状态
|
||||
$this->enabled = $new_value['enabled'] ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查智能路由是否启用
|
||||
*/
|
||||
public function is_enabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用智能路由
|
||||
*/
|
||||
public function enable(): void
|
||||
{
|
||||
$this->enabled = true;
|
||||
$settings = get_option('wpmind_routing_settings', []);
|
||||
$settings['enabled'] = true;
|
||||
update_option('wpmind_routing_settings', $settings);
|
||||
|
||||
// 重新注册过滤器
|
||||
if (!has_filter('wpmind_select_provider', [$this, 'select_provider'])) {
|
||||
add_filter('wpmind_select_provider', [$this, 'select_provider'], 10, 2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用智能路由
|
||||
*/
|
||||
public function disable(): void
|
||||
{
|
||||
$this->enabled = false;
|
||||
$settings = get_option('wpmind_routing_settings', []);
|
||||
$settings['enabled'] = false;
|
||||
update_option('wpmind_routing_settings', $settings);
|
||||
|
||||
// 移除过滤器
|
||||
remove_filter('wpmind_select_provider', [$this, 'select_provider'], 10);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,21 +19,21 @@ interface RoutingStrategyInterface
|
|||
*
|
||||
* @return string 策略标识符
|
||||
*/
|
||||
public function getName(): string;
|
||||
public function get_name(): string;
|
||||
|
||||
/**
|
||||
* 获取策略显示名称
|
||||
*
|
||||
* @return string 用于 UI 显示的名称
|
||||
*/
|
||||
public function getDisplayName(): string;
|
||||
public function get_display_name(): string;
|
||||
|
||||
/**
|
||||
* 获取策略描述
|
||||
*
|
||||
* @return string 策略的详细描述
|
||||
*/
|
||||
public function getDescription(): string;
|
||||
public function get_description(): string;
|
||||
|
||||
/**
|
||||
* 选择最佳 Provider
|
||||
|
|
@ -42,7 +42,7 @@ interface RoutingStrategyInterface
|
|||
* @param array<string, array> $providers 可用的 Provider 列表
|
||||
* @return string|null 选中的 Provider ID,无可用时返回 null
|
||||
*/
|
||||
public function selectProvider(RoutingContext $context, array $providers): ?string;
|
||||
public function select_provider(RoutingContext $context, array $providers): ?string;
|
||||
|
||||
/**
|
||||
* 对 Provider 列表进行排序
|
||||
|
|
@ -51,7 +51,7 @@ interface RoutingStrategyInterface
|
|||
* @param array<string, array> $providers 可用的 Provider 列表
|
||||
* @return array<string> 排序后的 Provider ID 列表
|
||||
*/
|
||||
public function rankProviders(RoutingContext $context, array $providers): array;
|
||||
public function rank_providers(RoutingContext $context, array $providers): array;
|
||||
|
||||
/**
|
||||
* 计算 Provider 的得分
|
||||
|
|
@ -60,5 +60,5 @@ interface RoutingStrategyInterface
|
|||
* @param RoutingContext $context 路由上下文
|
||||
* @return float 得分 (0-100)
|
||||
*/
|
||||
public function calculateScore(string $providerId, RoutingContext $context): float;
|
||||
public function calculate_score(string $providerId, RoutingContext $context): float;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,17 +17,17 @@ use WPMind\Routing\RoutingContext;
|
|||
|
||||
class AvailabilityStrategy extends AbstractStrategy
|
||||
{
|
||||
public function getName(): string
|
||||
public function get_name(): string
|
||||
{
|
||||
return 'availability';
|
||||
}
|
||||
|
||||
public function getDisplayName(): string
|
||||
public function get_display_name(): string
|
||||
{
|
||||
return '可用性优先';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
public function get_description(): string
|
||||
{
|
||||
return '选择健康分数最高的 Provider,适合对稳定性要求高的场景';
|
||||
}
|
||||
|
|
@ -37,10 +37,10 @@ class AvailabilityStrategy extends AbstractStrategy
|
|||
*
|
||||
* 直接使用健康分数
|
||||
*/
|
||||
public function calculateScore(string $providerId, RoutingContext $context): float
|
||||
public function calculate_score(string $providerId, RoutingContext $context): float
|
||||
{
|
||||
$healthScore = $context->getHealthScore($providerId);
|
||||
$latency = $context->getAverageLatency($providerId);
|
||||
$healthScore = $context->get_health_score($providerId);
|
||||
$latency = $context->get_average_latency($providerId);
|
||||
|
||||
// 延迟作为次要因素(延迟越低加分越多)
|
||||
$latencyBonus = 0;
|
||||
|
|
@ -56,9 +56,9 @@ class AvailabilityStrategy extends AbstractStrategy
|
|||
*
|
||||
* 按健康分数降序排列
|
||||
*/
|
||||
public function rankProviders(RoutingContext $context, array $providers): array
|
||||
public function rank_providers(RoutingContext $context, array $providers): array
|
||||
{
|
||||
$available = $this->filterAvailable($context, $providers);
|
||||
$available = $this->filter_available($context, $providers);
|
||||
|
||||
if (empty($available)) {
|
||||
return [];
|
||||
|
|
@ -68,8 +68,8 @@ class AvailabilityStrategy extends AbstractStrategy
|
|||
$providerData = [];
|
||||
foreach ($available as $providerId) {
|
||||
$providerData[$providerId] = [
|
||||
'health' => $context->getHealthScore($providerId),
|
||||
'latency' => $context->getAverageLatency($providerId),
|
||||
'health' => $context->get_health_score($providerId),
|
||||
'latency' => $context->get_average_latency($providerId),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ class CompositeStrategy extends AbstractStrategy
|
|||
* @param float $weight 权重 (0-1)
|
||||
* @return self
|
||||
*/
|
||||
public function addStrategy(RoutingStrategyInterface $strategy, float $weight): self
|
||||
public function add_strategy(RoutingStrategyInterface $strategy, float $weight): self
|
||||
{
|
||||
$this->strategies[] = [
|
||||
'strategy' => $strategy,
|
||||
|
|
@ -61,17 +61,17 @@ class CompositeStrategy extends AbstractStrategy
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
public function get_name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getDisplayName(): string
|
||||
public function get_display_name(): string
|
||||
{
|
||||
return $this->displayName;
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
public function get_description(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ class CompositeStrategy extends AbstractStrategy
|
|||
*
|
||||
* 按权重汇总各子策略的得分
|
||||
*/
|
||||
public function calculateScore(string $providerId, RoutingContext $context): float
|
||||
public function calculate_score(string $providerId, RoutingContext $context): float
|
||||
{
|
||||
if (empty($this->strategies)) {
|
||||
return 50.0;
|
||||
|
|
@ -94,7 +94,7 @@ class CompositeStrategy extends AbstractStrategy
|
|||
|
||||
$weightedScore = 0;
|
||||
foreach ($this->strategies as $item) {
|
||||
$score = $item['strategy']->calculateScore($providerId, $context);
|
||||
$score = $item['strategy']->calculate_score($providerId, $context);
|
||||
$normalizedWeight = $item['weight'] / $totalWeight;
|
||||
$weightedScore += $score * $normalizedWeight;
|
||||
}
|
||||
|
|
@ -107,11 +107,11 @@ class CompositeStrategy extends AbstractStrategy
|
|||
*
|
||||
* @return array<array{name: string, weight: float}>
|
||||
*/
|
||||
public function getStrategies(): array
|
||||
public function get_strategies(): array
|
||||
{
|
||||
return array_map(fn($item) => [
|
||||
'name' => $item['strategy']->getName(),
|
||||
'display_name' => $item['strategy']->getDisplayName(),
|
||||
'name' => $item['strategy']->get_name(),
|
||||
'display_name' => $item['strategy']->get_display_name(),
|
||||
'weight' => $item['weight'],
|
||||
], $this->strategies);
|
||||
}
|
||||
|
|
@ -121,12 +121,12 @@ class CompositeStrategy extends AbstractStrategy
|
|||
*
|
||||
* 成本、延迟、可用性各占 1/3
|
||||
*/
|
||||
public static function createBalanced(): self
|
||||
public static function create_balanced(): self
|
||||
{
|
||||
return (new self('balanced', '平衡策略', '平衡考虑成本、延迟和可用性'))
|
||||
->addStrategy(new CostStrategy(), 0.33)
|
||||
->addStrategy(new LatencyStrategy(), 0.33)
|
||||
->addStrategy(new AvailabilityStrategy(), 0.34);
|
||||
->add_strategy(new CostStrategy(), 0.33)
|
||||
->add_strategy(new LatencyStrategy(), 0.33)
|
||||
->add_strategy(new AvailabilityStrategy(), 0.34);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -134,12 +134,12 @@ class CompositeStrategy extends AbstractStrategy
|
|||
*
|
||||
* 延迟 50%,可用性 30%,成本 20%
|
||||
*/
|
||||
public static function createPerformance(): self
|
||||
public static function create_performance(): self
|
||||
{
|
||||
return (new self('performance', '性能优先', '优先考虑响应速度和稳定性'))
|
||||
->addStrategy(new LatencyStrategy(), 0.50)
|
||||
->addStrategy(new AvailabilityStrategy(), 0.30)
|
||||
->addStrategy(new CostStrategy(), 0.20);
|
||||
->add_strategy(new LatencyStrategy(), 0.50)
|
||||
->add_strategy(new AvailabilityStrategy(), 0.30)
|
||||
->add_strategy(new CostStrategy(), 0.20);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -147,11 +147,11 @@ class CompositeStrategy extends AbstractStrategy
|
|||
*
|
||||
* 成本 60%,可用性 30%,延迟 10%
|
||||
*/
|
||||
public static function createEconomic(): self
|
||||
public static function create_economic(): self
|
||||
{
|
||||
return (new self('economic', '经济策略', '优先考虑成本,兼顾稳定性'))
|
||||
->addStrategy(new CostStrategy(), 0.60)
|
||||
->addStrategy(new AvailabilityStrategy(), 0.30)
|
||||
->addStrategy(new LatencyStrategy(), 0.10);
|
||||
->add_strategy(new CostStrategy(), 0.60)
|
||||
->add_strategy(new AvailabilityStrategy(), 0.30)
|
||||
->add_strategy(new LatencyStrategy(), 0.10);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,21 +14,21 @@ namespace WPMind\Routing\Strategies;
|
|||
|
||||
use WPMind\Routing\AbstractStrategy;
|
||||
use WPMind\Routing\RoutingContext;
|
||||
use WPMind\Usage\UsageTracker;
|
||||
use WPMind\Modules\CostControl\UsageTracker;
|
||||
|
||||
class CostStrategy extends AbstractStrategy
|
||||
{
|
||||
public function getName(): string
|
||||
public function get_name(): string
|
||||
{
|
||||
return 'cost';
|
||||
}
|
||||
|
||||
public function getDisplayName(): string
|
||||
public function get_display_name(): string
|
||||
{
|
||||
return '成本优先';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
public function get_description(): string
|
||||
{
|
||||
return '选择成本最低的 Provider,适合预算敏感的场景';
|
||||
}
|
||||
|
|
@ -38,13 +38,13 @@ class CostStrategy extends AbstractStrategy
|
|||
*
|
||||
* 成本越低,得分越高
|
||||
*/
|
||||
public function calculateScore(string $providerId, RoutingContext $context): float
|
||||
public function calculate_score(string $providerId, RoutingContext $context): float
|
||||
{
|
||||
// 获取预估成本
|
||||
$estimatedCost = $context->estimateCost($providerId);
|
||||
$estimatedCost = $context->estimate_cost($providerId);
|
||||
|
||||
// 获取健康分数作为权重因子
|
||||
$healthScore = $context->getHealthScore($providerId);
|
||||
$healthScore = $context->get_health_score($providerId);
|
||||
|
||||
// 如果健康分数太低,大幅降低得分
|
||||
if ($healthScore < 50) {
|
||||
|
|
@ -53,7 +53,7 @@ class CostStrategy extends AbstractStrategy
|
|||
|
||||
// 成本归一化(假设最大成本为 $1 per request)
|
||||
// 成本越低,得分越高
|
||||
$costScore = $this->normalizeScore($estimatedCost, 0, 1.0, true);
|
||||
$costScore = $this->normalize_score($estimatedCost, 0, 1.0, true);
|
||||
|
||||
// 综合得分:成本权重 80%,健康权重 20%
|
||||
return ($costScore * 0.8) + ($healthScore * 0.2);
|
||||
|
|
@ -64,9 +64,9 @@ class CostStrategy extends AbstractStrategy
|
|||
*
|
||||
* 按成本升序排列,同成本时按健康分数降序
|
||||
*/
|
||||
public function rankProviders(RoutingContext $context, array $providers): array
|
||||
public function rank_providers(RoutingContext $context, array $providers): array
|
||||
{
|
||||
$available = $this->filterAvailable($context, $providers);
|
||||
$available = $this->filter_available($context, $providers);
|
||||
|
||||
if (empty($available)) {
|
||||
return [];
|
||||
|
|
@ -76,8 +76,8 @@ class CostStrategy extends AbstractStrategy
|
|||
$providerData = [];
|
||||
foreach ($available as $providerId) {
|
||||
$providerData[$providerId] = [
|
||||
'cost' => $context->estimateCost($providerId),
|
||||
'health' => $context->getHealthScore($providerId),
|
||||
'cost' => $context->estimate_cost($providerId),
|
||||
'health' => $context->get_health_score($providerId),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,17 +20,17 @@ class LatencyStrategy extends AbstractStrategy
|
|||
/** @var int 最大可接受延迟(毫秒) */
|
||||
private const MAX_ACCEPTABLE_LATENCY = 10000;
|
||||
|
||||
public function getName(): string
|
||||
public function get_name(): string
|
||||
{
|
||||
return 'latency';
|
||||
}
|
||||
|
||||
public function getDisplayName(): string
|
||||
public function get_display_name(): string
|
||||
{
|
||||
return '延迟优先';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
public function get_description(): string
|
||||
{
|
||||
return '选择响应最快的 Provider,适合对实时性要求高的场景';
|
||||
}
|
||||
|
|
@ -40,10 +40,10 @@ class LatencyStrategy extends AbstractStrategy
|
|||
*
|
||||
* 延迟越低,得分越高
|
||||
*/
|
||||
public function calculateScore(string $providerId, RoutingContext $context): float
|
||||
public function calculate_score(string $providerId, RoutingContext $context): float
|
||||
{
|
||||
$latency = $context->getAverageLatency($providerId);
|
||||
$healthScore = $context->getHealthScore($providerId);
|
||||
$latency = $context->get_average_latency($providerId);
|
||||
$healthScore = $context->get_health_score($providerId);
|
||||
|
||||
// 如果健康分数太低,大幅降低得分
|
||||
if ($healthScore < 50) {
|
||||
|
|
@ -57,7 +57,7 @@ class LatencyStrategy extends AbstractStrategy
|
|||
|
||||
// 延迟归一化(0-10000ms 范围)
|
||||
// 延迟越低,得分越高
|
||||
$latencyScore = $this->normalizeScore(
|
||||
$latencyScore = $this->normalize_score(
|
||||
(float) $latency,
|
||||
0,
|
||||
self::MAX_ACCEPTABLE_LATENCY,
|
||||
|
|
@ -73,9 +73,9 @@ class LatencyStrategy extends AbstractStrategy
|
|||
*
|
||||
* 按延迟升序排列
|
||||
*/
|
||||
public function rankProviders(RoutingContext $context, array $providers): array
|
||||
public function rank_providers(RoutingContext $context, array $providers): array
|
||||
{
|
||||
$available = $this->filterAvailable($context, $providers);
|
||||
$available = $this->filter_available($context, $providers);
|
||||
|
||||
if (empty($available)) {
|
||||
return [];
|
||||
|
|
@ -84,10 +84,10 @@ class LatencyStrategy extends AbstractStrategy
|
|||
// 计算每个 Provider 的延迟和健康分数
|
||||
$providerData = [];
|
||||
foreach ($available as $providerId) {
|
||||
$latency = $context->getAverageLatency($providerId);
|
||||
$latency = $context->get_average_latency($providerId);
|
||||
$providerData[$providerId] = [
|
||||
'latency' => $latency ?: PHP_INT_MAX, // 无数据时排在最后
|
||||
'health' => $context->getHealthScore($providerId),
|
||||
'health' => $context->get_health_score($providerId),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,17 +33,17 @@ class LoadBalancedStrategy extends AbstractStrategy
|
|||
$this->weights = $weights;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
public function get_name(): string
|
||||
{
|
||||
return 'load_balanced';
|
||||
}
|
||||
|
||||
public function getDisplayName(): string
|
||||
public function get_display_name(): string
|
||||
{
|
||||
return '负载均衡';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
public function get_description(): string
|
||||
{
|
||||
return '在多个 Provider 之间分散请求,避免单点过载';
|
||||
}
|
||||
|
|
@ -53,9 +53,9 @@ class LoadBalancedStrategy extends AbstractStrategy
|
|||
*
|
||||
* 根据算法选择下一个 Provider
|
||||
*/
|
||||
public function selectProvider(RoutingContext $context, array $providers): ?string
|
||||
public function select_provider(RoutingContext $context, array $providers): ?string
|
||||
{
|
||||
$available = $this->filterAvailable($context, $providers);
|
||||
$available = $this->filter_available($context, $providers);
|
||||
|
||||
if (empty($available)) {
|
||||
return null;
|
||||
|
|
@ -64,7 +64,7 @@ class LoadBalancedStrategy extends AbstractStrategy
|
|||
// 过滤掉健康分数太低的 Provider
|
||||
$healthy = array_filter(
|
||||
$available,
|
||||
fn($id) => $context->getHealthScore($id) >= 50
|
||||
fn($id) => $context->get_health_score($id) >= 50
|
||||
);
|
||||
|
||||
// 如果没有健康的 Provider,使用所有可用的
|
||||
|
|
@ -73,7 +73,7 @@ class LoadBalancedStrategy extends AbstractStrategy
|
|||
}
|
||||
|
||||
return match ($this->algorithm) {
|
||||
'round_robin' => $this->roundRobin($healthy),
|
||||
'round_robin' => $this->round_robin($healthy),
|
||||
'random' => $this->random($healthy),
|
||||
default => $this->weighted($healthy, $context),
|
||||
};
|
||||
|
|
@ -84,13 +84,13 @@ class LoadBalancedStrategy extends AbstractStrategy
|
|||
*
|
||||
* 综合考虑权重、健康分数和使用量
|
||||
*/
|
||||
public function calculateScore(string $providerId, RoutingContext $context): float
|
||||
public function calculate_score(string $providerId, RoutingContext $context): float
|
||||
{
|
||||
$healthScore = $context->getHealthScore($providerId);
|
||||
$healthScore = $context->get_health_score($providerId);
|
||||
$weight = $this->weights[$providerId] ?? 1;
|
||||
|
||||
// 获取使用统计
|
||||
$usageStats = $context->getProviderUsageStats($providerId);
|
||||
$usageStats = $context->get_provider_usage_stats($providerId);
|
||||
$requestCount = $usageStats['request_count'] ?? 0;
|
||||
|
||||
// 使用量越少,得分越高(鼓励分散)
|
||||
|
|
@ -103,7 +103,7 @@ class LoadBalancedStrategy extends AbstractStrategy
|
|||
/**
|
||||
* 轮询算法
|
||||
*/
|
||||
private function roundRobin(array $providers): string
|
||||
private function round_robin(array $providers): string
|
||||
{
|
||||
$providers = array_values($providers);
|
||||
$index = (int) get_transient('wpmind_round_robin_index') ?: 0;
|
||||
|
|
@ -130,7 +130,7 @@ class LoadBalancedStrategy extends AbstractStrategy
|
|||
|
||||
foreach ($providers as $providerId) {
|
||||
$weight = $this->weights[$providerId] ?? 1;
|
||||
$healthScore = $context->getHealthScore($providerId);
|
||||
$healthScore = $context->get_health_score($providerId);
|
||||
|
||||
// 健康分数作为权重修正因子
|
||||
$effectiveWeight = $weight * ($healthScore / 100);
|
||||
|
|
|
|||
208
includes/SDK/SDKAdapter.php
Normal file
208
includes/SDK/SDKAdapter.php
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<?php
|
||||
/**
|
||||
* WP AI Client SDK 适配器
|
||||
*
|
||||
* 封装 SDK 调用,提供与 PublicAPI 兼容的接口。
|
||||
* SDK 使用异常而非 WP_Error,响应是 GenerativeAiResult 对象而非数组。
|
||||
* 本适配器负责两者之间的转换。
|
||||
*
|
||||
* @package WPMind\SDK
|
||||
* @since 3.6.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\SDK;
|
||||
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* SDK 适配器
|
||||
*
|
||||
* 将 WP AI Client SDK 的调用方式适配为 PublicAPI 兼容的数组格式。
|
||||
*
|
||||
* @since 3.6.0
|
||||
*/
|
||||
class SDKAdapter {
|
||||
|
||||
/**
|
||||
* SDK 内置 Provider 映射
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const BUILTIN_PROVIDERS = [
|
||||
'openai' => 'WordPress\\AiClient\\Providers\\ProviderImplementations\\OpenAi\\OpenAiProvider',
|
||||
'anthropic' => 'WordPress\\AiClient\\Providers\\ProviderImplementations\\Anthropic\\AnthropicProvider',
|
||||
'google' => 'WordPress\\AiClient\\Providers\\ProviderImplementations\\Google\\GoogleProvider',
|
||||
];
|
||||
|
||||
/**
|
||||
* AI 对话
|
||||
*
|
||||
* @param array $args 请求参数(messages, max_tokens, temperature, json_mode, tools, tool_choice)
|
||||
* @param string $provider 服务商标识
|
||||
* @param string $model 模型标识
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public function chat(array $args, string $provider, string $model): array|WP_Error {
|
||||
// 检查 SDK 可用性
|
||||
if (!class_exists('WordPress\\AiClient\\AiClient')) {
|
||||
return new WP_Error('wpmind_sdk_unavailable', __('WP AI Client SDK 不可用', 'wpmind'));
|
||||
}
|
||||
|
||||
// 解析 Provider 类名
|
||||
$provider_class = $this->resolve_provider_class($provider);
|
||||
|
||||
try {
|
||||
$registry = \WordPress\AiClient\AiClient::defaultRegistry();
|
||||
|
||||
// 获取模型实例
|
||||
$model_instance = null;
|
||||
if ($provider_class && $model !== 'auto' && $model !== 'default') {
|
||||
try {
|
||||
$model_instance = $registry->getProviderModel($provider_class, $model);
|
||||
} catch (\Exception $e) {
|
||||
// 模型不存在,尝试不指定模型
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 PromptBuilder
|
||||
$builder = \WordPress\AiClient\AiClient::prompt($args['messages']);
|
||||
|
||||
if ($model_instance) {
|
||||
$builder->usingModel($model_instance);
|
||||
} elseif ($provider_class) {
|
||||
$builder->usingProvider($provider_class);
|
||||
}
|
||||
|
||||
$builder->usingTemperature($args['temperature'] ?? 0.7);
|
||||
$builder->usingMaxTokens($args['max_tokens'] ?? 2000);
|
||||
|
||||
// 提取 System instruction
|
||||
$system_msg = null;
|
||||
foreach ($args['messages'] as $msg) {
|
||||
if (($msg['role'] ?? '') === 'system') {
|
||||
$system_msg = $msg['content'] ?? '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($system_msg) {
|
||||
$builder->usingSystemInstruction($system_msg);
|
||||
}
|
||||
|
||||
// JSON mode
|
||||
if (!empty($args['json_mode'])) {
|
||||
$builder->asJsonResponse();
|
||||
}
|
||||
|
||||
// 执行请求
|
||||
$result = $builder->generateTextResult();
|
||||
|
||||
// 提取 finish_reason
|
||||
$finish_reason = '';
|
||||
$candidates = $result->getCandidates();
|
||||
if (!empty($candidates)) {
|
||||
$fr = $candidates[0]->getFinishReason();
|
||||
if ($fr !== null) {
|
||||
$finish_reason = is_object($fr) && property_exists($fr, 'value') ? $fr->value : (string) $fr;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => $result->toText(),
|
||||
'provider' => $provider,
|
||||
'model' => $model,
|
||||
'usage' => $this->extract_token_usage($result),
|
||||
'finish_reason' => $finish_reason,
|
||||
];
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new WP_Error('wpmind_sdk_invalid_args', $e->getMessage());
|
||||
} catch (\RuntimeException $e) {
|
||||
return $this->convert_exception_to_wp_error($e);
|
||||
} catch (\Exception $e) {
|
||||
return $this->convert_exception_to_wp_error($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全提取 token 用量
|
||||
*
|
||||
* @param object $result SDK 结果对象
|
||||
* @return array
|
||||
*/
|
||||
private function extract_token_usage(object $result): array {
|
||||
try {
|
||||
$usage = $result->getTokenUsage();
|
||||
if ($usage === null) {
|
||||
return ['prompt_tokens' => 0, 'completion_tokens' => 0, 'total_tokens' => 0];
|
||||
}
|
||||
return [
|
||||
'prompt_tokens' => $usage->getPromptTokens() ?? 0,
|
||||
'completion_tokens' => $usage->getCompletionTokens() ?? 0,
|
||||
'total_tokens' => $usage->getTotalTokens() ?? 0,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['prompt_tokens' => 0, 'completion_tokens' => 0, 'total_tokens' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Provider 类名
|
||||
*
|
||||
* 先检查 WPMind 注册的 Provider,再检查 SDK 内置 Provider。
|
||||
*
|
||||
* @param string $provider 服务商标识
|
||||
* @return string|null Provider 完整类名,未找到返回 null
|
||||
*/
|
||||
private function resolve_provider_class(string $provider): ?string {
|
||||
// 先检查 WPMind 注册的 Provider
|
||||
if (class_exists('WPMind\\Providers\\ProviderRegistrar')) {
|
||||
$class = \WPMind\Providers\ProviderRegistrar::getProviderClass($provider);
|
||||
if ($class) {
|
||||
return $class;
|
||||
}
|
||||
}
|
||||
|
||||
// 再检查 SDK 内置 Provider
|
||||
return self::BUILTIN_PROVIDERS[$provider] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将异常转换为 WP_Error
|
||||
*
|
||||
* 尝试从异常消息中提取 HTTP 状态码。
|
||||
*
|
||||
* @param \Exception $e 异常
|
||||
* @return WP_Error
|
||||
*/
|
||||
private function convert_exception_to_wp_error(\Exception $e): WP_Error {
|
||||
$message = $e->getMessage();
|
||||
$status = 0;
|
||||
|
||||
// 尝试从异常消息中提取 HTTP 状态码
|
||||
if (preg_match('/\b(4\d{2}|5\d{2})\b/', $message, $matches)) {
|
||||
$status = (int) $matches[1];
|
||||
}
|
||||
|
||||
$error_data = [];
|
||||
if ($status > 0) {
|
||||
$error_data['status'] = $status;
|
||||
}
|
||||
|
||||
// 仅在 debug 模式下记录完整异常信息
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log(sprintf('[WPMind SDK] Exception: %s', $message));
|
||||
}
|
||||
|
||||
// 对外返回通用描述,不暴露内部细节
|
||||
$user_message = $status > 0
|
||||
? sprintf(__('SDK 请求失败 (HTTP %d)', 'wpmind'), $status)
|
||||
: __('SDK 请求失败', 'wpmind');
|
||||
|
||||
return new WP_Error(
|
||||
'wpmind_sdk_error',
|
||||
$user_message,
|
||||
$error_data
|
||||
);
|
||||
}
|
||||
}
|
||||
133
includes/Usage/Pricing.php
Normal file
133
includes/Usage/Pricing.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
/**
|
||||
* AI Provider Pricing Data
|
||||
*
|
||||
* Centralized pricing constants shared between UsageTracker implementations.
|
||||
* Prices are per 1M tokens. Data source: provider official sites (2026-01).
|
||||
*
|
||||
* @package WPMind\Usage
|
||||
* @since 3.2.1
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Usage;
|
||||
|
||||
class Pricing
|
||||
{
|
||||
/**
|
||||
* Provider pricing data (per 1M tokens).
|
||||
*
|
||||
* currency: USD or CNY
|
||||
*/
|
||||
public const DATA = [
|
||||
'openai' => [
|
||||
'currency' => 'USD',
|
||||
'gpt-4o' => ['input' => 2.50, 'output' => 10.00],
|
||||
'gpt-4o-mini' => ['input' => 0.15, 'output' => 0.60],
|
||||
'gpt-4-turbo' => ['input' => 10.00, 'output' => 30.00],
|
||||
'gpt-3.5-turbo' => ['input' => 0.50, 'output' => 1.50],
|
||||
'default' => ['input' => 2.50, 'output' => 10.00],
|
||||
],
|
||||
'anthropic' => [
|
||||
'currency' => 'USD',
|
||||
'claude-3-5-sonnet' => ['input' => 3.00, 'output' => 15.00],
|
||||
'claude-3-opus' => ['input' => 15.00, 'output' => 75.00],
|
||||
'claude-3-haiku' => ['input' => 0.25, 'output' => 1.25],
|
||||
'default' => ['input' => 3.00, 'output' => 15.00],
|
||||
],
|
||||
'google' => [
|
||||
'currency' => 'USD',
|
||||
'gemini-1.5-pro' => ['input' => 1.25, 'output' => 5.00],
|
||||
'gemini-1.5-flash' => ['input' => 0.075, 'output' => 0.30],
|
||||
'gemini-2.0-flash' => ['input' => 0.10, 'output' => 0.40],
|
||||
'default' => ['input' => 0.075, 'output' => 0.30],
|
||||
],
|
||||
'deepseek' => [
|
||||
'currency' => 'CNY',
|
||||
'deepseek-chat' => ['input' => 1.00, 'output' => 2.00],
|
||||
'deepseek-reasoner' => ['input' => 4.00, 'output' => 16.00],
|
||||
'default' => ['input' => 1.00, 'output' => 2.00],
|
||||
],
|
||||
'qwen' => [
|
||||
'currency' => 'CNY',
|
||||
'qwen-turbo' => ['input' => 2.00, 'output' => 6.00],
|
||||
'qwen-plus' => ['input' => 4.00, 'output' => 12.00],
|
||||
'qwen-max' => ['input' => 20.00, 'output' => 60.00],
|
||||
'default' => ['input' => 2.00, 'output' => 6.00],
|
||||
],
|
||||
'zhipu' => [
|
||||
'currency' => 'CNY',
|
||||
'glm-4' => ['input' => 100.00, 'output' => 100.00],
|
||||
'glm-4-flash' => ['input' => 1.00, 'output' => 1.00],
|
||||
'glm-4-plus' => ['input' => 50.00, 'output' => 50.00],
|
||||
'default' => ['input' => 1.00, 'output' => 1.00],
|
||||
],
|
||||
'moonshot' => [
|
||||
'currency' => 'CNY',
|
||||
'moonshot-v1-8k' => ['input' => 12.00, 'output' => 12.00],
|
||||
'moonshot-v1-32k' => ['input' => 24.00, 'output' => 24.00],
|
||||
'moonshot-v1-128k' => ['input' => 60.00, 'output' => 60.00],
|
||||
'default' => ['input' => 12.00, 'output' => 12.00],
|
||||
],
|
||||
'doubao' => [
|
||||
'currency' => 'CNY',
|
||||
'doubao-pro-4k' => ['input' => 0.80, 'output' => 2.00],
|
||||
'doubao-pro-32k' => ['input' => 0.80, 'output' => 2.00],
|
||||
'doubao-pro-128k' => ['input' => 5.00, 'output' => 9.00],
|
||||
'default' => ['input' => 0.80, 'output' => 2.00],
|
||||
],
|
||||
'siliconflow' => [
|
||||
'currency' => 'CNY',
|
||||
'deepseek-ai/DeepSeek-V3' => ['input' => 1.00, 'output' => 2.00],
|
||||
'Qwen/Qwen2.5-72B-Instruct' => ['input' => 4.00, 'output' => 4.00],
|
||||
'default' => ['input' => 1.00, 'output' => 2.00],
|
||||
],
|
||||
'baidu' => [
|
||||
'currency' => 'CNY',
|
||||
'ernie-4.0' => ['input' => 30.00, 'output' => 60.00],
|
||||
'ernie-3.5' => ['input' => 1.20, 'output' => 1.20],
|
||||
'default' => ['input' => 1.20, 'output' => 1.20],
|
||||
],
|
||||
'minimax' => [
|
||||
'currency' => 'CNY',
|
||||
'abab6.5s-chat' => ['input' => 1.00, 'output' => 1.00],
|
||||
'abab6.5-chat' => ['input' => 30.00, 'output' => 30.00],
|
||||
'default' => ['input' => 1.00, 'output' => 1.00],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Get pricing for a provider.
|
||||
*/
|
||||
public static function get(string $provider): array
|
||||
{
|
||||
return self::DATA[$provider] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currency for a provider.
|
||||
*/
|
||||
public static function get_currency(string $provider): string
|
||||
{
|
||||
return self::DATA[$provider]['currency'] ?? 'USD';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cost for a request.
|
||||
*/
|
||||
public static function calculate_cost(
|
||||
string $provider,
|
||||
string $model,
|
||||
int $inputTokens,
|
||||
int $outputTokens
|
||||
): float {
|
||||
$pricing = self::DATA[$provider] ?? [];
|
||||
$modelPricing = $pricing[$model] ?? $pricing['default'] ?? ['input' => 0, 'output' => 0];
|
||||
|
||||
$inputCost = ($inputTokens / 1_000_000) * ($modelPricing['input'] ?? 0);
|
||||
$outputCost = ($outputTokens / 1_000_000) * ($modelPricing['output'] ?? 0);
|
||||
|
||||
return round($inputCost + $outputCost, 6);
|
||||
}
|
||||
}
|
||||
266
includes/class-wenpai-updater.php
Normal file
266
includes/class-wenpai-updater.php
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
<?php
|
||||
/**
|
||||
* WenPai 插件更新器
|
||||
*
|
||||
* 为文派系插件提供自建更新服务支持。
|
||||
* 通过文派云桥 (WenPai Bridge) 检查插件更新,
|
||||
* 利用 WordPress 5.8+ 的 Update URI 机制。
|
||||
*
|
||||
* 当 wp-china-yes 插件激活并启用集中更新时,
|
||||
* 此更新器会通过 wenpai_updater_override filter 自动让位。
|
||||
*
|
||||
* @package WenPai
|
||||
* @version 1.0.0
|
||||
* @requires WordPress 5.8+
|
||||
* @requires PHP 7.4+
|
||||
* @link https://feicode.com/WenPai-org
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( class_exists( 'WenPai_Updater' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
class WenPai_Updater {
|
||||
|
||||
/**
|
||||
* 更新器版本号。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const VERSION = '1.1.0';
|
||||
|
||||
/**
|
||||
* 云桥 API 地址。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const API_URL = 'https://updates.wenpai.net/api/v1';
|
||||
|
||||
/**
|
||||
* 插件主文件 basename(如 wpslug/wpslug.php)。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $plugin_file;
|
||||
|
||||
/**
|
||||
* 插件 slug(如 wpslug)。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $slug;
|
||||
|
||||
/**
|
||||
* 当前插件版本。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $version;
|
||||
|
||||
/**
|
||||
* 初始化更新器。
|
||||
*
|
||||
* @param string $plugin_file 插件主文件路径(plugin_basename 格式)。
|
||||
* @param string $version 当前插件版本号。
|
||||
*/
|
||||
public function __construct( string $plugin_file, string $version ) {
|
||||
$this->plugin_file = $plugin_file;
|
||||
$this->slug = dirname( $plugin_file );
|
||||
$this->version = $version;
|
||||
|
||||
// 检查是否被 wp-china-yes 集中更新接管
|
||||
$is_overridden = apply_filters(
|
||||
'wenpai_updater_override',
|
||||
false,
|
||||
$this->slug
|
||||
);
|
||||
|
||||
if ( ! $is_overridden ) {
|
||||
$this->register_hooks();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 WordPress hooks。
|
||||
*/
|
||||
private function register_hooks(): void {
|
||||
// Update URI: https://updates.wenpai.net 触发此 filter
|
||||
add_filter(
|
||||
'update_plugins_updates.wenpai.net',
|
||||
[ $this, 'check_update' ],
|
||||
10,
|
||||
4
|
||||
);
|
||||
|
||||
// 插件详情弹窗
|
||||
add_filter( 'plugins_api', [ $this, 'plugin_info' ], 20, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查插件更新。
|
||||
*
|
||||
* WordPress 在检查更新时,对声明了 Update URI 的插件
|
||||
* 触发 update_plugins_{hostname} filter。
|
||||
*
|
||||
* @param array|false $update 当前更新数据。
|
||||
* @param array $plugin_data 插件头信息。
|
||||
* @param string $plugin_file 插件文件路径。
|
||||
* @param string[] $locales 语言列表。
|
||||
* @return object|false 更新数据或 false。
|
||||
*/
|
||||
public function check_update( $update, array $plugin_data, string $plugin_file, array $locales ) {
|
||||
if ( $plugin_file !== $this->plugin_file ) {
|
||||
return $update;
|
||||
}
|
||||
|
||||
$response = $this->api_request( 'update-check', [
|
||||
'plugins' => [
|
||||
$this->plugin_file => [
|
||||
'Version' => $this->version,
|
||||
],
|
||||
],
|
||||
] );
|
||||
|
||||
if ( is_wp_error( $response ) || empty( $response['plugins'][ $this->plugin_file ] ) ) {
|
||||
return $update;
|
||||
}
|
||||
|
||||
$data = $response['plugins'][ $this->plugin_file ];
|
||||
|
||||
return (object) [
|
||||
'id' => $data['id'] ?? '',
|
||||
'slug' => $data['slug'] ?? $this->slug,
|
||||
'plugin' => $this->plugin_file,
|
||||
'version' => $data['version'] ?? '',
|
||||
'new_version' => $data['version'] ?? '',
|
||||
'url' => $data['url'] ?? '',
|
||||
'package' => $data['package'] ?? '',
|
||||
'icons' => $data['icons'] ?? [],
|
||||
'banners' => $data['banners'] ?? [],
|
||||
'requires' => $data['requires'] ?? '',
|
||||
'tested' => $data['tested'] ?? '',
|
||||
'requires_php' => $data['requires_php'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件详情弹窗数据。
|
||||
*
|
||||
* 当用户在 WP 后台点击"查看详情"时触发。
|
||||
*
|
||||
* @param false|object|array $result 当前结果。
|
||||
* @param string $action API 动作。
|
||||
* @param object $args 请求参数。
|
||||
* @return false|object 插件信息或 false。
|
||||
*/
|
||||
public function plugin_info( $result, string $action, object $args ) {
|
||||
if ( 'plugin_information' !== $action ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ( ! isset( $args->slug ) || $args->slug !== $this->slug ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$response = $this->api_request( "plugins/{$this->slug}/info" );
|
||||
|
||||
if ( ! is_wp_error( $response ) && ! isset( $response['error'] ) && ! empty( $response['name'] ) ) {
|
||||
$info = new stdClass();
|
||||
$info->name = $response['name'];
|
||||
$info->slug = $response['slug'] ?? $this->slug;
|
||||
$info->version = $response['version'] ?? '';
|
||||
$info->author = $response['author'] ?? '';
|
||||
$info->homepage = $response['homepage'] ?? '';
|
||||
$info->download_link = $response['download_link'] ?? '';
|
||||
$info->requires = $response['requires'] ?? '';
|
||||
$info->tested = $response['tested'] ?? '';
|
||||
$info->requires_php = $response['requires_php'] ?? '';
|
||||
$info->last_updated = $response['last_updated'] ?? '';
|
||||
$info->icons = $response['icons'] ?? [];
|
||||
$info->banners = $response['banners'] ?? [];
|
||||
$info->sections = $response['sections'] ?? [];
|
||||
$info->external = true;
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
// API 不可用或插件未注册时,用本地插件头信息兜底
|
||||
$plugin_path = WP_PLUGIN_DIR . '/' . $this->plugin_file;
|
||||
if ( ! file_exists( $plugin_path ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'get_plugin_data' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
|
||||
$plugin_data = get_plugin_data( $plugin_path );
|
||||
|
||||
$info = new stdClass();
|
||||
$info->name = $plugin_data['Name'] ?? $this->slug;
|
||||
$info->slug = $this->slug;
|
||||
$info->version = $this->version;
|
||||
$info->author = $plugin_data['AuthorName'] ?? '';
|
||||
$info->homepage = $plugin_data['PluginURI'] ?? '';
|
||||
$info->requires = $plugin_data['RequiresWP'] ?? '';
|
||||
$info->requires_php = $plugin_data['RequiresPHP'] ?? '';
|
||||
$info->sections = [
|
||||
'description' => $plugin_data['Description'] ?? '',
|
||||
];
|
||||
$info->external = true;
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 向云桥 API 发送请求。
|
||||
*
|
||||
* @param string $endpoint API 端点(不含 /api/v1/ 前缀)。
|
||||
* @param array|null $body POST 请求体(null 则用 GET)。
|
||||
* @return array|WP_Error 解码后的响应或错误。
|
||||
*/
|
||||
private function api_request( string $endpoint, ?array $body = null ) {
|
||||
$url = self::API_URL . '/' . ltrim( $endpoint, '/' );
|
||||
|
||||
$args = [
|
||||
'timeout' => 10,
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
];
|
||||
|
||||
if ( null !== $body ) {
|
||||
$args['headers']['Content-Type'] = 'application/json';
|
||||
$args['body'] = wp_json_encode( $body );
|
||||
$response = wp_remote_post( $url, $args );
|
||||
} else {
|
||||
$response = wp_remote_get( $url, $args );
|
||||
}
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $code ) {
|
||||
return new WP_Error(
|
||||
'wenpai_bridge_error',
|
||||
sprintf( 'WenPai Bridge API returned %d', $code )
|
||||
);
|
||||
}
|
||||
|
||||
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
if ( ! is_array( $data ) ) {
|
||||
return new WP_Error(
|
||||
'wenpai_bridge_parse_error',
|
||||
'Invalid JSON response from WenPai Bridge'
|
||||
);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
3096
languages/wpmind.pot
Normal file
3096
languages/wpmind.pot
Normal file
File diff suppressed because it is too large
Load diff
201
modules/analytics/AnalyticsModule.php
Normal file
201
modules/analytics/AnalyticsModule.php
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<?php
|
||||
/**
|
||||
* Analytics Module - 分析面板模块
|
||||
*
|
||||
* @package WPMind\Modules\Analytics
|
||||
* @since 3.3.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\Analytics;
|
||||
|
||||
use WPMind\Core\ModuleInterface;
|
||||
|
||||
class AnalyticsModule implements ModuleInterface
|
||||
{
|
||||
/**
|
||||
* 模块配置
|
||||
*/
|
||||
private array $config = [];
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$config_file = __DIR__ . '/module.json';
|
||||
if (file_exists($config_file)) {
|
||||
$this->config = json_decode(file_get_contents($config_file), true) ?: [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块 ID
|
||||
*/
|
||||
public function get_id(): string
|
||||
{
|
||||
return 'analytics';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块名称
|
||||
*/
|
||||
public function get_name(): string
|
||||
{
|
||||
return $this->config['name'] ?? 'Analytics';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块描述
|
||||
*/
|
||||
public function get_description(): string
|
||||
{
|
||||
return $this->config['description'] ?? '分析仪表板';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块版本
|
||||
*/
|
||||
public function get_version(): string
|
||||
{
|
||||
return $this->config['version'] ?? '1.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块依赖
|
||||
*/
|
||||
public function get_dependencies(): array
|
||||
{
|
||||
return $this->config['requires'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模块依赖是否满足
|
||||
*/
|
||||
public function check_dependencies(): bool
|
||||
{
|
||||
$requires = $this->get_dependencies();
|
||||
|
||||
if (empty($requires)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$module_loader = \WPMind\Core\ModuleLoader::instance();
|
||||
|
||||
foreach ($requires as $required_module) {
|
||||
if (!$module_loader->is_module_enabled($required_module)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设置标签页
|
||||
*/
|
||||
public function get_settings_tab(): ?string
|
||||
{
|
||||
return $this->config['settings_tab'] ?? 'analytics';
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化模块
|
||||
*/
|
||||
public function init(): void
|
||||
{
|
||||
// 加载模块类
|
||||
$this->load_classes();
|
||||
|
||||
// 注册设置标签页
|
||||
add_filter('wpmind_settings_tabs', [$this, 'register_settings_tab'], 10);
|
||||
|
||||
// 注册 AJAX 处理器
|
||||
add_action('wp_ajax_wpmind_get_analytics', [$this, 'ajax_get_analytics']);
|
||||
|
||||
// 注册资源加载
|
||||
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载模块类
|
||||
*/
|
||||
private function load_classes(): void
|
||||
{
|
||||
require_once __DIR__ . '/includes/AnalyticsManager.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册设置标签页
|
||||
*/
|
||||
public function register_settings_tab(array $tabs): array
|
||||
{
|
||||
$tab_config = $this->config['settings_tab'] ?? [];
|
||||
|
||||
$tabs['analytics'] = [
|
||||
'label' => $tab_config['label'] ?? __('数据分析', 'wpmind'),
|
||||
'icon' => $tab_config['icon'] ?? 'dashicons-chart-area',
|
||||
'priority' => $tab_config['priority'] ?? 10,
|
||||
'template' => __DIR__ . '/templates/dashboard.php',
|
||||
];
|
||||
|
||||
return $tabs;
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 获取分析数据
|
||||
*/
|
||||
public function ajax_get_analytics(): void
|
||||
{
|
||||
check_ajax_referer('wpmind_ajax', 'nonce');
|
||||
|
||||
if (!current_user_can('manage_options')) {
|
||||
wp_send_json_error(['message' => __('权限不足', 'wpmind')]);
|
||||
}
|
||||
|
||||
$range = isset($_POST['range']) ? sanitize_text_field($_POST['range']) : '7d';
|
||||
|
||||
$analytics = AnalyticsManager::instance();
|
||||
$data = $analytics->get_analytics_data($range);
|
||||
|
||||
wp_send_json_success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载模块资源
|
||||
*/
|
||||
public function enqueue_assets(string $hook): void
|
||||
{
|
||||
// 只在 WPMind 设置页面加载
|
||||
if (strpos($hook, 'wpmind') === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Chart.js 已在主插件加载,这里只需确保依赖
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活模块
|
||||
*/
|
||||
public function activate(): void
|
||||
{
|
||||
// 模块激活时的初始化操作
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用模块
|
||||
*/
|
||||
public function deactivate(): void
|
||||
{
|
||||
// 模块停用时的清理操作
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载模块
|
||||
*/
|
||||
public function uninstall(): void
|
||||
{
|
||||
// 模块卸载时删除数据(可选)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,15 +4,15 @@
|
|||
*
|
||||
* 提供用量数据的聚合和分析功能
|
||||
*
|
||||
* @package WPMind
|
||||
* @package WPMind\Modules\Analytics
|
||||
* @since 1.8.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Analytics;
|
||||
namespace WPMind\Modules\Analytics;
|
||||
|
||||
use WPMind\Usage\UsageTracker;
|
||||
use WPMind\Modules\CostControl\UsageTracker;
|
||||
|
||||
class AnalyticsManager
|
||||
{
|
||||
|
|
@ -44,10 +44,10 @@ class AnalyticsManager
|
|||
* @param array|null $stats 预加载的统计数据
|
||||
* @return array
|
||||
*/
|
||||
public function getUsageTrend(int $days = 7, ?array $stats = null): array
|
||||
public function get_usage_trend(int $days = 7, ?array $stats = null): array
|
||||
{
|
||||
if ($stats === null) {
|
||||
$stats = UsageTracker::getStats();
|
||||
$stats = UsageTracker::get_stats();
|
||||
}
|
||||
$daily = $stats['daily'] ?? [];
|
||||
|
||||
|
|
@ -95,10 +95,10 @@ class AnalyticsManager
|
|||
* @param array|null $stats 预加载的统计数据
|
||||
* @return array
|
||||
*/
|
||||
public function getProviderComparison(?array $stats = null): array
|
||||
public function get_provider_comparison(?array $stats = null): array
|
||||
{
|
||||
if ($stats === null) {
|
||||
$stats = UsageTracker::getStats();
|
||||
$stats = UsageTracker::get_stats();
|
||||
}
|
||||
$providers = $stats['providers'] ?? [];
|
||||
|
||||
|
|
@ -109,11 +109,11 @@ class AnalyticsManager
|
|||
$colors = [];
|
||||
|
||||
foreach ($providers as $providerId => $data) {
|
||||
$labels[] = UsageTracker::getProviderDisplayName($providerId);
|
||||
$labels[] = UsageTracker::get_provider_display_name($providerId);
|
||||
$tokens[] = ($data['total_input_tokens'] ?? 0) + ($data['total_output_tokens'] ?? 0);
|
||||
$costs[] = round($data['total_cost'] ?? 0, 4);
|
||||
$requests[] = $data['request_count'] ?? 0;
|
||||
$colors[] = $this->getProviderChartColor($providerId);
|
||||
$colors[] = $this->get_provider_chart_color($providerId);
|
||||
}
|
||||
|
||||
return [
|
||||
|
|
@ -134,10 +134,10 @@ class AnalyticsManager
|
|||
* @param array|null $stats 预加载的统计数据
|
||||
* @return array
|
||||
*/
|
||||
public function getCostAnalysis(int $months = 6, ?array $stats = null): array
|
||||
public function get_cost_analysis(int $months = 6, ?array $stats = null): array
|
||||
{
|
||||
if ($stats === null) {
|
||||
$stats = UsageTracker::getStats();
|
||||
$stats = UsageTracker::get_stats();
|
||||
}
|
||||
$monthly = $stats['monthly'] ?? [];
|
||||
|
||||
|
|
@ -176,10 +176,10 @@ class AnalyticsManager
|
|||
* @param array|null $stats 预加载的统计数据
|
||||
* @return array
|
||||
*/
|
||||
public function getModelDistribution(?array $stats = null): array
|
||||
public function get_model_distribution(?array $stats = null): array
|
||||
{
|
||||
if ($stats === null) {
|
||||
$stats = UsageTracker::getStats();
|
||||
$stats = UsageTracker::get_stats();
|
||||
}
|
||||
$providers = $stats['providers'] ?? [];
|
||||
|
||||
|
|
@ -190,7 +190,7 @@ class AnalyticsManager
|
|||
foreach ($providerModels as $modelName => $modelData) {
|
||||
$models[] = [
|
||||
'provider' => $providerId,
|
||||
'provider_name' => UsageTracker::getProviderDisplayName($providerId),
|
||||
'provider_name' => UsageTracker::get_provider_display_name($providerId),
|
||||
'model' => $modelName,
|
||||
'tokens' => ($modelData['input_tokens'] ?? 0) + ($modelData['output_tokens'] ?? 0),
|
||||
'cost' => $modelData['cost'] ?? 0,
|
||||
|
|
@ -233,9 +233,9 @@ class AnalyticsManager
|
|||
* @param int $limit 记录数
|
||||
* @return array
|
||||
*/
|
||||
public function getLatencyMetrics(int $limit = 100): array
|
||||
public function get_latency_metrics(int $limit = 100): array
|
||||
{
|
||||
$history = UsageTracker::getHistory($limit);
|
||||
$history = UsageTracker::get_history($limit);
|
||||
|
||||
$providerLatency = [];
|
||||
|
||||
|
|
@ -265,7 +265,7 @@ class AnalyticsManager
|
|||
if ($data['count'] > 0) {
|
||||
$result[] = [
|
||||
'provider' => $provider,
|
||||
'provider_name' => UsageTracker::getProviderDisplayName($provider),
|
||||
'provider_name' => UsageTracker::get_provider_display_name($provider),
|
||||
'avg_latency' => round($data['total'] / $data['count']),
|
||||
'min_latency' => $data['min'] === PHP_INT_MAX ? 0 : $data['min'],
|
||||
'max_latency' => $data['max'],
|
||||
|
|
@ -287,12 +287,12 @@ class AnalyticsManager
|
|||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getDashboardSummary(): array
|
||||
public function get_dashboard_summary(): array
|
||||
{
|
||||
$today = UsageTracker::getTodayStats();
|
||||
$week = UsageTracker::getWeekStats();
|
||||
$month = UsageTracker::getMonthStats();
|
||||
$stats = UsageTracker::getStats();
|
||||
$today = UsageTracker::get_today_stats();
|
||||
$week = UsageTracker::get_week_stats();
|
||||
$month = UsageTracker::get_month_stats();
|
||||
$stats = UsageTracker::get_stats();
|
||||
$total = $stats['total'] ?? [];
|
||||
|
||||
return [
|
||||
|
|
@ -330,7 +330,7 @@ class AnalyticsManager
|
|||
* @param string $range 时间范围 (7d, 30d, 6m)
|
||||
* @return array
|
||||
*/
|
||||
public function getAnalyticsData(string $range = '7d'): array
|
||||
public function get_analytics_data(string $range = '7d'): array
|
||||
{
|
||||
// 白名单验证
|
||||
$allowed_ranges = ['7d', '30d', '6m'];
|
||||
|
|
@ -355,15 +355,15 @@ class AnalyticsManager
|
|||
}
|
||||
|
||||
// 一次性获取统计数据,避免重复调用
|
||||
$stats = UsageTracker::getStats();
|
||||
$stats = UsageTracker::get_stats();
|
||||
|
||||
return [
|
||||
'summary' => $this->getDashboardSummary(),
|
||||
'trend' => $this->getUsageTrend($days, $stats),
|
||||
'providers' => $this->getProviderComparison($stats),
|
||||
'cost' => $this->getCostAnalysis($months, $stats),
|
||||
'models' => $this->getModelDistribution($stats),
|
||||
'latency' => $this->getLatencyMetrics(),
|
||||
'summary' => $this->get_dashboard_summary(),
|
||||
'trend' => $this->get_usage_trend($days, $stats),
|
||||
'providers' => $this->get_provider_comparison($stats),
|
||||
'cost' => $this->get_cost_analysis($months, $stats),
|
||||
'models' => $this->get_model_distribution($stats),
|
||||
'latency' => $this->get_latency_metrics(),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -373,7 +373,7 @@ class AnalyticsManager
|
|||
* @param string $provider Provider ID
|
||||
* @return string 十六进制颜色值
|
||||
*/
|
||||
private function getProviderChartColor(string $provider): string
|
||||
private function get_provider_chart_color(string $provider): string
|
||||
{
|
||||
$colors = [
|
||||
'openai' => '#10a37f',
|
||||
18
modules/analytics/module.json
Normal file
18
modules/analytics/module.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"id": "analytics",
|
||||
"name": "Analytics",
|
||||
"version": "1.0.0",
|
||||
"description": "数据分析 - 用量趋势、服务商对比、成本分析",
|
||||
"author": "WPMind",
|
||||
"icon": "ri-bar-chart-box-line",
|
||||
"class": "WPMind\\Modules\\Analytics\\AnalyticsModule",
|
||||
"can_disable": true,
|
||||
"settings_tab": "analytics",
|
||||
"requires": ["cost-control"],
|
||||
"features": [
|
||||
"用量趋势图表",
|
||||
"服务商对比分析",
|
||||
"成本统计报表",
|
||||
"实时监控面板"
|
||||
]
|
||||
}
|
||||
276
modules/analytics/templates/dashboard.php
Normal file
276
modules/analytics/templates/dashboard.php
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
<?php
|
||||
/**
|
||||
* WPMind 仪表板 Tab
|
||||
*
|
||||
* 包含:用量统计 + 分析图表 + 服务状态
|
||||
*
|
||||
* @package WPMind
|
||||
* @since 2.0.0
|
||||
*/
|
||||
|
||||
// 防止直接访问
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
// 获取数据
|
||||
$failover_manager = \WPMind\Failover\FailoverManager::instance();
|
||||
$provider_status = $failover_manager->get_status_summary();
|
||||
|
||||
$usage_stats = \WPMind\Modules\CostControl\UsageTracker::get_stats();
|
||||
$today_stats = \WPMind\Modules\CostControl\UsageTracker::get_today_stats();
|
||||
$week_stats = \WPMind\Modules\CostControl\UsageTracker::get_week_stats();
|
||||
$month_stats = \WPMind\Modules\CostControl\UsageTracker::get_month_stats();
|
||||
$last_updated = $usage_stats['last_updated'] ?? 0;
|
||||
$has_usage_data = ( $usage_stats['total']['requests'] ?? 0 ) > 0;
|
||||
?>
|
||||
|
||||
<!-- Token 用量统计面板 -->
|
||||
<div class="wpmind-usage-panel">
|
||||
<div class="wpmind-usage-header">
|
||||
<h2 class="wpmind-usage-title">
|
||||
<span class="dashicons ri-bar-chart-box-line"></span>
|
||||
<?php esc_html_e( 'Token 用量统计', 'wpmind' ); ?>
|
||||
</h2>
|
||||
<?php if ( $last_updated > 0 ) : ?>
|
||||
<span class="wpmind-last-updated" title="<?php esc_attr_e( '上次更新时间', 'wpmind' ); ?>">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: relative time */
|
||||
esc_html__( '更新于 %s', 'wpmind' ),
|
||||
esc_html( human_time_diff( $last_updated, time() ) . __( '前', 'wpmind' ) )
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<button type="button" class="button button-small wpmind-refresh-usage" title="<?php esc_attr_e( '刷新统计', 'wpmind' ); ?>" aria-label="<?php esc_attr_e( '刷新用量统计', 'wpmind' ); ?>">
|
||||
<span class="dashicons ri-refresh-line"></span>
|
||||
</button>
|
||||
<button type="button" class="button button-small wpmind-clear-usage" title="<?php esc_attr_e( '清除统计', 'wpmind' ); ?>" aria-label="<?php esc_attr_e( '清除所有用量统计数据', 'wpmind' ); ?>">
|
||||
<span class="dashicons ri-delete-bin-line"></span>
|
||||
<?php esc_html_e( '清除', 'wpmind' ); ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="wpmind-usage-desc">
|
||||
<?php esc_html_e( '追踪各 AI 服务的 Token 消耗和费用估算,帮助优化成本。', 'wpmind' ); ?>
|
||||
</p>
|
||||
|
||||
<?php if ( ! $has_usage_data ) : ?>
|
||||
<!-- 空状态提示 -->
|
||||
<div class="wpmind-usage-empty">
|
||||
<span class="dashicons ri-bar-chart-box-line"></span>
|
||||
<p><?php esc_html_e( '暂无用量数据', 'wpmind' ); ?></p>
|
||||
<p class="description"><?php esc_html_e( '当 AI 服务被调用时,用量统计将自动记录在这里。', 'wpmind' ); ?></p>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
|
||||
<p class="wpmind-usage-note">
|
||||
<?php esc_html_e( '费用为估算值,按各服务商官方定价计算(每百万 tokens)。实际费用以服务商账单为准。', 'wpmind' ); ?>
|
||||
</p>
|
||||
|
||||
<div class="wpmind-usage-cards">
|
||||
<div class="wpmind-usage-card">
|
||||
<div class="wpmind-usage-card-header">
|
||||
<span class="dashicons ri-calendar-line"></span>
|
||||
<?php esc_html_e( '今日', 'wpmind' ); ?>
|
||||
</div>
|
||||
<div class="wpmind-usage-card-body">
|
||||
<div class="wpmind-usage-stat">
|
||||
<span class="wpmind-usage-value" id="today-tokens"><?php echo esc_html( \WPMind\Modules\CostControl\UsageTracker::format_tokens( $today_stats['input_tokens'] + $today_stats['output_tokens'] ) ); ?></span>
|
||||
<span class="wpmind-usage-label"><?php esc_html_e( 'Tokens', 'wpmind' ); ?></span>
|
||||
</div>
|
||||
<div class="wpmind-usage-stat">
|
||||
<span class="wpmind-usage-value wpmind-usage-cost" id="today-cost"><?php echo esc_html( \WPMind\Modules\CostControl\UsageTracker::format_cost_by_currency( $today_stats['cost_usd'] ?? 0, $today_stats['cost_cny'] ?? 0 ) ); ?></span>
|
||||
<span class="wpmind-usage-label"><?php esc_html_e( '费用', 'wpmind' ); ?></span>
|
||||
</div>
|
||||
<div class="wpmind-usage-stat">
|
||||
<span class="wpmind-usage-value" id="today-requests"><?php echo esc_html( $today_stats['requests'] ); ?></span>
|
||||
<span class="wpmind-usage-label"><?php esc_html_e( '请求', 'wpmind' ); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wpmind-usage-card">
|
||||
<div class="wpmind-usage-card-header">
|
||||
<span class="dashicons ri-history-line"></span>
|
||||
<?php esc_html_e( '本周', 'wpmind' ); ?>
|
||||
</div>
|
||||
<div class="wpmind-usage-card-body">
|
||||
<div class="wpmind-usage-stat">
|
||||
<span class="wpmind-usage-value" id="week-tokens"><?php echo esc_html( \WPMind\Modules\CostControl\UsageTracker::format_tokens( $week_stats['input_tokens'] + $week_stats['output_tokens'] ) ); ?></span>
|
||||
<span class="wpmind-usage-label"><?php esc_html_e( 'Tokens', 'wpmind' ); ?></span>
|
||||
</div>
|
||||
<div class="wpmind-usage-stat">
|
||||
<span class="wpmind-usage-value wpmind-usage-cost" id="week-cost"><?php echo esc_html( \WPMind\Modules\CostControl\UsageTracker::format_cost_by_currency( $week_stats['cost_usd'] ?? 0, $week_stats['cost_cny'] ?? 0 ) ); ?></span>
|
||||
<span class="wpmind-usage-label"><?php esc_html_e( '费用', 'wpmind' ); ?></span>
|
||||
</div>
|
||||
<div class="wpmind-usage-stat">
|
||||
<span class="wpmind-usage-value" id="week-requests"><?php echo esc_html( $week_stats['requests'] ); ?></span>
|
||||
<span class="wpmind-usage-label"><?php esc_html_e( '请求', 'wpmind' ); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wpmind-usage-card">
|
||||
<div class="wpmind-usage-card-header">
|
||||
<span class="dashicons ri-calendar-2-line"></span>
|
||||
<?php esc_html_e( '本月', 'wpmind' ); ?>
|
||||
</div>
|
||||
<div class="wpmind-usage-card-body">
|
||||
<div class="wpmind-usage-stat">
|
||||
<span class="wpmind-usage-value" id="month-tokens"><?php echo esc_html( \WPMind\Modules\CostControl\UsageTracker::format_tokens( $month_stats['input_tokens'] + $month_stats['output_tokens'] ) ); ?></span>
|
||||
<span class="wpmind-usage-label"><?php esc_html_e( 'Tokens', 'wpmind' ); ?></span>
|
||||
</div>
|
||||
<div class="wpmind-usage-stat">
|
||||
<span class="wpmind-usage-value wpmind-usage-cost" id="month-cost"><?php echo esc_html( \WPMind\Modules\CostControl\UsageTracker::format_cost_by_currency( $month_stats['cost_usd'] ?? 0, $month_stats['cost_cny'] ?? 0 ) ); ?></span>
|
||||
<span class="wpmind-usage-label"><?php esc_html_e( '费用', 'wpmind' ); ?></span>
|
||||
</div>
|
||||
<div class="wpmind-usage-stat">
|
||||
<span class="wpmind-usage-value" id="month-requests"><?php echo esc_html( $month_stats['requests'] ); ?></span>
|
||||
<span class="wpmind-usage-label"><?php esc_html_e( '请求', 'wpmind' ); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wpmind-usage-card">
|
||||
<div class="wpmind-usage-card-header">
|
||||
<span class="dashicons ri-bar-chart-box-line"></span>
|
||||
<?php esc_html_e( '总计', 'wpmind' ); ?>
|
||||
</div>
|
||||
<div class="wpmind-usage-card-body">
|
||||
<div class="wpmind-usage-stat">
|
||||
<span class="wpmind-usage-value" id="total-tokens"><?php echo esc_html( \WPMind\Modules\CostControl\UsageTracker::format_tokens( ($usage_stats['total']['input_tokens'] ?? 0) + ($usage_stats['total']['output_tokens'] ?? 0) ) ); ?></span>
|
||||
<span class="wpmind-usage-label"><?php esc_html_e( 'Tokens', 'wpmind' ); ?></span>
|
||||
</div>
|
||||
<div class="wpmind-usage-stat">
|
||||
<span class="wpmind-usage-value wpmind-usage-cost" id="total-cost"><?php echo esc_html( \WPMind\Modules\CostControl\UsageTracker::format_cost_by_currency( $usage_stats['total']['cost_usd'] ?? 0, $usage_stats['total']['cost_cny'] ?? 0 ) ); ?></span>
|
||||
<span class="wpmind-usage-label"><?php esc_html_e( '费用', 'wpmind' ); ?></span>
|
||||
</div>
|
||||
<div class="wpmind-usage-stat">
|
||||
<span class="wpmind-usage-value" id="total-requests"><?php echo esc_html( $usage_stats['total']['requests'] ?? 0 ); ?></span>
|
||||
<span class="wpmind-usage-label"><?php esc_html_e( '请求', 'wpmind' ); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 各渠道用量统计 -->
|
||||
<?php if ( ! empty( $usage_stats['providers'] ) ) : ?>
|
||||
<h3 class="wpmind-usage-section-title">
|
||||
<span class="dashicons ri-server-line"></span>
|
||||
<?php esc_html_e( '各渠道用量', 'wpmind' ); ?>
|
||||
</h3>
|
||||
<div class="wpmind-provider-usage-grid">
|
||||
<?php foreach ( $usage_stats['providers'] as $provider_id => $provider_stats ) :
|
||||
$currency = \WPMind\Modules\CostControl\UsageTracker::get_currency( $provider_id );
|
||||
$display_name = \WPMind\Modules\CostControl\UsageTracker::get_provider_display_name( $provider_id );
|
||||
$icon_class = \WPMind\Modules\CostControl\UsageTracker::get_provider_icon( $provider_id );
|
||||
$icon_color = \WPMind\Modules\CostControl\UsageTracker::get_provider_color( $provider_id );
|
||||
?>
|
||||
<div class="wpmind-provider-usage-item">
|
||||
<div class="wpmind-provider-usage-header">
|
||||
<i class="<?php echo esc_attr( $icon_class ); ?> wpmind-provider-usage-icon" style="color: <?php echo esc_attr( $icon_color ); ?>;"></i>
|
||||
<span class="wpmind-provider-usage-name"><?php echo esc_html( $display_name ); ?></span>
|
||||
<span class="wpmind-provider-usage-currency"><?php echo esc_html( $currency ); ?></span>
|
||||
</div>
|
||||
<div class="wpmind-provider-usage-body">
|
||||
<div class="wpmind-provider-usage-row">
|
||||
<span class="wpmind-provider-usage-label"><?php esc_html_e( 'Tokens', 'wpmind' ); ?></span>
|
||||
<span class="wpmind-provider-usage-value"><?php echo esc_html( \WPMind\Modules\CostControl\UsageTracker::format_tokens( $provider_stats['total_input_tokens'] + $provider_stats['total_output_tokens'] ) ); ?></span>
|
||||
</div>
|
||||
<div class="wpmind-provider-usage-row">
|
||||
<span class="wpmind-provider-usage-label"><?php esc_html_e( '费用', 'wpmind' ); ?></span>
|
||||
<span class="wpmind-provider-usage-value"><?php echo esc_html( \WPMind\Modules\CostControl\UsageTracker::format_cost( $provider_stats['total_cost'], $currency ) ); ?></span>
|
||||
</div>
|
||||
<div class="wpmind-provider-usage-row">
|
||||
<span class="wpmind-provider-usage-label"><?php esc_html_e( '请求', 'wpmind' ); ?></span>
|
||||
<span class="wpmind-provider-usage-value"><?php echo esc_html( $provider_stats['request_count'] ); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php endif; // end has_usage_data ?>
|
||||
</div>
|
||||
|
||||
<!-- 分析仪表板 -->
|
||||
<?php if ( $has_usage_data ) : ?>
|
||||
<div class="wpmind-analytics-panel">
|
||||
<h2 class="title">
|
||||
<span class="dashicons ri-line-chart-line"></span>
|
||||
<?php esc_html_e( '分析仪表板', 'wpmind' ); ?>
|
||||
<select id="wpmind-analytics-range" class="wpmind-analytics-range-select">
|
||||
<option value="7d"><?php esc_html_e( '最近 7 天', 'wpmind' ); ?></option>
|
||||
<option value="30d"><?php esc_html_e( '最近 30 天', 'wpmind' ); ?></option>
|
||||
</select>
|
||||
<button type="button" class="button button-small wpmind-refresh-analytics" title="<?php esc_attr_e( '刷新图表', 'wpmind' ); ?>">
|
||||
<span class="dashicons ri-refresh-line"></span>
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
<div class="wpmind-analytics-content">
|
||||
<!-- 用量趋势图 -->
|
||||
<div class="wpmind-chart-container">
|
||||
<h3><?php esc_html_e( '用量趋势', 'wpmind' ); ?></h3>
|
||||
<div class="wpmind-chart-wrapper">
|
||||
<canvas id="wpmind-usage-trend-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务商对比图 -->
|
||||
<div class="wpmind-chart-container">
|
||||
<h3><?php esc_html_e( '服务商对比', 'wpmind' ); ?></h3>
|
||||
<div class="wpmind-chart-wrapper">
|
||||
<canvas id="wpmind-provider-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成本分析图 -->
|
||||
<div class="wpmind-chart-container">
|
||||
<h3><?php esc_html_e( '成本趋势', 'wpmind' ); ?></h3>
|
||||
<div class="wpmind-chart-wrapper">
|
||||
<canvas id="wpmind-cost-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型使用分布 -->
|
||||
<div class="wpmind-chart-container">
|
||||
<h3><?php esc_html_e( '模型使用排行', 'wpmind' ); ?></h3>
|
||||
<div class="wpmind-chart-wrapper">
|
||||
<canvas id="wpmind-model-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Provider 状态面板 -->
|
||||
<?php if ( ! empty( $provider_status ) ) : ?>
|
||||
<div class="wpmind-status-panel">
|
||||
<h2 class="title">
|
||||
<?php esc_html_e( '服务状态', 'wpmind' ); ?>
|
||||
<button type="button" class="button button-small wpmind-refresh-status" title="<?php esc_attr_e( '刷新状态', 'wpmind' ); ?>">
|
||||
<span class="dashicons ri-refresh-line"></span>
|
||||
</button>
|
||||
<button type="button" class="button button-small wpmind-reset-all-breakers" title="<?php esc_attr_e( '重置所有熔断器', 'wpmind' ); ?>">
|
||||
<span class="dashicons ri-restart-line"></span>
|
||||
<?php esc_html_e( '重置', 'wpmind' ); ?>
|
||||
</button>
|
||||
</h2>
|
||||
<div class="wpmind-status-grid" id="wpmind-status-grid">
|
||||
<?php foreach ( $provider_status as $provider_id => $status ) : ?>
|
||||
<div class="wpmind-status-item" data-provider="<?php echo esc_attr( $provider_id ); ?>">
|
||||
<span class="wpmind-status-indicator wpmind-status-<?php echo esc_attr( $status['state'] ); ?>"></span>
|
||||
<span class="wpmind-status-name"><?php echo esc_html( $status['display_name'] ); ?></span>
|
||||
<span class="wpmind-status-label"><?php echo esc_html( $status['state_label'] ); ?></span>
|
||||
<span class="wpmind-status-score" title="<?php esc_attr_e( '健康分数', 'wpmind' ); ?>">
|
||||
<?php echo esc_html( $status['health_score'] ); ?>%
|
||||
</span>
|
||||
<?php if ( $status['state'] === 'open' && $status['recovery_in'] ) : ?>
|
||||
<span class="wpmind-status-recovery">
|
||||
<?php printf( esc_html__( '%d分钟后恢复', 'wpmind' ), ceil( $status['recovery_in'] / 60 ) ); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
250
modules/api-gateway/ApiGatewayModule.php
Normal file
250
modules/api-gateway/ApiGatewayModule.php
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<?php
|
||||
/**
|
||||
* API Gateway Module
|
||||
*
|
||||
* OpenAI-compatible AI API gateway module for WPMind.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway;
|
||||
|
||||
use WPMind\Core\ModuleInterface;
|
||||
|
||||
// Load module classes.
|
||||
require_once __DIR__ . '/includes/SchemaManager.php';
|
||||
require_once __DIR__ . '/includes/Auth/ApiKeyHasher.php';
|
||||
require_once __DIR__ . '/includes/Auth/ApiKeyRepository.php';
|
||||
require_once __DIR__ . '/includes/Auth/ApiKeyAuthResult.php';
|
||||
require_once __DIR__ . '/includes/Auth/ApiKeyManager.php';
|
||||
require_once __DIR__ . '/includes/GatewayRequestSchema.php';
|
||||
require_once __DIR__ . '/includes/Pipeline/GatewayStageInterface.php';
|
||||
require_once __DIR__ . '/includes/Pipeline/GatewayRequestContext.php';
|
||||
require_once __DIR__ . '/includes/Pipeline/GatewayPipeline.php';
|
||||
require_once __DIR__ . '/includes/RateLimit/RateStoreResult.php';
|
||||
require_once __DIR__ . '/includes/RateLimit/RateStoreInterface.php';
|
||||
require_once __DIR__ . '/includes/RateLimit/RedisRateStore.php';
|
||||
require_once __DIR__ . '/includes/RateLimit/TransientRateStore.php';
|
||||
require_once __DIR__ . '/includes/RateLimit/RateLimiter.php';
|
||||
require_once __DIR__ . '/includes/Pipeline/AuthMiddleware.php';
|
||||
require_once __DIR__ . '/includes/Pipeline/BudgetMiddleware.php';
|
||||
require_once __DIR__ . '/includes/Pipeline/QuotaMiddleware.php';
|
||||
require_once __DIR__ . '/includes/Transform/ModelMapper.php';
|
||||
require_once __DIR__ . '/includes/Transform/RequestTransformer.php';
|
||||
require_once __DIR__ . '/includes/Transform/ResponseTransformer.php';
|
||||
require_once __DIR__ . '/includes/Pipeline/RequestTransformMiddleware.php';
|
||||
require_once __DIR__ . '/includes/Pipeline/ResponseTransformMiddleware.php';
|
||||
require_once __DIR__ . '/includes/Pipeline/RouteMiddleware.php';
|
||||
require_once __DIR__ . '/includes/Stream/CancellationToken.php';
|
||||
require_once __DIR__ . '/includes/Stream/SseSlot.php';
|
||||
require_once __DIR__ . '/includes/Stream/StreamResult.php';
|
||||
require_once __DIR__ . '/includes/Stream/SseConcurrencyGuard.php';
|
||||
require_once __DIR__ . '/includes/Stream/UpstreamStreamClient.php';
|
||||
require_once __DIR__ . '/includes/Stream/SseStreamController.php';
|
||||
require_once __DIR__ . '/includes/Error/ErrorMapper.php';
|
||||
require_once __DIR__ . '/includes/Pipeline/ErrorMiddleware.php';
|
||||
require_once __DIR__ . '/includes/Pipeline/LogMiddleware.php';
|
||||
require_once __DIR__ . '/includes/RestController.php';
|
||||
require_once __DIR__ . '/includes/Admin/AuditLogRepository.php';
|
||||
require_once __DIR__ . '/includes/Admin/GatewayAjaxController.php';
|
||||
|
||||
/**
|
||||
* Class ApiGatewayModule
|
||||
*
|
||||
* Main entry point for the API Gateway module.
|
||||
*/
|
||||
class ApiGatewayModule implements ModuleInterface {
|
||||
|
||||
/**
|
||||
* Get module ID.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_id(): string {
|
||||
return 'api-gateway';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module name.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_name(): string {
|
||||
return __( 'API Gateway', 'wpmind' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module description.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_description(): string {
|
||||
return __( 'OpenAI 兼容的 AI API 网关 — 将 WordPress 变为自托管 AI 代理', 'wpmind' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module version.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_version(): string {
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check dependencies.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function check_dependencies(): bool {
|
||||
return version_compare( PHP_VERSION, '8.1', '>=' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get settings tab slug.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function get_settings_tab(): ?string {
|
||||
return 'api-gateway';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module.
|
||||
*/
|
||||
public function init(): void {
|
||||
// Ensure database schema is up to date.
|
||||
SchemaManager::maybe_upgrade();
|
||||
|
||||
// Register REST API routes.
|
||||
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
|
||||
|
||||
// Register settings tab.
|
||||
add_filter( 'wpmind_settings_tabs', array( $this, 'register_settings_tab' ) );
|
||||
|
||||
// Register admin AJAX handlers.
|
||||
if ( is_admin() ) {
|
||||
$ajax_controller = new Admin\GatewayAjaxController();
|
||||
$ajax_controller->register_hooks();
|
||||
|
||||
// Admin assets (only on WPMind settings page).
|
||||
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_assets' ] );
|
||||
}
|
||||
|
||||
// Audit logging for SSE streams (bypasses pipeline finalization).
|
||||
add_action( 'wpmind_gateway_sse_complete', array( $this, 'log_sse_completion' ), 10, 3 );
|
||||
|
||||
/**
|
||||
* Fires when API Gateway module is initialized.
|
||||
*
|
||||
* @param ApiGatewayModule $this Module instance.
|
||||
*/
|
||||
do_action( 'wpmind_api_gateway_init', $this );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue admin assets for the API Gateway tab.
|
||||
*
|
||||
* @param string $hook_suffix Current page hook suffix.
|
||||
*/
|
||||
public function enqueue_admin_assets( string $hook_suffix ): void {
|
||||
if ( 'toplevel_page_wpmind' !== $hook_suffix ) {
|
||||
return;
|
||||
}
|
||||
wp_enqueue_style(
|
||||
'wpmind-api-gateway',
|
||||
WPMIND_PLUGIN_URL . 'assets/css/pages/api-gateway.css',
|
||||
[ 'wpmind-admin' ],
|
||||
WPMIND_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register REST API routes.
|
||||
*
|
||||
* Instantiates the RestController and registers all gateway endpoints.
|
||||
*/
|
||||
public function register_rest_routes(): void {
|
||||
$controller = new RestController();
|
||||
$controller->register_routes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register settings tab.
|
||||
*
|
||||
* @param array $tabs Existing tabs.
|
||||
* @return array Modified tabs.
|
||||
*/
|
||||
public function register_settings_tab( array $tabs ): array {
|
||||
$tabs['api-gateway'] = array(
|
||||
'title' => __( 'API Gateway', 'wpmind' ),
|
||||
'icon' => 'ri-server-line',
|
||||
'template' => WPMIND_PATH . 'modules/api-gateway/templates/settings.php',
|
||||
'priority' => 35,
|
||||
);
|
||||
return $tabs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log SSE stream completion for audit and usage tracking.
|
||||
*
|
||||
* SSE streams exit() before pipeline finalization, so this
|
||||
* hook ensures audit logs and usage counters are still updated.
|
||||
*
|
||||
* @param string $request_id Request ID.
|
||||
* @param string $key_id API key ID.
|
||||
* @param Stream\StreamResult $result Stream result.
|
||||
*/
|
||||
public function log_sse_completion( string $request_id, string $key_id, Stream\StreamResult $result ): void {
|
||||
global $wpdb;
|
||||
|
||||
// Audit log.
|
||||
$audit_table = $wpdb->prefix . 'wpmind_api_audit_log';
|
||||
$wpdb->insert(
|
||||
$audit_table,
|
||||
[
|
||||
'event_type' => 'api_stream_request',
|
||||
'key_id' => $key_id,
|
||||
'actor_user_id' => 0,
|
||||
'request_id' => $request_id,
|
||||
'ip_hash' => hash( 'sha256', sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) ),
|
||||
'user_agent' => sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ),
|
||||
'detail_json' => wp_json_encode( [
|
||||
'tokens_used' => $result->tokens_used,
|
||||
'finish_reason' => $result->finish_reason,
|
||||
'stream' => true,
|
||||
] ),
|
||||
'created_at' => current_time( 'mysql', true ),
|
||||
],
|
||||
[ '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s' ]
|
||||
);
|
||||
|
||||
// Usage upsert.
|
||||
$usage_table = $wpdb->prefix . 'wpmind_api_key_usage';
|
||||
$window_month = gmdate( 'Y-m' );
|
||||
$now = current_time( 'mysql', true );
|
||||
$tokens = $result->tokens_used;
|
||||
|
||||
$wpdb->query( $wpdb->prepare(
|
||||
"INSERT INTO %i (key_id, window_month, request_count, input_tokens, output_tokens, total_tokens, total_cost_usd, updated_at)
|
||||
VALUES (%s, %s, 1, 0, %d, %d, 0, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
request_count = request_count + 1,
|
||||
output_tokens = output_tokens + %d,
|
||||
total_tokens = total_tokens + %d,
|
||||
updated_at = %s",
|
||||
$usage_table,
|
||||
$key_id,
|
||||
$window_month,
|
||||
$tokens,
|
||||
$tokens,
|
||||
$now,
|
||||
$tokens,
|
||||
$tokens,
|
||||
$now
|
||||
) );
|
||||
}
|
||||
}
|
||||
109
modules/api-gateway/includes/Admin/AuditLogRepository.php
Normal file
109
modules/api-gateway/includes/Admin/AuditLogRepository.php
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
/**
|
||||
* Audit Log Repository
|
||||
*
|
||||
* Database access layer for the API audit log.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway\Admin
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway\Admin;
|
||||
|
||||
/**
|
||||
* Class AuditLogRepository
|
||||
*
|
||||
* Read-only queries for the wpmind_api_audit_log table.
|
||||
*/
|
||||
class AuditLogRepository {
|
||||
|
||||
/**
|
||||
* Get the full table name.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'wpmind_api_audit_log';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WHERE clause and parameter values from filters.
|
||||
*
|
||||
* @param array $filters Optional filters: key_id, event_type, date_from, date_to.
|
||||
* @return array{string, array} Tuple of [ where_sql, values ].
|
||||
*/
|
||||
private static function build_where_clause( array $filters ): array {
|
||||
$where = [];
|
||||
$values = [];
|
||||
|
||||
if ( ! empty( $filters['key_id'] ) ) {
|
||||
$where[] = 'key_id = %s';
|
||||
$values[] = sanitize_text_field( $filters['key_id'] );
|
||||
}
|
||||
|
||||
if ( ! empty( $filters['event_type'] ) ) {
|
||||
$where[] = 'event_type = %s';
|
||||
$values[] = sanitize_text_field( $filters['event_type'] );
|
||||
}
|
||||
|
||||
if ( ! empty( $filters['date_from'] ) ) {
|
||||
$where[] = 'created_at >= %s';
|
||||
$values[] = sanitize_text_field( $filters['date_from'] ) . ' 00:00:00';
|
||||
}
|
||||
|
||||
if ( ! empty( $filters['date_to'] ) ) {
|
||||
$where[] = 'created_at <= %s';
|
||||
$values[] = sanitize_text_field( $filters['date_to'] ) . ' 23:59:59';
|
||||
}
|
||||
|
||||
$where_sql = ! empty( $where ) ? 'WHERE ' . implode( ' AND ', $where ) : '';
|
||||
|
||||
return [ $where_sql, $values ];
|
||||
}
|
||||
|
||||
/**
|
||||
* List audit log entries with pagination and filters.
|
||||
*
|
||||
* @param array $filters Optional filters: key_id, event_type, date_from, date_to.
|
||||
* @param int $page Page number (1-based).
|
||||
* @param int $per_page Items per page.
|
||||
* @return array Array of row arrays.
|
||||
*/
|
||||
public static function list_logs( array $filters = [], int $page = 1, int $per_page = 20 ): array {
|
||||
global $wpdb;
|
||||
|
||||
[ $where_sql, $filter_values ] = self::build_where_clause( $filters );
|
||||
|
||||
$offset = max( 0, ( $page - 1 ) * $per_page );
|
||||
|
||||
$sql = "SELECT * FROM %i {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params = array_merge( [ self::table() ], $filter_values, [ $per_page, $offset ] );
|
||||
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare( $sql, ...$params ),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return is_array( $results ) ? $results : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total audit log entries matching filters.
|
||||
*
|
||||
* @param array $filters Optional filters: key_id, event_type, date_from, date_to.
|
||||
* @return int Total count.
|
||||
*/
|
||||
public static function count_logs( array $filters = [] ): int {
|
||||
global $wpdb;
|
||||
|
||||
[ $where_sql, $filter_values ] = self::build_where_clause( $filters );
|
||||
|
||||
$sql = "SELECT COUNT(*) FROM %i {$where_sql}";
|
||||
$params = array_merge( [ self::table() ], $filter_values );
|
||||
|
||||
return (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$params ) );
|
||||
}
|
||||
}
|
||||
303
modules/api-gateway/includes/Admin/GatewayAjaxController.php
Normal file
303
modules/api-gateway/includes/Admin/GatewayAjaxController.php
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
<?php
|
||||
/**
|
||||
* Gateway AJAX Controller
|
||||
*
|
||||
* Handles admin AJAX requests for the API Gateway module.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway\Admin
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway\Admin;
|
||||
|
||||
use WPMind\Modules\ApiGateway\Auth\ApiKeyManager;
|
||||
use WPMind\Modules\ApiGateway\Auth\ApiKeyRepository;
|
||||
|
||||
/**
|
||||
* Class GatewayAjaxController
|
||||
*
|
||||
* Registers and handles all AJAX actions for the API Gateway settings page.
|
||||
*/
|
||||
class GatewayAjaxController {
|
||||
|
||||
/**
|
||||
* Register AJAX hooks.
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_action( 'wp_ajax_wpmind_save_gateway_settings', [ $this, 'ajax_save_gateway_settings' ] );
|
||||
add_action( 'wp_ajax_wpmind_create_api_key', [ $this, 'ajax_create_api_key' ] );
|
||||
add_action( 'wp_ajax_wpmind_list_api_keys', [ $this, 'ajax_list_api_keys' ] );
|
||||
add_action( 'wp_ajax_wpmind_revoke_api_key', [ $this, 'ajax_revoke_api_key' ] );
|
||||
add_action( 'wp_ajax_wpmind_update_api_key', [ $this, 'ajax_update_api_key' ] );
|
||||
add_action( 'wp_ajax_wpmind_list_audit_logs', [ $this, 'ajax_list_audit_logs' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Save gateway settings.
|
||||
*/
|
||||
public function ajax_save_gateway_settings(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$enabled = ! empty( $_POST['gateway_enabled'] );
|
||||
$sse_global_limit = absint( wp_unslash( $_POST['sse_global_limit'] ?? 20 ) );
|
||||
$default_rpm = absint( wp_unslash( $_POST['default_rpm'] ?? 60 ) );
|
||||
$default_tpm = absint( wp_unslash( $_POST['default_tpm'] ?? 100000 ) );
|
||||
$max_body_bytes = absint( wp_unslash( $_POST['max_body_bytes'] ?? 0 ) );
|
||||
$max_tokens_cap = absint( wp_unslash( $_POST['max_tokens_cap'] ?? 0 ) );
|
||||
$log_prompts = ! empty( $_POST['log_prompts'] );
|
||||
|
||||
// Clamp values to reasonable ranges.
|
||||
$sse_global_limit = max( 1, min( 200, $sse_global_limit ) );
|
||||
$default_rpm = max( 1, min( 10000, $default_rpm ) );
|
||||
$default_tpm = max( 1000, min( 10000000, $default_tpm ) );
|
||||
$max_body_bytes = min( 104857600, $max_body_bytes ); // 100 MB max.
|
||||
$max_tokens_cap = min( 1000000, $max_tokens_cap );
|
||||
|
||||
update_option( 'wpmind_gateway_enabled', $enabled ? '1' : '0' );
|
||||
update_option( 'wpmind_gateway_sse_global_limit', $sse_global_limit );
|
||||
update_option( 'wpmind_gateway_default_rpm', $default_rpm );
|
||||
update_option( 'wpmind_gateway_default_tpm', $default_tpm );
|
||||
update_option( 'wpmind_gateway_max_body_bytes', $max_body_bytes );
|
||||
update_option( 'wpmind_gateway_max_tokens_cap', $max_tokens_cap );
|
||||
update_option( 'wpmind_gateway_log_prompts', $log_prompts ? '1' : '0' );
|
||||
|
||||
wp_send_json_success( [ 'message' => __( '网关设置已保存', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API key.
|
||||
*/
|
||||
public function ajax_create_api_key(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$name = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) );
|
||||
$rpm_limit = absint( wp_unslash( $_POST['rpm_limit'] ?? 60 ) );
|
||||
$tpm_limit = absint( wp_unslash( $_POST['tpm_limit'] ?? 100000 ) );
|
||||
$concurrency_limit = absint( wp_unslash( $_POST['concurrency_limit'] ?? 2 ) );
|
||||
$monthly_budget = (float) wp_unslash( $_POST['monthly_budget_usd'] ?? 0 );
|
||||
$ip_whitelist_raw = sanitize_text_field( wp_unslash( $_POST['ip_whitelist'] ?? '' ) );
|
||||
$expires_at = sanitize_text_field( wp_unslash( $_POST['expires_at'] ?? '' ) );
|
||||
|
||||
if ( empty( $name ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '请输入 Key 名称', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
// Parse IP whitelist.
|
||||
$ip_whitelist = [];
|
||||
if ( ! empty( $ip_whitelist_raw ) ) {
|
||||
$ips = array_map( 'trim', explode( ',', $ip_whitelist_raw ) );
|
||||
foreach ( $ips as $ip ) {
|
||||
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
|
||||
$ip_whitelist[] = $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp values.
|
||||
$rpm_limit = max( 1, min( 10000, $rpm_limit ) );
|
||||
$tpm_limit = max( 1000, min( 10000000, $tpm_limit ) );
|
||||
$concurrency_limit = max( 1, min( 100, $concurrency_limit ) );
|
||||
$monthly_budget = max( 0.0, $monthly_budget );
|
||||
|
||||
$attrs = [
|
||||
'name' => $name,
|
||||
'owner_user_id' => get_current_user_id(),
|
||||
'rpm_limit' => $rpm_limit,
|
||||
'tpm_limit' => $tpm_limit,
|
||||
'concurrency_limit' => $concurrency_limit,
|
||||
'monthly_budget_usd' => $monthly_budget,
|
||||
];
|
||||
|
||||
if ( ! empty( $ip_whitelist ) ) {
|
||||
$attrs['ip_whitelist'] = $ip_whitelist;
|
||||
}
|
||||
|
||||
if ( ! empty( $expires_at ) ) {
|
||||
$ts = strtotime( $expires_at );
|
||||
if ( false === $ts ) {
|
||||
wp_send_json_error( [ 'message' => __( '过期时间格式无效', 'wpmind' ) ] );
|
||||
}
|
||||
$attrs['expires_at'] = gmdate( 'Y-m-d H:i:s', $ts );
|
||||
}
|
||||
|
||||
$result = ApiKeyManager::create_api_key( $attrs );
|
||||
|
||||
wp_send_json_success( [
|
||||
'message' => __( 'API Key 创建成功', 'wpmind' ),
|
||||
'raw_key' => $result['raw_key'],
|
||||
'key_id' => $result['key_id'],
|
||||
'key_prefix' => $result['key_prefix'],
|
||||
] );
|
||||
}
|
||||
|
||||
/**
|
||||
* List all API keys with usage data.
|
||||
*/
|
||||
public function ajax_list_api_keys(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$keys = ApiKeyRepository::list_all_with_usage();
|
||||
|
||||
wp_send_json_success( [ 'keys' => $keys ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key.
|
||||
*/
|
||||
public function ajax_revoke_api_key(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$key_id = sanitize_text_field( wp_unslash( $_POST['key_id'] ?? '' ) );
|
||||
|
||||
if ( empty( $key_id ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '缺少 Key ID', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
// Verify key exists.
|
||||
$row = ApiKeyRepository::find_by_key_id( $key_id );
|
||||
if ( $row === null ) {
|
||||
wp_send_json_error( [ 'message' => __( 'API Key 不存在', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
if ( $row['status'] === 'revoked' ) {
|
||||
wp_send_json_error( [ 'message' => __( '该 Key 已被吊销', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
ApiKeyRepository::revoke_key( $key_id, get_current_user_id(), 'admin_revoke' );
|
||||
|
||||
wp_send_json_success( [ 'message' => __( 'API Key 已吊销', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing API key.
|
||||
*/
|
||||
public function ajax_update_api_key(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$key_id = sanitize_text_field( wp_unslash( $_POST['key_id'] ?? '' ) );
|
||||
|
||||
if ( empty( $key_id ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '缺少 Key ID', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$row = ApiKeyRepository::find_by_key_id( $key_id );
|
||||
if ( $row === null ) {
|
||||
wp_send_json_error( [ 'message' => __( 'API Key 不存在', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$data = [];
|
||||
|
||||
if ( isset( $_POST['name'] ) ) {
|
||||
$data['name'] = sanitize_text_field( wp_unslash( $_POST['name'] ) );
|
||||
}
|
||||
if ( isset( $_POST['rpm_limit'] ) ) {
|
||||
$data['rpm_limit'] = max( 1, min( 10000, absint( wp_unslash( $_POST['rpm_limit'] ) ) ) );
|
||||
}
|
||||
if ( isset( $_POST['tpm_limit'] ) ) {
|
||||
$data['tpm_limit'] = max( 1000, min( 10000000, absint( wp_unslash( $_POST['tpm_limit'] ) ) ) );
|
||||
}
|
||||
if ( isset( $_POST['concurrency_limit'] ) ) {
|
||||
$data['concurrency_limit'] = max( 1, min( 100, absint( wp_unslash( $_POST['concurrency_limit'] ) ) ) );
|
||||
}
|
||||
if ( isset( $_POST['monthly_budget_usd'] ) ) {
|
||||
$data['monthly_budget_usd'] = max( 0.0, (float) wp_unslash( $_POST['monthly_budget_usd'] ) );
|
||||
}
|
||||
if ( isset( $_POST['ip_whitelist'] ) ) {
|
||||
$raw = sanitize_text_field( wp_unslash( $_POST['ip_whitelist'] ) );
|
||||
$ips = [];
|
||||
if ( ! empty( $raw ) ) {
|
||||
foreach ( array_map( 'trim', explode( ',', $raw ) ) as $ip ) {
|
||||
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
|
||||
$ips[] = $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
$data['ip_whitelist'] = ! empty( $ips ) ? $ips : '';
|
||||
}
|
||||
if ( isset( $_POST['expires_at'] ) ) {
|
||||
$exp = sanitize_text_field( wp_unslash( $_POST['expires_at'] ) );
|
||||
if ( ! empty( $exp ) ) {
|
||||
$ts = strtotime( $exp );
|
||||
if ( false === $ts ) {
|
||||
wp_send_json_error( [ 'message' => __( '过期时间格式无效', 'wpmind' ) ] );
|
||||
}
|
||||
$data['expires_at'] = gmdate( 'Y-m-d H:i:s', $ts );
|
||||
} else {
|
||||
$data['expires_at'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $data ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '没有需要更新的字段', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$ok = ApiKeyRepository::update_key( $key_id, $data );
|
||||
|
||||
if ( $ok ) {
|
||||
wp_send_json_success( [ 'message' => __( 'API Key 已更新', 'wpmind' ) ] );
|
||||
} else {
|
||||
wp_send_json_error( [ 'message' => __( '更新失败', 'wpmind' ) ] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List audit logs with pagination and filters.
|
||||
*/
|
||||
public function ajax_list_audit_logs(): void {
|
||||
check_ajax_referer( 'wpmind_ajax', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( '权限不足', 'wpmind' ) ] );
|
||||
}
|
||||
|
||||
$page = max( 1, absint( $_POST['page'] ?? 1 ) );
|
||||
$per_page = 20;
|
||||
|
||||
$filters = [];
|
||||
if ( ! empty( $_POST['key_id'] ) ) {
|
||||
$filters['key_id'] = sanitize_text_field( wp_unslash( $_POST['key_id'] ) );
|
||||
}
|
||||
if ( ! empty( $_POST['event_type'] ) ) {
|
||||
$filters['event_type'] = sanitize_text_field( wp_unslash( $_POST['event_type'] ) );
|
||||
}
|
||||
if ( ! empty( $_POST['date_from'] ) ) {
|
||||
$filters['date_from'] = sanitize_text_field( wp_unslash( $_POST['date_from'] ) );
|
||||
}
|
||||
if ( ! empty( $_POST['date_to'] ) ) {
|
||||
$filters['date_to'] = sanitize_text_field( wp_unslash( $_POST['date_to'] ) );
|
||||
}
|
||||
|
||||
$logs = AuditLogRepository::list_logs( $filters, $page, $per_page );
|
||||
$total = AuditLogRepository::count_logs( $filters );
|
||||
|
||||
wp_send_json_success( [
|
||||
'logs' => $logs,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
'total_pages' => (int) ceil( $total / $per_page ),
|
||||
] );
|
||||
}
|
||||
}
|
||||
164
modules/api-gateway/includes/Auth/ApiKeyAuthResult.php
Normal file
164
modules/api-gateway/includes/Auth/ApiKeyAuthResult.php
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<?php
|
||||
/**
|
||||
* API Key Auth Result DTO
|
||||
*
|
||||
* Immutable data transfer object representing an authenticated API key.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway\Auth
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway\Auth;
|
||||
|
||||
/**
|
||||
* Class ApiKeyAuthResult
|
||||
*
|
||||
* Read-only representation of an authenticated API key.
|
||||
*/
|
||||
class ApiKeyAuthResult {
|
||||
|
||||
/**
|
||||
* The 12-character key identifier.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public readonly string $key_id;
|
||||
|
||||
/**
|
||||
* WordPress user ID of the key owner, or null.
|
||||
*
|
||||
* @var int|null
|
||||
*/
|
||||
public readonly ?int $owner_user_id;
|
||||
|
||||
/**
|
||||
* Allowed provider IDs.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public readonly array $allowed_providers;
|
||||
|
||||
/**
|
||||
* Requests per minute limit.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public readonly int $rpm_limit;
|
||||
|
||||
/**
|
||||
* Tokens per minute limit.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public readonly int $tpm_limit;
|
||||
|
||||
/**
|
||||
* Concurrency limit.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public readonly int $concurrency_limit;
|
||||
|
||||
/**
|
||||
* Monthly budget in USD.
|
||||
*
|
||||
* @var float
|
||||
*/
|
||||
public readonly float $monthly_budget_usd;
|
||||
|
||||
/**
|
||||
* Construct from a database row array.
|
||||
*
|
||||
* @param array $row Associative array from the api_keys table.
|
||||
*/
|
||||
public function __construct( array $row ) {
|
||||
$this->key_id = (string) $row['key_id'];
|
||||
$this->owner_user_id = isset( $row['owner_user_id'] ) ? (int) $row['owner_user_id'] : null;
|
||||
$this->allowed_providers = self::decode_json_array( $row['allowed_providers'] ?? null );
|
||||
$this->rpm_limit = (int) ( $row['rpm_limit'] ?? 60 );
|
||||
$this->tpm_limit = (int) ( $row['tpm_limit'] ?? 100000 );
|
||||
$this->concurrency_limit = (int) ( $row['concurrency_limit'] ?? 2 );
|
||||
$this->monthly_budget_usd = (float) ( $row['monthly_budget_usd'] ?? 0.0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key identifier.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_key_id(): string {
|
||||
return $this->key_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the owner user ID.
|
||||
*
|
||||
* @return int|null
|
||||
*/
|
||||
public function get_owner_user_id(): ?int {
|
||||
return $this->owner_user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get allowed providers.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_allowed_providers(): array {
|
||||
return $this->allowed_providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get requests per minute limit.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_rpm_limit(): int {
|
||||
return $this->rpm_limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tokens per minute limit.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_tpm_limit(): int {
|
||||
return $this->tpm_limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get concurrency limit.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_concurrency_limit(): int {
|
||||
return $this->concurrency_limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monthly budget in USD.
|
||||
*
|
||||
* @return float
|
||||
*/
|
||||
public function get_monthly_budget_usd(): float {
|
||||
return $this->monthly_budget_usd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a JSON string to an array, returning empty array on failure.
|
||||
*
|
||||
* @param string|null $json JSON string or null.
|
||||
* @return array Decoded array.
|
||||
*/
|
||||
private static function decode_json_array( ?string $json ): array {
|
||||
if ( $json === null || $json === '' ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode( $json, true );
|
||||
|
||||
return is_array( $decoded ) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
70
modules/api-gateway/includes/Auth/ApiKeyHasher.php
Normal file
70
modules/api-gateway/includes/Auth/ApiKeyHasher.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
/**
|
||||
* API Key Hasher
|
||||
*
|
||||
* Handles secret hashing and constant-time verification for API keys.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway\Auth
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway\Auth;
|
||||
|
||||
/**
|
||||
* Class ApiKeyHasher
|
||||
*
|
||||
* Cryptographic utilities for API key secrets.
|
||||
*/
|
||||
class ApiKeyHasher {
|
||||
|
||||
/**
|
||||
* Dummy salt hex for timing-safe lookups.
|
||||
*
|
||||
* Used when key_id is not found to prevent timing enumeration.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const DUMMY_SALT_HEX = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6';
|
||||
|
||||
/**
|
||||
* Dummy hash hex for timing-safe lookups.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const DUMMY_HASH_HEX = '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
/**
|
||||
* Generate a random 32-character hex salt.
|
||||
*
|
||||
* @return string 32-character hex string.
|
||||
*/
|
||||
public static function make_salt_hex(): string {
|
||||
return bin2hex( random_bytes( 16 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a secret with the given salt.
|
||||
*
|
||||
* @param string $secret The plaintext secret.
|
||||
* @param string $salt_hex The hex-encoded salt.
|
||||
* @return string 64-character hex hash.
|
||||
*/
|
||||
public static function hash_secret( string $secret, string $salt_hex ): string {
|
||||
return hash( 'sha256', $salt_hex . $secret );
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time verification of a secret against an expected hash.
|
||||
*
|
||||
* @param string $secret The plaintext secret to verify.
|
||||
* @param string $salt_hex The hex-encoded salt.
|
||||
* @param string $expected The expected hash to compare against.
|
||||
* @return bool True if the secret matches.
|
||||
*/
|
||||
public static function constant_time_verify( string $secret, string $salt_hex, string $expected ): bool {
|
||||
$computed = self::hash_secret( $secret, $salt_hex );
|
||||
return hash_equals( $expected, $computed );
|
||||
}
|
||||
}
|
||||
270
modules/api-gateway/includes/Auth/ApiKeyManager.php
Normal file
270
modules/api-gateway/includes/Auth/ApiKeyManager.php
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
<?php
|
||||
/**
|
||||
* API Key Manager
|
||||
*
|
||||
* High-level API key creation and authentication logic.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway\Auth
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway\Auth;
|
||||
|
||||
/**
|
||||
* Class ApiKeyManager
|
||||
*
|
||||
* Orchestrates key generation, storage, and bearer token authentication.
|
||||
*/
|
||||
class ApiKeyManager {
|
||||
|
||||
/**
|
||||
* Regex pattern for parsing a full API key.
|
||||
*
|
||||
* Format: sk_mind_{KEY_ID}_{SECRET}
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const KEY_PATTERN = '/^sk_mind_([A-Z0-9]{12})_([A-Za-z0-9_-]{43})$/';
|
||||
|
||||
/**
|
||||
* Create a new API key.
|
||||
*
|
||||
* @param array $attrs Optional attributes (name, owner_user_id, etc.).
|
||||
* @return array Contains 'raw_key', 'key_id', 'key_prefix', and 'id'.
|
||||
*/
|
||||
public static function create_api_key( array $attrs = [] ): array {
|
||||
$key_id = self::generate_key_id();
|
||||
$secret = self::generate_secret();
|
||||
|
||||
$salt_hex = ApiKeyHasher::make_salt_hex();
|
||||
$secret_hash = ApiKeyHasher::hash_secret( $secret, $salt_hex );
|
||||
$key_prefix = substr( $secret, 0, 8 );
|
||||
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
$data = [
|
||||
'key_id' => $key_id,
|
||||
'key_prefix' => $key_prefix,
|
||||
'secret_hash' => $secret_hash,
|
||||
'secret_salt' => $salt_hex,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
// Merge optional attributes.
|
||||
$allowed_fields = [
|
||||
'name',
|
||||
'owner_user_id',
|
||||
'allowed_providers',
|
||||
'rpm_limit',
|
||||
'tpm_limit',
|
||||
'concurrency_limit',
|
||||
'monthly_budget_usd',
|
||||
'ip_whitelist',
|
||||
'status',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
foreach ( $allowed_fields as $field ) {
|
||||
if ( array_key_exists( $field, $attrs ) ) {
|
||||
$value = $attrs[ $field ];
|
||||
// Encode arrays to JSON for storage.
|
||||
if ( is_array( $value ) ) {
|
||||
$value = wp_json_encode( $value );
|
||||
}
|
||||
$data[ $field ] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$id = ApiKeyRepository::insert_key( $data );
|
||||
|
||||
$raw_key = "sk_mind_{$key_id}_{$secret}";
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'key_id' => $key_id,
|
||||
'key_prefix' => $key_prefix,
|
||||
'raw_key' => $raw_key,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a Bearer token from the Authorization header.
|
||||
*
|
||||
* @param string $authorization The full Authorization header value.
|
||||
* @param string $client_ip The client IP address.
|
||||
* @return ApiKeyAuthResult|\WP_Error Auth result or error.
|
||||
*/
|
||||
public static function authenticate_bearer_header( string $authorization, string $client_ip ): ApiKeyAuthResult|\WP_Error {
|
||||
// Extract Bearer token.
|
||||
if ( stripos( $authorization, 'Bearer ' ) !== 0 ) {
|
||||
return new \WP_Error(
|
||||
'invalid_auth_header',
|
||||
'Authorization header must use Bearer scheme.',
|
||||
[ 'status' => 401 ]
|
||||
);
|
||||
}
|
||||
|
||||
$raw_key = substr( $authorization, 7 );
|
||||
$parsed = self::parse_api_key( $raw_key );
|
||||
|
||||
if ( $parsed === null ) {
|
||||
return new \WP_Error(
|
||||
'invalid_api_key_format',
|
||||
'API key format is invalid.',
|
||||
[ 'status' => 401 ]
|
||||
);
|
||||
}
|
||||
|
||||
$key_id = $parsed['key_id'];
|
||||
$secret = $parsed['secret'];
|
||||
|
||||
// Look up the key row.
|
||||
$row = ApiKeyRepository::find_by_key_id( $key_id );
|
||||
|
||||
// Constant-time verify even when key not found (anti timing enumeration).
|
||||
if ( $row === null ) {
|
||||
ApiKeyHasher::constant_time_verify(
|
||||
$secret,
|
||||
ApiKeyHasher::DUMMY_SALT_HEX,
|
||||
ApiKeyHasher::DUMMY_HASH_HEX
|
||||
);
|
||||
|
||||
return new \WP_Error(
|
||||
'api_key_not_found',
|
||||
'Invalid API key.',
|
||||
[ 'status' => 401 ]
|
||||
);
|
||||
}
|
||||
|
||||
// Verify secret.
|
||||
$valid = ApiKeyHasher::constant_time_verify(
|
||||
$secret,
|
||||
$row['secret_salt'],
|
||||
$row['secret_hash']
|
||||
);
|
||||
|
||||
if ( ! $valid ) {
|
||||
return new \WP_Error(
|
||||
'api_key_invalid_secret',
|
||||
'Invalid API key.',
|
||||
[ 'status' => 401 ]
|
||||
);
|
||||
}
|
||||
|
||||
// Check status.
|
||||
if ( $row['status'] !== 'active' ) {
|
||||
return new \WP_Error(
|
||||
'api_key_inactive',
|
||||
'API key is ' . $row['status'] . '.',
|
||||
[ 'status' => 403 ]
|
||||
);
|
||||
}
|
||||
|
||||
// Check expiration.
|
||||
if ( self::is_key_expired( $row ) ) {
|
||||
return new \WP_Error(
|
||||
'api_key_expired',
|
||||
'API key has expired.',
|
||||
[ 'status' => 403 ]
|
||||
);
|
||||
}
|
||||
|
||||
// Check IP whitelist.
|
||||
if ( ! self::is_ip_allowed( $row, $client_ip ) ) {
|
||||
return new \WP_Error(
|
||||
'api_key_ip_denied',
|
||||
'Request IP is not allowed for this API key.',
|
||||
[ 'status' => 403 ]
|
||||
);
|
||||
}
|
||||
|
||||
// Update last used timestamp (fire and forget).
|
||||
ApiKeyRepository::update_last_used( $key_id );
|
||||
|
||||
return new ApiKeyAuthResult( $row );
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw API key string into its components.
|
||||
*
|
||||
* @param string $raw_key The full key string (sk_mind_...).
|
||||
* @return array|null Array with 'key_id' and 'secret', or null on failure.
|
||||
*/
|
||||
public static function parse_api_key( string $raw_key ): ?array {
|
||||
if ( ! preg_match( self::KEY_PATTERN, $raw_key, $matches ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'key_id' => $matches[1],
|
||||
'secret' => $matches[2],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key row is expired.
|
||||
*
|
||||
* @param array $row Database row.
|
||||
* @return bool True if expired.
|
||||
*/
|
||||
public static function is_key_expired( array $row ): bool {
|
||||
if ( empty( $row['expires_at'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expires = strtotime( $row['expires_at'] );
|
||||
|
||||
return $expires !== false && $expires < time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a client IP is allowed by the key's whitelist.
|
||||
*
|
||||
* @param array $row Database row.
|
||||
* @param string $client_ip Client IP address.
|
||||
* @return bool True if allowed.
|
||||
*/
|
||||
public static function is_ip_allowed( array $row, string $client_ip ): bool {
|
||||
if ( empty( $row['ip_whitelist'] ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$whitelist = json_decode( $row['ip_whitelist'], true );
|
||||
|
||||
if ( ! is_array( $whitelist ) || empty( $whitelist ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array( $client_ip, $whitelist, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a 12-character base32-like key ID.
|
||||
*
|
||||
* @return string 12-character uppercase alphanumeric string.
|
||||
*/
|
||||
private static function generate_key_id(): string {
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
$len = strlen( $alphabet );
|
||||
$id = '';
|
||||
|
||||
$bytes = random_bytes( 12 );
|
||||
for ( $i = 0; $i < 12; $i++ ) {
|
||||
$id .= $alphabet[ ord( $bytes[ $i ] ) % $len ];
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a 43-character base64url secret.
|
||||
*
|
||||
* @return string 43-character base64url string.
|
||||
*/
|
||||
private static function generate_secret(): string {
|
||||
return rtrim( strtr( base64_encode( random_bytes( 32 ) ), '+/', '-_' ), '=' );
|
||||
}
|
||||
}
|
||||
367
modules/api-gateway/includes/Auth/ApiKeyRepository.php
Normal file
367
modules/api-gateway/includes/Auth/ApiKeyRepository.php
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
<?php
|
||||
/**
|
||||
* API Key Repository
|
||||
*
|
||||
* Database access layer for API keys.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway\Auth
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway\Auth;
|
||||
|
||||
/**
|
||||
* Class ApiKeyRepository
|
||||
*
|
||||
* CRUD operations for the wpmind_api_keys table.
|
||||
*/
|
||||
class ApiKeyRepository {
|
||||
|
||||
/**
|
||||
* Cache group for API key metadata.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const CACHE_GROUP = 'wpmind_api_keys';
|
||||
|
||||
/**
|
||||
* Cache TTL in seconds.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const CACHE_TTL = 60;
|
||||
|
||||
/**
|
||||
* Get the full table name.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'wpmind_api_keys';
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cache for a given key_id.
|
||||
*
|
||||
* @param string $key_id The 12-character key identifier.
|
||||
*/
|
||||
private static function invalidate_cache( string $key_id ): void {
|
||||
wp_cache_delete( $key_id, self::CACHE_GROUP );
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new API key row.
|
||||
*
|
||||
* @param array $data Column => value pairs.
|
||||
* @return int Inserted row ID.
|
||||
*/
|
||||
public static function insert_key( array $data ): int {
|
||||
global $wpdb;
|
||||
|
||||
$format = [];
|
||||
foreach ( $data as $col => $val ) {
|
||||
if ( is_int( $val ) ) {
|
||||
$format[] = '%d';
|
||||
} elseif ( is_float( $val ) ) {
|
||||
$format[] = '%f';
|
||||
} else {
|
||||
$format[] = '%s';
|
||||
}
|
||||
}
|
||||
|
||||
$result = $wpdb->insert( self::table(), $data, $format );
|
||||
|
||||
if ( false === $result ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $wpdb->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a key row by its unique key_id.
|
||||
*
|
||||
* @param string $key_id The 12-character key identifier.
|
||||
* @return array|null Row as associative array, or null if not found.
|
||||
*/
|
||||
public static function find_by_key_id( string $key_id ): ?array {
|
||||
// Check cache first.
|
||||
$cached = wp_cache_get( $key_id, self::CACHE_GROUP );
|
||||
if ( is_array( $cached ) ) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM %i WHERE key_id = %s LIMIT 1",
|
||||
self::table(),
|
||||
$key_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( is_array( $row ) ) {
|
||||
wp_cache_set( $key_id, $row, self::CACHE_GROUP, self::CACHE_TTL );
|
||||
return $row;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last_used_at timestamp for a key.
|
||||
*
|
||||
* @param string $key_id The 12-character key identifier.
|
||||
*/
|
||||
public static function update_last_used( string $key_id ): void {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->update(
|
||||
self::table(),
|
||||
[
|
||||
'last_used_at' => current_time( 'mysql', true ),
|
||||
'updated_at' => current_time( 'mysql', true ),
|
||||
],
|
||||
[ 'key_id' => $key_id ],
|
||||
[ '%s', '%s' ],
|
||||
[ '%s' ]
|
||||
);
|
||||
|
||||
self::invalidate_cache( $key_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key.
|
||||
*
|
||||
* @param string $key_id The 12-character key identifier.
|
||||
* @param int $actor_user_id The user performing the revocation.
|
||||
* @param string $reason Reason for revocation.
|
||||
*/
|
||||
public static function revoke_key( string $key_id, int $actor_user_id, string $reason ): void {
|
||||
global $wpdb;
|
||||
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
$wpdb->update(
|
||||
self::table(),
|
||||
[
|
||||
'status' => 'revoked',
|
||||
'revoked_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[ 'key_id' => $key_id ],
|
||||
[ '%s', '%s', '%s' ],
|
||||
[ '%s' ]
|
||||
);
|
||||
|
||||
$audit_table = $wpdb->prefix . 'wpmind_api_audit_log';
|
||||
$wpdb->insert(
|
||||
$audit_table,
|
||||
[
|
||||
'event_type' => 'key_revoked',
|
||||
'key_id' => $key_id,
|
||||
'actor_user_id' => $actor_user_id,
|
||||
'detail_json' => wp_json_encode( [ 'reason' => $reason ] ),
|
||||
'created_at' => $now,
|
||||
],
|
||||
[ '%s', '%s', '%d', '%s', '%s' ]
|
||||
);
|
||||
|
||||
self::invalidate_cache( $key_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* List API keys with pagination.
|
||||
*
|
||||
* @param int $page Page number (1-based).
|
||||
* @param int $per_page Items per page.
|
||||
* @return array Array of row arrays.
|
||||
*/
|
||||
public static function list_keys( int $page = 1, int $per_page = 20 ): array {
|
||||
global $wpdb;
|
||||
|
||||
$offset = max( 0, ( $page - 1 ) * $per_page );
|
||||
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM %i ORDER BY created_at DESC LIMIT %d OFFSET %d",
|
||||
self::table(),
|
||||
$per_page,
|
||||
$offset
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return is_array( $results ) ? $results : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total API keys.
|
||||
*
|
||||
* @return int Total number of keys.
|
||||
*/
|
||||
public static function count_keys(): int {
|
||||
global $wpdb;
|
||||
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare( "SELECT COUNT(*) FROM %i", self::table() )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count active API keys.
|
||||
*
|
||||
* @return int Number of active keys.
|
||||
*/
|
||||
public static function count_active_keys(): int {
|
||||
global $wpdb;
|
||||
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM %i WHERE status = %s",
|
||||
self::table(),
|
||||
'active'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total request count for the current month across all keys.
|
||||
*
|
||||
* @return int Total requests this month.
|
||||
*/
|
||||
public static function get_month_total_requests(): int {
|
||||
global $wpdb;
|
||||
|
||||
$usage_table = $wpdb->prefix . 'wpmind_api_key_usage';
|
||||
$window_month = gmdate( 'Y-m' );
|
||||
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COALESCE(SUM(request_count), 0) FROM %i WHERE window_month = %s",
|
||||
$usage_table,
|
||||
$window_month
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update editable fields for an API key.
|
||||
*
|
||||
* @param string $key_id The 12-character key identifier.
|
||||
* @param array $data Column => value pairs (whitelisted).
|
||||
* @return bool True on success, false on failure.
|
||||
*/
|
||||
public static function update_key( string $key_id, array $data ): bool {
|
||||
global $wpdb;
|
||||
|
||||
$allowed = [
|
||||
'name',
|
||||
'rpm_limit',
|
||||
'tpm_limit',
|
||||
'concurrency_limit',
|
||||
'monthly_budget_usd',
|
||||
'ip_whitelist',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
$update = [];
|
||||
$format = [];
|
||||
|
||||
foreach ( $data as $col => $val ) {
|
||||
if ( ! in_array( $col, $allowed, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch ( $col ) {
|
||||
case 'name':
|
||||
$update[ $col ] = sanitize_text_field( (string) $val );
|
||||
$format[] = '%s';
|
||||
break;
|
||||
|
||||
case 'rpm_limit':
|
||||
case 'tpm_limit':
|
||||
case 'concurrency_limit':
|
||||
$update[ $col ] = absint( $val );
|
||||
$format[] = '%d';
|
||||
break;
|
||||
|
||||
case 'monthly_budget_usd':
|
||||
$update[ $col ] = max( 0.0, (float) $val );
|
||||
$format[] = '%f';
|
||||
break;
|
||||
|
||||
case 'ip_whitelist':
|
||||
$update[ $col ] = is_array( $val )
|
||||
? wp_json_encode( $val )
|
||||
: sanitize_text_field( (string) $val );
|
||||
$format[] = '%s';
|
||||
break;
|
||||
|
||||
case 'expires_at':
|
||||
$update[ $col ] = empty( $val ) ? null : sanitize_text_field( (string) $val );
|
||||
$format[] = '%s';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $update ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$update['updated_at'] = current_time( 'mysql', true );
|
||||
$format[] = '%s';
|
||||
|
||||
$result = $wpdb->update(
|
||||
self::table(),
|
||||
$update,
|
||||
[ 'key_id' => $key_id ],
|
||||
$format,
|
||||
[ '%s' ]
|
||||
);
|
||||
|
||||
self::invalidate_cache( $key_id );
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all keys with current month usage, excluding secret columns.
|
||||
*
|
||||
* @return array Array of key rows with usage data.
|
||||
*/
|
||||
public static function list_all_with_usage(): array {
|
||||
global $wpdb;
|
||||
|
||||
$keys_table = self::table();
|
||||
$usage_table = $wpdb->prefix . 'wpmind_api_key_usage';
|
||||
$window_month = gmdate( 'Y-m' );
|
||||
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT k.id, k.key_id, k.key_prefix, k.name, k.owner_user_id,
|
||||
k.rpm_limit, k.tpm_limit, k.concurrency_limit,
|
||||
k.monthly_budget_usd, k.ip_whitelist, k.status,
|
||||
k.last_used_at, k.expires_at, k.revoked_at,
|
||||
k.created_at, k.updated_at,
|
||||
COALESCE(u.request_count, 0) AS usage_request_count,
|
||||
COALESCE(u.total_tokens, 0) AS usage_total_tokens,
|
||||
COALESCE(u.total_cost_usd, 0) AS usage_total_cost_usd
|
||||
FROM %i AS k
|
||||
LEFT JOIN %i AS u ON k.key_id = u.key_id AND u.window_month = %s
|
||||
ORDER BY k.created_at DESC",
|
||||
$keys_table,
|
||||
$usage_table,
|
||||
$window_month
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return is_array( $results ) ? $results : [];
|
||||
}
|
||||
}
|
||||
157
modules/api-gateway/includes/Error/ErrorMapper.php
Normal file
157
modules/api-gateway/includes/Error/ErrorMapper.php
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<?php
|
||||
/**
|
||||
* Error Mapper
|
||||
*
|
||||
* Maps WPMind WP_Error codes to OpenAI-compatible error responses.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway\Error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway\Error;
|
||||
|
||||
/**
|
||||
* Class ErrorMapper
|
||||
*
|
||||
* Static utility for converting internal error codes to
|
||||
* OpenAI-compatible error format with correct HTTP status codes.
|
||||
*/
|
||||
final class ErrorMapper {
|
||||
|
||||
/**
|
||||
* WP_Error code to OpenAI error mapping.
|
||||
*
|
||||
* Each entry maps to: [type, code, HTTP status].
|
||||
*
|
||||
* @var array<string, array{type: string, code: string, status: int}>
|
||||
*/
|
||||
private const MAP = [
|
||||
'missing_auth_header' => [
|
||||
'type' => 'invalid_request_error',
|
||||
'code' => 'missing_auth_header',
|
||||
'status' => 401,
|
||||
],
|
||||
'invalid_auth_header' => [
|
||||
'type' => 'invalid_request_error',
|
||||
'code' => 'invalid_api_key',
|
||||
'status' => 401,
|
||||
],
|
||||
'invalid_api_key_format' => [
|
||||
'type' => 'invalid_request_error',
|
||||
'code' => 'invalid_api_key',
|
||||
'status' => 401,
|
||||
],
|
||||
'api_key_not_found' => [
|
||||
'type' => 'invalid_request_error',
|
||||
'code' => 'invalid_api_key',
|
||||
'status' => 401,
|
||||
],
|
||||
'api_key_invalid_secret' => [
|
||||
'type' => 'invalid_request_error',
|
||||
'code' => 'invalid_api_key',
|
||||
'status' => 401,
|
||||
],
|
||||
'api_key_inactive' => [
|
||||
'type' => 'invalid_request_error',
|
||||
'code' => 'invalid_api_key',
|
||||
'status' => 403,
|
||||
],
|
||||
'api_key_expired' => [
|
||||
'type' => 'invalid_request_error',
|
||||
'code' => 'invalid_api_key',
|
||||
'status' => 403,
|
||||
],
|
||||
'api_key_ip_denied' => [
|
||||
'type' => 'invalid_request_error',
|
||||
'code' => 'invalid_api_key',
|
||||
'status' => 403,
|
||||
],
|
||||
'insufficient_quota' => [
|
||||
'type' => 'insufficient_quota',
|
||||
'code' => 'insufficient_quota',
|
||||
'status' => 429,
|
||||
],
|
||||
'rate_limit_exceeded' => [
|
||||
'type' => 'rate_limit_exceeded',
|
||||
'code' => 'rate_limit_exceeded',
|
||||
'status' => 429,
|
||||
],
|
||||
'model_not_found' => [
|
||||
'type' => 'invalid_request_error',
|
||||
'code' => 'model_not_found',
|
||||
'status' => 400,
|
||||
],
|
||||
'request_too_large' => [
|
||||
'type' => 'invalid_request_error',
|
||||
'code' => 'request_too_large',
|
||||
'status' => 413,
|
||||
],
|
||||
'not_authenticated' => [
|
||||
'type' => 'invalid_request_error',
|
||||
'code' => 'invalid_api_key',
|
||||
'status' => 401,
|
||||
],
|
||||
'forbidden' => [
|
||||
'type' => 'invalid_request_error',
|
||||
'code' => 'insufficient_permissions',
|
||||
'status' => 403,
|
||||
],
|
||||
'sse_concurrency_exceeded' => [
|
||||
'type' => 'rate_limit_exceeded',
|
||||
'code' => 'rate_limit_exceeded',
|
||||
'status' => 429,
|
||||
],
|
||||
'sse_lock_timeout' => [
|
||||
'type' => 'server_error',
|
||||
'code' => 'server_error',
|
||||
'status' => 503,
|
||||
],
|
||||
'unsupported_operation' => [
|
||||
'type' => 'invalid_request_error',
|
||||
'code' => 'unsupported_operation',
|
||||
'status' => 400,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Default mapping for unknown error codes.
|
||||
*
|
||||
* @var array{type: string, code: string, status: int}
|
||||
*/
|
||||
private const DEFAULT_MAP = [
|
||||
'type' => 'server_error',
|
||||
'code' => 'internal_error',
|
||||
'status' => 500,
|
||||
];
|
||||
|
||||
/**
|
||||
* Map a WP_Error code to OpenAI error metadata.
|
||||
*
|
||||
* @param string $wp_error_code WP_Error code.
|
||||
* @return array{type: string, code: string, status: int}
|
||||
*/
|
||||
public static function map( string $wp_error_code ): array {
|
||||
return self::MAP[ $wp_error_code ] ?? self::DEFAULT_MAP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an OpenAI-compatible error response body.
|
||||
*
|
||||
* @param string $message Human-readable error message.
|
||||
* @param string $type OpenAI error type.
|
||||
* @param string $code OpenAI error code.
|
||||
* @return array{error: array{message: string, type: string, param: null, code: string}}
|
||||
*/
|
||||
public static function format_openai_error( string $message, string $type, string $code ): array {
|
||||
return [
|
||||
'error' => [
|
||||
'message' => $message,
|
||||
'type' => $type,
|
||||
'param' => null,
|
||||
'code' => $code,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
350
modules/api-gateway/includes/GatewayRequestSchema.php
Normal file
350
modules/api-gateway/includes/GatewayRequestSchema.php
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
<?php
|
||||
/**
|
||||
* Gateway Request Schema
|
||||
*
|
||||
* Defines WordPress REST API argument schemas for each endpoint.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway;
|
||||
|
||||
/**
|
||||
* Class GatewayRequestSchema
|
||||
*
|
||||
* Static methods returning WP REST API args arrays
|
||||
* for request validation and sanitization.
|
||||
*/
|
||||
final class GatewayRequestSchema {
|
||||
|
||||
/**
|
||||
* Schema for chat completions endpoint.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function chat_completions(): array {
|
||||
return [
|
||||
'model' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
'messages' => [
|
||||
'type' => 'array',
|
||||
'required' => true,
|
||||
'maxItems' => 256,
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'role' => [
|
||||
'type' => 'string',
|
||||
'enum' => [ 'system', 'user', 'assistant', 'tool' ],
|
||||
],
|
||||
'content' => [
|
||||
'type' => [ 'string', 'array' ],
|
||||
],
|
||||
],
|
||||
],
|
||||
'validate_callback' => [ __CLASS__, 'validate_messages' ],
|
||||
],
|
||||
'temperature' => [
|
||||
'type' => 'number',
|
||||
'default' => 1.0,
|
||||
'minimum' => 0,
|
||||
'maximum' => 2,
|
||||
],
|
||||
'max_tokens' => [
|
||||
'type' => 'integer',
|
||||
'default' => null,
|
||||
'minimum' => 1,
|
||||
],
|
||||
'top_p' => [
|
||||
'type' => 'number',
|
||||
'default' => 1.0,
|
||||
'minimum' => 0,
|
||||
'maximum' => 1,
|
||||
],
|
||||
'frequency_penalty' => [
|
||||
'type' => 'number',
|
||||
'default' => 0,
|
||||
'minimum' => -2,
|
||||
'maximum' => 2,
|
||||
],
|
||||
'presence_penalty' => [
|
||||
'type' => 'number',
|
||||
'default' => 0,
|
||||
'minimum' => -2,
|
||||
'maximum' => 2,
|
||||
],
|
||||
'stream' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
],
|
||||
'stop' => [
|
||||
'type' => [ 'string', 'array' ],
|
||||
'default' => null,
|
||||
'validate_callback' => [ __CLASS__, 'validate_stop' ],
|
||||
],
|
||||
'n' => [
|
||||
'type' => 'integer',
|
||||
'default' => 1,
|
||||
'minimum' => 1,
|
||||
'validate_callback' => [ __CLASS__, 'validate_n' ],
|
||||
],
|
||||
'tools' => [
|
||||
'type' => 'array',
|
||||
'default' => null,
|
||||
'validate_callback' => [ __CLASS__, 'validate_tools' ],
|
||||
],
|
||||
'tool_choice' => [
|
||||
'type' => [ 'string', 'object' ],
|
||||
'default' => null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for embeddings endpoint.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function embeddings(): array {
|
||||
return [
|
||||
'model' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
'input' => [
|
||||
'type' => [ 'string', 'array' ],
|
||||
'required' => true,
|
||||
'validate_callback' => [ __CLASS__, 'validate_input' ],
|
||||
],
|
||||
'encoding_format' => [
|
||||
'type' => 'string',
|
||||
'default' => 'float',
|
||||
'enum' => [ 'float', 'base64' ],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for responses endpoint.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function responses(): array {
|
||||
return [
|
||||
'model' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
'input' => [
|
||||
'type' => [ 'string', 'array' ],
|
||||
'required' => true,
|
||||
],
|
||||
'instructions' => [
|
||||
'type' => 'string',
|
||||
'default' => null,
|
||||
'sanitize_callback' => 'sanitize_textarea_field',
|
||||
],
|
||||
'temperature' => [
|
||||
'type' => 'number',
|
||||
'default' => 1.0,
|
||||
'minimum' => 0,
|
||||
'maximum' => 2,
|
||||
],
|
||||
'max_tokens' => [
|
||||
'type' => 'integer',
|
||||
'default' => null,
|
||||
'minimum' => 1,
|
||||
],
|
||||
'tools' => [
|
||||
'type' => 'array',
|
||||
'default' => null,
|
||||
'validate_callback' => [ __CLASS__, 'validate_tools' ],
|
||||
],
|
||||
'stream' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for models endpoint.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function models(): array {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate messages array.
|
||||
*
|
||||
* @param mixed $value Parameter value.
|
||||
* @param \WP_REST_Request $request REST request.
|
||||
* @param string $param Parameter name.
|
||||
* @return true|\WP_Error
|
||||
*/
|
||||
public static function validate_messages( $value, \WP_REST_Request $request, string $param ): true|\WP_Error {
|
||||
if ( ! is_array( $value ) ) {
|
||||
return new \WP_Error( 'rest_invalid_param', 'messages must be an array.', [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
if ( count( $value ) === 0 ) {
|
||||
return new \WP_Error( 'rest_invalid_param', 'messages must not be empty.', [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
if ( count( $value ) > 256 ) {
|
||||
return new \WP_Error( 'rest_invalid_param', 'messages must not exceed 256 items.', [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
$valid_roles = [ 'system', 'user', 'assistant', 'tool' ];
|
||||
|
||||
foreach ( $value as $i => $msg ) {
|
||||
if ( ! is_array( $msg ) || ! isset( $msg['role'], $msg['content'] ) ) {
|
||||
return new \WP_Error(
|
||||
'rest_invalid_param',
|
||||
sprintf( 'messages[%d] must have role and content.', $i ),
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! in_array( $msg['role'], $valid_roles, true ) ) {
|
||||
return new \WP_Error(
|
||||
'rest_invalid_param',
|
||||
sprintf( 'messages[%d].role must be one of: %s.', $i, implode( ', ', $valid_roles ) ),
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! is_string( $msg['content'] ) && ! is_array( $msg['content'] ) ) {
|
||||
return new \WP_Error(
|
||||
'rest_invalid_param',
|
||||
sprintf( 'messages[%d].content must be a string or array.', $i ),
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate stop parameter.
|
||||
*
|
||||
* @param mixed $value Parameter value.
|
||||
* @param \WP_REST_Request $request REST request.
|
||||
* @param string $param Parameter name.
|
||||
* @return true|\WP_Error
|
||||
*/
|
||||
public static function validate_stop( $value, \WP_REST_Request $request, string $param ): true|\WP_Error {
|
||||
if ( $value === null ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( is_string( $value ) ) {
|
||||
if ( $value === '' ) {
|
||||
return new \WP_Error( 'rest_invalid_param', 'stop string must not be empty.', [ 'status' => 400 ] );
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( is_array( $value ) ) {
|
||||
if ( count( $value ) > 4 ) {
|
||||
return new \WP_Error( 'rest_invalid_param', 'stop array must not exceed 4 items.', [ 'status' => 400 ] );
|
||||
}
|
||||
foreach ( $value as $item ) {
|
||||
if ( ! is_string( $item ) ) {
|
||||
return new \WP_Error( 'rest_invalid_param', 'Each stop item must be a string.', [ 'status' => 400 ] );
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return new \WP_Error( 'rest_invalid_param', 'stop must be a string or array.', [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate n parameter (must be 1).
|
||||
*
|
||||
* @param mixed $value Parameter value.
|
||||
* @param \WP_REST_Request $request REST request.
|
||||
* @param string $param Parameter name.
|
||||
* @return true|\WP_Error
|
||||
*/
|
||||
public static function validate_n( $value, \WP_REST_Request $request, string $param ): true|\WP_Error {
|
||||
if ( (int) $value !== 1 ) {
|
||||
return new \WP_Error(
|
||||
'rest_invalid_param',
|
||||
'Only n=1 is supported. Multiple completions (n>1) are not available.',
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate tools array.
|
||||
*
|
||||
* @param mixed $value Parameter value.
|
||||
* @param \WP_REST_Request $request REST request.
|
||||
* @param string $param Parameter name.
|
||||
* @return true|\WP_Error
|
||||
*/
|
||||
public static function validate_tools( $value, \WP_REST_Request $request, string $param ): true|\WP_Error {
|
||||
if ( $value === null ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( ! is_array( $value ) ) {
|
||||
return new \WP_Error( 'rest_invalid_param', 'tools must be an array.', [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
foreach ( $value as $i => $tool ) {
|
||||
if ( ! is_array( $tool ) || ! isset( $tool['type'], $tool['function'] ) ) {
|
||||
return new \WP_Error(
|
||||
'rest_invalid_param',
|
||||
sprintf( 'tools[%d] must have type and function keys.', $i ),
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate input parameter for embeddings.
|
||||
*
|
||||
* @param mixed $value Parameter value.
|
||||
* @param \WP_REST_Request $request REST request.
|
||||
* @param string $param Parameter name.
|
||||
* @return true|\WP_Error
|
||||
*/
|
||||
public static function validate_input( $value, \WP_REST_Request $request, string $param ): true|\WP_Error {
|
||||
if ( is_string( $value ) ) {
|
||||
if ( $value === '' ) {
|
||||
return new \WP_Error( 'rest_invalid_param', 'input string must not be empty.', [ 'status' => 400 ] );
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( is_array( $value ) ) {
|
||||
foreach ( $value as $item ) {
|
||||
if ( ! is_string( $item ) ) {
|
||||
return new \WP_Error( 'rest_invalid_param', 'Each input item must be a string.', [ 'status' => 400 ] );
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return new \WP_Error( 'rest_invalid_param', 'input must be a string or array of strings.', [ 'status' => 400 ] );
|
||||
}
|
||||
}
|
||||
171
modules/api-gateway/includes/Pipeline/AuthMiddleware.php
Normal file
171
modules/api-gateway/includes/Pipeline/AuthMiddleware.php
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
/**
|
||||
* Auth Middleware
|
||||
*
|
||||
* Pipeline stage that authenticates API gateway requests.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway\Pipeline
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway\Pipeline;
|
||||
|
||||
use WPMind\Modules\ApiGateway\Auth\ApiKeyManager;
|
||||
|
||||
/**
|
||||
* Class AuthMiddleware
|
||||
*
|
||||
* Authenticates requests via Bearer API key (for API endpoints)
|
||||
* or Cookie+Nonce / Application Password (for management endpoints).
|
||||
*/
|
||||
final class AuthMiddleware implements GatewayStageInterface {
|
||||
|
||||
/**
|
||||
* Operation prefixes that require Bearer API key authentication.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const API_OPERATION_PREFIXES = [
|
||||
'chat.',
|
||||
'embeddings',
|
||||
'responses',
|
||||
'models',
|
||||
'model_detail',
|
||||
'status',
|
||||
];
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function process( GatewayRequestContext $context ): void {
|
||||
if ( $this->is_api_operation( $context->operation() ) ) {
|
||||
$this->authenticate_api_request( $context );
|
||||
return;
|
||||
}
|
||||
|
||||
$this->authenticate_management_request( $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate an API endpoint request via Bearer API key.
|
||||
*
|
||||
* @param GatewayRequestContext $context Request context.
|
||||
*/
|
||||
private function authenticate_api_request( GatewayRequestContext $context ): void {
|
||||
$auth_header = $context->rest_request()->get_header( 'authorization' );
|
||||
|
||||
if ( empty( $auth_header ) ) {
|
||||
$context->set_error(
|
||||
new \WP_Error(
|
||||
'missing_auth_header',
|
||||
'Missing Authorization header.',
|
||||
[ 'status' => 401 ]
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$client_ip = $this->get_client_ip();
|
||||
$result = ApiKeyManager::authenticate_bearer_header( $auth_header, $client_ip );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
$context->set_error( $result );
|
||||
return;
|
||||
}
|
||||
|
||||
$context->set_client_ip( $client_ip );
|
||||
$context->set_auth_result( $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a management endpoint request via Cookie+Nonce or Application Password.
|
||||
*
|
||||
* @param GatewayRequestContext $context Request context.
|
||||
*/
|
||||
private function authenticate_management_request( GatewayRequestContext $context ): void {
|
||||
if ( ! is_user_logged_in() ) {
|
||||
$context->set_error(
|
||||
new \WP_Error(
|
||||
'not_authenticated',
|
||||
'Authentication required.',
|
||||
[ 'status' => 401 ]
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
$context->set_error(
|
||||
new \WP_Error(
|
||||
'forbidden',
|
||||
'Insufficient permissions.',
|
||||
[ 'status' => 403 ]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the operation is an API endpoint (requires Bearer key).
|
||||
*
|
||||
* @param string $operation Operation identifier.
|
||||
* @return bool
|
||||
*/
|
||||
private function is_api_operation( string $operation ): bool {
|
||||
foreach ( self::API_OPERATION_PREFIXES as $prefix ) {
|
||||
if ( str_starts_with( $operation, $prefix ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the client IP address from request headers.
|
||||
*
|
||||
* Only trusts proxy headers (X-Forwarded-For, X-Real-IP) when
|
||||
* REMOTE_ADDR matches a configured trusted proxy. Otherwise
|
||||
* falls back to REMOTE_ADDR to prevent IP spoofing.
|
||||
*
|
||||
* @return string Client IP address.
|
||||
*/
|
||||
private function get_client_ip(): string {
|
||||
$remote_addr = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
|
||||
|
||||
if ( $remote_addr === '' ) {
|
||||
return '127.0.0.1';
|
||||
}
|
||||
|
||||
$trusted_proxies = (array) apply_filters( 'wpmind_gateway_trusted_proxies', [ '127.0.0.1', '::1' ] );
|
||||
|
||||
if ( in_array( $remote_addr, $trusted_proxies, true ) ) {
|
||||
$proxy_headers = [ 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP' ];
|
||||
|
||||
foreach ( $proxy_headers as $header ) {
|
||||
$value = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ?? '' ) );
|
||||
|
||||
if ( $value === '' ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( $header === 'HTTP_X_FORWARDED_FOR' ) {
|
||||
$parts = explode( ',', $value );
|
||||
$value = trim( $parts[0] );
|
||||
}
|
||||
|
||||
if ( filter_var( $value, FILTER_VALIDATE_IP ) !== false ) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( filter_var( $remote_addr, FILTER_VALIDATE_IP ) !== false ) {
|
||||
return $remote_addr;
|
||||
}
|
||||
|
||||
return '127.0.0.1';
|
||||
}
|
||||
}
|
||||
84
modules/api-gateway/includes/Pipeline/BudgetMiddleware.php
Normal file
84
modules/api-gateway/includes/Pipeline/BudgetMiddleware.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
/**
|
||||
* Budget Middleware
|
||||
*
|
||||
* Pipeline stage that enforces monthly budget limits per API key.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway\Pipeline
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway\Pipeline;
|
||||
|
||||
/**
|
||||
* Class BudgetMiddleware
|
||||
*
|
||||
* Checks the current month's spend against the key's monthly budget.
|
||||
* Skips for management routes and already-errored contexts.
|
||||
*/
|
||||
final class BudgetMiddleware implements GatewayStageInterface {
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function process( GatewayRequestContext $context ): void {
|
||||
if ( $context->is_management_route() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $context->has_error() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$auth_result = $context->auth_result();
|
||||
|
||||
if ( $auth_result === null ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$budget = (float) $auth_result->monthly_budget_usd;
|
||||
|
||||
if ( $budget <= 0.0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$spent = $this->get_current_month_spend( $auth_result->key_id );
|
||||
|
||||
if ( $spent >= $budget ) {
|
||||
$context->set_error(
|
||||
new \WP_Error(
|
||||
'insufficient_quota',
|
||||
'Monthly budget exceeded.',
|
||||
[ 'status' => 429 ]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the total spend for the current month from the usage table.
|
||||
*
|
||||
* @param string $key_id API key identifier.
|
||||
* @return float Total cost in USD for the current month.
|
||||
*/
|
||||
private function get_current_month_spend( string $key_id ): float {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . 'wpmind_api_key_usage';
|
||||
$window_month = gmdate( 'Y-m' );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$result = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
'SELECT total_cost_usd FROM %i WHERE key_id = %s AND window_month = %s LIMIT 1',
|
||||
$table,
|
||||
$key_id,
|
||||
$window_month
|
||||
)
|
||||
);
|
||||
|
||||
return $result !== null ? (float) $result : 0.0;
|
||||
}
|
||||
}
|
||||
102
modules/api-gateway/includes/Pipeline/ErrorMiddleware.php
Normal file
102
modules/api-gateway/includes/Pipeline/ErrorMiddleware.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
/**
|
||||
* Error Middleware
|
||||
*
|
||||
* Pipeline stage that converts errors and exceptions into
|
||||
* OpenAI-compatible JSON error responses.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway\Pipeline
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway\Pipeline;
|
||||
|
||||
use WPMind\Modules\ApiGateway\Error\ErrorMapper;
|
||||
|
||||
/**
|
||||
* Class ErrorMiddleware
|
||||
*
|
||||
* Always executes (finally semantics). Converts WP_Error or
|
||||
* uncaught exceptions into OpenAI-formatted REST responses.
|
||||
*/
|
||||
final class ErrorMiddleware implements GatewayStageInterface {
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function process( GatewayRequestContext $context ): void {
|
||||
$this->handle_exception( $context );
|
||||
$this->handle_error( $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an uncaught exception to a WP_Error if no error is set yet.
|
||||
*
|
||||
* Never exposes exception details to the client. The actual
|
||||
* exception message is logged server-side for debugging.
|
||||
*
|
||||
* @param GatewayRequestContext $context Request context.
|
||||
*/
|
||||
private function handle_exception( GatewayRequestContext $context ): void {
|
||||
if ( ! $context->has_exception() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Log the real exception for server-side debugging.
|
||||
$exception = $context->exception();
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log(
|
||||
sprintf(
|
||||
'[WPMind API Gateway] Uncaught exception in request %s: %s in %s:%d',
|
||||
$context->request_id(),
|
||||
$exception->getMessage(),
|
||||
$exception->getFile(),
|
||||
$exception->getLine()
|
||||
)
|
||||
);
|
||||
|
||||
// Only set a generic error if no specific error was already set.
|
||||
if ( ! $context->has_error() ) {
|
||||
$context->set_error(
|
||||
new \WP_Error(
|
||||
'internal_error',
|
||||
'An internal error occurred.',
|
||||
[ 'status' => 500 ]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a WP_Error to an OpenAI-formatted REST response.
|
||||
*
|
||||
* @param GatewayRequestContext $context Request context.
|
||||
*/
|
||||
private function handle_error( GatewayRequestContext $context ): void {
|
||||
if ( ! $context->has_error() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$error = $context->error();
|
||||
$error_code = $error->get_error_code();
|
||||
$mapping = ErrorMapper::map( $error_code );
|
||||
|
||||
$body = ErrorMapper::format_openai_error(
|
||||
$error->get_error_message(),
|
||||
$mapping['type'],
|
||||
$mapping['code']
|
||||
);
|
||||
|
||||
$response = new \WP_REST_Response( $body, $mapping['status'] );
|
||||
$response->header( 'Content-Type', 'application/json' );
|
||||
|
||||
// Add Retry-After header for rate-limited responses.
|
||||
if ( $context->retry_after_sec() > 0 ) {
|
||||
$response->header( 'Retry-After', (string) $context->retry_after_sec() );
|
||||
}
|
||||
|
||||
$context->set_rest_response( $response );
|
||||
}
|
||||
}
|
||||
84
modules/api-gateway/includes/Pipeline/GatewayPipeline.php
Normal file
84
modules/api-gateway/includes/Pipeline/GatewayPipeline.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
/**
|
||||
* Gateway Pipeline
|
||||
*
|
||||
* Orchestrates the 8-stage middleware pipeline for API requests.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway\Pipeline
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway\Pipeline;
|
||||
|
||||
/**
|
||||
* Class GatewayPipeline
|
||||
*
|
||||
* Runs a request through: auth -> budget -> quota ->
|
||||
* request_transform -> route -> response_transform -> error -> log.
|
||||
*
|
||||
* The error and log stages always execute (finally semantics).
|
||||
*/
|
||||
final class GatewayPipeline {
|
||||
|
||||
public function __construct(
|
||||
private GatewayStageInterface $auth,
|
||||
private GatewayStageInterface $budget,
|
||||
private GatewayStageInterface $quota,
|
||||
private GatewayStageInterface $request_transform,
|
||||
private GatewayStageInterface $route,
|
||||
private GatewayStageInterface $response_transform,
|
||||
private GatewayStageInterface $error,
|
||||
private GatewayStageInterface $log
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle an API gateway request through the full pipeline.
|
||||
*
|
||||
* @param string $operation Operation type (e.g. 'chat.completions').
|
||||
* @param \WP_REST_Request $request WordPress REST request.
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
public function handle( string $operation, \WP_REST_Request $request ): \WP_REST_Response {
|
||||
$context = GatewayRequestContext::from_rest_request( $operation, $request );
|
||||
|
||||
try {
|
||||
$this->auth->process( $context );
|
||||
|
||||
if ( ! $context->has_error() ) {
|
||||
$this->budget->process( $context );
|
||||
}
|
||||
if ( ! $context->has_error() ) {
|
||||
$this->quota->process( $context );
|
||||
}
|
||||
if ( ! $context->has_error() ) {
|
||||
$this->request_transform->process( $context );
|
||||
}
|
||||
if ( ! $context->has_error() ) {
|
||||
$this->route->process( $context );
|
||||
}
|
||||
if ( ! $context->has_error() ) {
|
||||
$this->response_transform->process( $context );
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
$context->set_exception( $e );
|
||||
}
|
||||
|
||||
// Error and log stages always execute (finally semantics).
|
||||
try {
|
||||
$this->error->process( $context );
|
||||
} catch ( \Throwable $e ) {
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log( '[WPMind GW] Error stage failed: ' . $e->getMessage() );
|
||||
}
|
||||
try {
|
||||
$this->log->process( $context );
|
||||
} catch ( \Throwable $e ) {
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log( '[WPMind GW] Log stage failed: ' . $e->getMessage() );
|
||||
}
|
||||
|
||||
return $context->to_rest_response();
|
||||
}
|
||||
}
|
||||
339
modules/api-gateway/includes/Pipeline/GatewayRequestContext.php
Normal file
339
modules/api-gateway/includes/Pipeline/GatewayRequestContext.php
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
<?php
|
||||
/**
|
||||
* Gateway Request Context
|
||||
*
|
||||
* Core DTO that flows through every pipeline stage.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway\Pipeline
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway\Pipeline;
|
||||
|
||||
/**
|
||||
* Class GatewayRequestContext
|
||||
*
|
||||
* Immutable-ish value object carrying all state for a single
|
||||
* API gateway request through the middleware pipeline.
|
||||
*/
|
||||
final class GatewayRequestContext {
|
||||
|
||||
private string $operation;
|
||||
private string $request_id;
|
||||
private \WP_REST_Request $rest_request;
|
||||
private ?object $auth_result = null;
|
||||
private ?array $internal_payload = null;
|
||||
private mixed $internal_result = null;
|
||||
private ?\WP_REST_Response $rest_response = null;
|
||||
private ?\WP_Error $error = null;
|
||||
private ?\Throwable $exception = null;
|
||||
private ?string $client_ip = null;
|
||||
private array $response_headers = [];
|
||||
private int $retry_after_sec = 0;
|
||||
private float $start_time;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* Create context from a WP REST request.
|
||||
*
|
||||
* @param string $operation Operation type (e.g. 'chat.completions').
|
||||
* @param \WP_REST_Request $request Original REST request.
|
||||
* @return self
|
||||
*/
|
||||
public static function from_rest_request( string $operation, \WP_REST_Request $request ): self {
|
||||
$ctx = new self();
|
||||
$ctx->operation = $operation;
|
||||
$ctx->rest_request = $request;
|
||||
$ctx->start_time = microtime( true );
|
||||
|
||||
// Generate UUID v4.
|
||||
$data = random_bytes( 16 );
|
||||
$data[6] = chr( ord( $data[6] ) & 0x0f | 0x40 );
|
||||
$data[8] = chr( ord( $data[8] ) & 0x3f | 0x80 );
|
||||
$ctx->request_id = vsprintf(
|
||||
'%s%s-%s-%s-%s-%s%s%s',
|
||||
str_split( bin2hex( $data ), 4 )
|
||||
);
|
||||
|
||||
return $ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the operation type.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function operation(): string {
|
||||
return $this->operation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unique request ID (UUID v4).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function request_id(): string {
|
||||
return $this->request_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original WP REST request.
|
||||
*
|
||||
* @return \WP_REST_Request
|
||||
*/
|
||||
public function rest_request(): \WP_REST_Request {
|
||||
return $this->rest_request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw request body.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function raw_body(): string {
|
||||
return $this->rest_request->get_body();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key_id from auth result.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function key_id(): ?string {
|
||||
return $this->auth_result?->key_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the resolved client IP address.
|
||||
*
|
||||
* @param string $ip Client IP address.
|
||||
*/
|
||||
public function set_client_ip( string $ip ): void {
|
||||
$this->client_ip = $ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resolved client IP address.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function client_ip(): ?string {
|
||||
return $this->client_ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the authentication result.
|
||||
*
|
||||
* @param object $result Auth result object.
|
||||
*/
|
||||
public function set_auth_result( object $result ): void {
|
||||
$this->auth_result = $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication result.
|
||||
*
|
||||
* @return object|null
|
||||
*/
|
||||
public function auth_result(): ?object {
|
||||
return $this->auth_result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the internal payload (WPMind format).
|
||||
*
|
||||
* @param array $payload Transformed payload.
|
||||
*/
|
||||
public function set_internal_payload( array $payload ): void {
|
||||
$this->internal_payload = $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the internal payload.
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function get_internal_payload(): ?array {
|
||||
return $this->internal_payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the internal result from PublicAPI.
|
||||
*
|
||||
* @param mixed $result Result data.
|
||||
*/
|
||||
public function set_internal_result( mixed $result ): void {
|
||||
$this->internal_result = $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the internal result.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function get_internal_result(): mixed {
|
||||
return $this->internal_result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an error on the context.
|
||||
*
|
||||
* @param \WP_Error $error WordPress error.
|
||||
*/
|
||||
public function set_error( \WP_Error $error ): void {
|
||||
$this->error = $error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the context has an error.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function has_error(): bool {
|
||||
return $this->error !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the error.
|
||||
*
|
||||
* @return \WP_Error|null
|
||||
*/
|
||||
public function error(): ?\WP_Error {
|
||||
return $this->error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an exception on the context.
|
||||
*
|
||||
* @param \Throwable $e The exception.
|
||||
*/
|
||||
public function set_exception( \Throwable $e ): void {
|
||||
$this->exception = $e;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the context has an exception.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function has_exception(): bool {
|
||||
return $this->exception !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the exception.
|
||||
*
|
||||
* @return \Throwable|null
|
||||
*/
|
||||
public function exception(): ?\Throwable {
|
||||
return $this->exception;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a response header.
|
||||
*
|
||||
* @param string $name Header name.
|
||||
* @param string $value Header value.
|
||||
*/
|
||||
public function set_response_header( string $name, string $value ): void {
|
||||
$this->response_headers[ $name ] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all response headers.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function get_response_headers(): array {
|
||||
return $this->response_headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set retry-after seconds for rate limiting.
|
||||
*
|
||||
* @param int $seconds Seconds to wait.
|
||||
*/
|
||||
public function set_retry_after( int $seconds ): void {
|
||||
$this->retry_after_sec = $seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retry-after seconds.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function retry_after_sec(): int {
|
||||
return $this->retry_after_sec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the final REST response.
|
||||
*
|
||||
* @param \WP_REST_Response $response REST response.
|
||||
*/
|
||||
public function set_rest_response( \WP_REST_Response $response ): void {
|
||||
$this->rest_response = $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and return the final REST response.
|
||||
*
|
||||
* If a rest_response was explicitly set, returns it.
|
||||
* If an error exists, converts it to a REST response.
|
||||
* Otherwise builds a 200 response from internal_result.
|
||||
*
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
public function to_rest_response(): \WP_REST_Response {
|
||||
if ( $this->rest_response !== null ) {
|
||||
$response = $this->rest_response;
|
||||
} elseif ( $this->error !== null ) {
|
||||
$data = $this->error->get_error_data();
|
||||
$status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 500;
|
||||
$response = new \WP_REST_Response(
|
||||
[
|
||||
'error' => [
|
||||
'message' => $this->error->get_error_message(),
|
||||
'type' => $this->error->get_error_code(),
|
||||
],
|
||||
],
|
||||
$status
|
||||
);
|
||||
} else {
|
||||
$response = new \WP_REST_Response( $this->internal_result, 200 );
|
||||
}
|
||||
|
||||
// Apply collected headers.
|
||||
foreach ( $this->response_headers as $name => $value ) {
|
||||
$response->header( $name, $value );
|
||||
}
|
||||
|
||||
// Always include request ID.
|
||||
$response->header( 'X-Request-Id', $this->request_id );
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get elapsed time in milliseconds since request start.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function elapsed_ms(): int {
|
||||
return (int) round( ( microtime( true ) - $this->start_time ) * 1000 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a management route (e.g. status, models).
|
||||
*
|
||||
* Management routes may skip budget/quota checks.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_management_route(): bool {
|
||||
return in_array( $this->operation, [ 'status', 'models' ], true );
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
/**
|
||||
* Gateway Stage Interface
|
||||
*
|
||||
* Contract for pipeline middleware stages.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway\Pipeline
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway\Pipeline;
|
||||
|
||||
/**
|
||||
* Interface GatewayStageInterface
|
||||
*
|
||||
* Each pipeline stage receives the shared request context,
|
||||
* inspects or mutates it, and returns void.
|
||||
*/
|
||||
interface GatewayStageInterface {
|
||||
|
||||
/**
|
||||
* Process the gateway request context.
|
||||
*
|
||||
* @param GatewayRequestContext $context Shared request context.
|
||||
*/
|
||||
public function process( GatewayRequestContext $context ): void;
|
||||
}
|
||||
248
modules/api-gateway/includes/Pipeline/LogMiddleware.php
Normal file
248
modules/api-gateway/includes/Pipeline/LogMiddleware.php
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
<?php
|
||||
/**
|
||||
* Log Middleware
|
||||
*
|
||||
* Pipeline stage that writes audit log entries and updates
|
||||
* API key usage counters for every gateway request.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway\Pipeline
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway\Pipeline;
|
||||
|
||||
/**
|
||||
* Class LogMiddleware
|
||||
*
|
||||
* Always executes (finally semantics). Writes to the audit log
|
||||
* table and increments usage counters. Failures are silently
|
||||
* logged -- logging must never break the API response.
|
||||
*/
|
||||
final class LogMiddleware implements GatewayStageInterface {
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function process( GatewayRequestContext $context ): void {
|
||||
try {
|
||||
$this->write_audit_log( $context );
|
||||
$this->update_key_usage( $context );
|
||||
} catch ( \Throwable $e ) {
|
||||
// Logging must never cause the request to fail.
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log(
|
||||
sprintf(
|
||||
'[WPMind API Gateway] Log middleware error for request %s: %s',
|
||||
$context->request_id(),
|
||||
$e->getMessage()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an entry to the audit log table.
|
||||
*
|
||||
* @param GatewayRequestContext $context Request context.
|
||||
*/
|
||||
private function write_audit_log( GatewayRequestContext $context ): void {
|
||||
global $wpdb;
|
||||
|
||||
$has_error = $context->has_error();
|
||||
$event_type = $has_error ? 'api_error' : 'api_request';
|
||||
|
||||
// Build detail JSON.
|
||||
$detail = $this->build_detail( $context );
|
||||
|
||||
// Determine actor_user_id (0 for anonymous).
|
||||
$user_id = get_current_user_id();
|
||||
$actor_user_id = $user_id > 0 ? $user_id : 0;
|
||||
|
||||
// Privacy-preserving IP hash.
|
||||
$ip_hash = $this->hash_client_ip( $context );
|
||||
|
||||
// User-Agent, truncated to 255 chars.
|
||||
$user_agent = $context->rest_request()->get_header( 'user-agent' );
|
||||
if ( is_string( $user_agent ) && mb_strlen( $user_agent ) > 255 ) {
|
||||
$user_agent = mb_substr( $user_agent, 0, 255 );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$inserted = $wpdb->insert(
|
||||
$wpdb->prefix . 'wpmind_api_audit_log',
|
||||
[
|
||||
'event_type' => $event_type,
|
||||
'key_id' => $context->key_id(),
|
||||
'actor_user_id' => $actor_user_id,
|
||||
'request_id' => $context->request_id(),
|
||||
'ip_hash' => $ip_hash,
|
||||
'user_agent' => $user_agent,
|
||||
'detail_json' => wp_json_encode( $detail ),
|
||||
'created_at' => current_time( 'mysql', true ),
|
||||
],
|
||||
[
|
||||
'%s', // event_type
|
||||
'%s', // key_id
|
||||
'%d', // actor_user_id
|
||||
'%s', // request_id
|
||||
'%s', // ip_hash
|
||||
'%s', // user_agent
|
||||
'%s', // detail_json
|
||||
'%s', // created_at
|
||||
]
|
||||
);
|
||||
|
||||
if ( $inserted === false ) {
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log(
|
||||
sprintf(
|
||||
'[WPMind API Gateway] Failed to insert audit log for request %s: %s',
|
||||
$context->request_id(),
|
||||
$wpdb->last_error
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the API key usage counters (atomic upsert).
|
||||
*
|
||||
* Only runs for successful requests with a valid key_id.
|
||||
*
|
||||
* @param GatewayRequestContext $context Request context.
|
||||
*/
|
||||
private function update_key_usage( GatewayRequestContext $context ): void {
|
||||
// Only for successful requests with a key.
|
||||
if ( $context->has_error() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$key_id = $context->key_id();
|
||||
if ( $key_id === null ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . 'wpmind_api_key_usage';
|
||||
$window_month = gmdate( 'Y-m' );
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
// Extract token counts from internal result if available.
|
||||
$result = $context->get_internal_result();
|
||||
$input_tokens = 0;
|
||||
$output_tokens = 0;
|
||||
$total_tokens = 0;
|
||||
$cost_usd = 0.0;
|
||||
|
||||
if ( is_array( $result ) ) {
|
||||
$usage = $result['usage'] ?? [];
|
||||
if ( is_array( $usage ) ) {
|
||||
$input_tokens = (int) ( $usage['prompt_tokens'] ?? 0 );
|
||||
$output_tokens = (int) ( $usage['completion_tokens'] ?? 0 );
|
||||
$total_tokens = (int) ( $usage['total_tokens'] ?? 0 );
|
||||
}
|
||||
$cost_usd = (float) ( $result['cost_usd'] ?? 0.0 );
|
||||
}
|
||||
|
||||
// Atomic upsert: INSERT ... ON DUPLICATE KEY UPDATE.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$query_result = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO {$table}
|
||||
( key_id, window_month, request_count, input_tokens, output_tokens, total_tokens, total_cost_usd, updated_at )
|
||||
VALUES ( %s, %s, 1, %d, %d, %d, %f, %s )
|
||||
ON DUPLICATE KEY UPDATE
|
||||
request_count = request_count + 1,
|
||||
input_tokens = input_tokens + VALUES(input_tokens),
|
||||
output_tokens = output_tokens + VALUES(output_tokens),
|
||||
total_tokens = total_tokens + VALUES(total_tokens),
|
||||
total_cost_usd = total_cost_usd + VALUES(total_cost_usd),
|
||||
updated_at = VALUES(updated_at)",
|
||||
$key_id,
|
||||
$window_month,
|
||||
$input_tokens,
|
||||
$output_tokens,
|
||||
$total_tokens,
|
||||
$cost_usd,
|
||||
$now
|
||||
)
|
||||
);
|
||||
|
||||
if ( $query_result === false ) {
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log(
|
||||
sprintf(
|
||||
'[WPMind API Gateway] Failed to update key usage for %s: %s',
|
||||
$key_id,
|
||||
$wpdb->last_error
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the detail JSON object for the audit log entry.
|
||||
*
|
||||
* @param GatewayRequestContext $context Request context.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function build_detail( GatewayRequestContext $context ): array {
|
||||
$detail = [
|
||||
'operation' => $context->operation(),
|
||||
'status' => $context->has_error() ? $this->get_error_status( $context ) : 200,
|
||||
'elapsed_ms' => $context->elapsed_ms(),
|
||||
];
|
||||
|
||||
// Include error code if present.
|
||||
if ( $context->has_error() ) {
|
||||
$detail['error_code'] = $context->error()->get_error_code();
|
||||
}
|
||||
|
||||
// Include model and provider from internal payload if available.
|
||||
$payload = $context->get_internal_payload();
|
||||
if ( is_array( $payload ) ) {
|
||||
if ( isset( $payload['model'] ) ) {
|
||||
$detail['model'] = $payload['model'];
|
||||
}
|
||||
if ( isset( $payload['provider'] ) ) {
|
||||
$detail['provider'] = $payload['provider'];
|
||||
}
|
||||
}
|
||||
|
||||
return $detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the HTTP status code from a WP_Error.
|
||||
*
|
||||
* @param GatewayRequestContext $context Request context.
|
||||
* @return int HTTP status code.
|
||||
*/
|
||||
private function get_error_status( GatewayRequestContext $context ): int {
|
||||
$error = $context->error();
|
||||
$data = $error->get_error_data();
|
||||
|
||||
if ( is_array( $data ) && isset( $data['status'] ) ) {
|
||||
return (int) $data['status'];
|
||||
}
|
||||
|
||||
return 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an HMAC-SHA256 hash of the client IP for privacy.
|
||||
*
|
||||
* Uses the IP resolved by AuthMiddleware via the context.
|
||||
*
|
||||
* @param GatewayRequestContext $context Request context.
|
||||
* @return string 64-character hex hash.
|
||||
*/
|
||||
private function hash_client_ip( GatewayRequestContext $context ): string {
|
||||
$ip = $context->client_ip() ?? '127.0.0.1';
|
||||
|
||||
return hash_hmac( 'sha256', $ip, wp_salt( 'auth' ) );
|
||||
}
|
||||
}
|
||||
89
modules/api-gateway/includes/Pipeline/QuotaMiddleware.php
Normal file
89
modules/api-gateway/includes/Pipeline/QuotaMiddleware.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
/**
|
||||
* Quota Middleware
|
||||
*
|
||||
* Pipeline stage that enforces RPM and TPM rate limits per API key.
|
||||
*
|
||||
* @package WPMind\Modules\ApiGateway\Pipeline
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPMind\Modules\ApiGateway\Pipeline;
|
||||
|
||||
use WPMind\Modules\ApiGateway\RateLimit\RateLimiter;
|
||||
|
||||
/**
|
||||
* Class QuotaMiddleware
|
||||
*
|
||||
* Checks requests-per-minute and tokens-per-minute limits using
|
||||
* the RateLimiter. Sets appropriate rate-limit response headers.
|
||||
*/
|
||||
final class QuotaMiddleware implements GatewayStageInterface {
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function process( GatewayRequestContext $context ): void {
|
||||
if ( $context->is_management_route() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $context->has_error() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$auth_result = $context->auth_result();
|
||||
|
||||
if ( $auth_result === null ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$estimated_tokens = $this->estimate_tokens( $context->raw_body() );
|
||||
$limiter = RateLimiter::create();
|
||||
|
||||
$result = $limiter->check_and_consume(
|
||||
$auth_result->key_id,
|
||||
$context->request_id(),
|
||||
$auth_result->rpm_limit,
|
||||
$auth_result->tpm_limit,
|
||||
$estimated_tokens
|
||||
);
|
||||
|
||||
$reset_seconds = max( 0, $result->reset_epoch - time() );
|
||||
|
||||
$context->set_response_header( 'x-ratelimit-limit-requests', (string) $auth_result->rpm_limit );
|
||||
$context->set_response_header( 'x-ratelimit-remaining-requests', (string) max( 0, $result->remaining ) );
|
||||
$context->set_response_header( 'x-ratelimit-reset-requests', $reset_seconds . 's' );
|
||||
|
||||
if ( ! $result->allowed ) {
|
||||
$retry_after = max( 1, $reset_seconds );
|
||||
|
||||
$context->set_response_header( 'Retry-After', (string) $retry_after );
|
||||
$context->set_retry_after( $retry_after );
|
||||
|
||||
$context->set_error(
|
||||
new \WP_Error(
|
||||
'rate_limit_exceeded',
|
||||
'Rate limit exceeded. Please retry after ' . $retry_after . ' seconds.',
|
||||
[ 'status' => 429 ]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate token count from the raw request body.
|
||||
*
|
||||
* Uses a simple heuristic of ~4 characters per token.
|
||||
*
|
||||
* @param string $body Raw request body.
|
||||
* @return int Estimated token count.
|
||||
*/
|
||||
private function estimate_tokens( string $body ): int {
|
||||
$length = mb_strlen( $body, 'UTF-8' );
|
||||
|
||||
return max( 1, (int) ( $length / 3 ) );
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue