Compare commits

...

120 commits

Author SHA1 Message Date
LinuxJoy
558d029bc5 feat(i18n): add .pot translation template and auto-generate in deploy
Generate wpmind.pot via wp-cli during deploy, enabling translation
pipeline and i18n coverage testing by translate and elementary VMs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-19 23:15:25 +08:00
LinuxJoy
7b1dad3287 feat(updater): integrate WenPai update checker with popup fix
Add Update URI header and WenPai_Updater v1.1.0 for self-hosted update
support via updates.wenpai.net. The updater includes three key fixes over
v1.0.0: external flag to prevent broken wp.org links in popup, API error
response validation, and local plugin header fallback when API unavailable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-19 22:02:28 +08:00
LinuxJoy
f30df6fd59 feat(ci): sync wp-release.yml with changelog generation from ci-workflows 2026-02-19 11:35:42 +08:00
LinuxJoy
2f84a2747c fix(ci): avoid SIGPIPE in verify tools step
All checks were successful
Release Plugin / release (push) Successful in 24s
2026-02-18 16:57:22 +08:00
LinuxJoy
9b59e85ad2 chore: restore local tracking of internal docs
Some checks failed
Release Plugin / release (push) Failing after -8h1m16s
Sensitive files tracked locally but excluded from remote push
via push-feicode.sh + .git/push-exclude mechanism.
2026-02-18 16:44:26 +08:00
LinuxJoy
9fb3b7de08 chore: remove roadmap and deployment docs from tracking 2026-02-18 16:32:53 +08:00
LinuxJoy
2969813144 chore: add Forgejo CI/CD workflow and reset version to 0.11.3
- Add .forgejo/workflows/release.yml (tag-triggered auto release)
- Reset version from 3.11.3 to 0.11.3 (pre-release stage)
- Update version references in docs
2026-02-18 16:11:33 +08:00
LinuxJoy
a3f26e7719 style: 设置页 header 贴边布局重构
- 模块 header 移到 panel 外层,突破 tab-pane padding 实现贴边显示
- 统一 header 样式:padding、border-bottom、space-between 布局
- 各模块模板添加 wpmind-tab-pane-body 包裹内容区域
- Tab 列表改为 flex-wrap 并添加左右 padding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-16 01:02:23 +08:00
LinuxJoy
1d6c27f94c fix: 移除 init 钩子中的 provider 连通性调试循环
register_wpmind_providers() 在 init 钩子中对每个已启用的 AI provider
调用 isProviderConfigured(),该方法会发送真实 API 请求测试连通性。
导致每次页面加载都产生 N 个 AI API 请求(N = 已启用 provider 数量),
浪费 API 配额并拖慢页面加载。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-16 00:53:33 +08:00
LinuxJoy
e9a8b57196 fix(geo): add tabbed Schema preview (Article/Breadcrumb/WebSite)
Enhance the JSON-LD preview section with three switchable tabs
for Article, BreadcrumbList, and WebSite schema types. Update
property table with BreadcrumbList and WebSite entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-10 16:31:43 +08:00
LinuxJoy
8fa833aa58 feat(geo): add WebSite/BreadcrumbList Schema, Author sameAs, JSON-LD preview
- BrandEntity: output WebSite + SearchAction JSON-LD on front page
- SchemaGenerator: output BreadcrumbList (Home → Category → Post) on
  singular pages, enhance author schema with sameAs from user profile
  social fields (Twitter, LinkedIn, GitHub, Weibo, Facebook)
- Settings: add live JSON-LD preview in 结构化数据 tab right sidebar
- Version bump 3.11.2 → 3.11.3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-10 13:24:24 +08:00
LinuxJoy
1a57cc6603 refactor(geo): move entity linker to brand tab, enhance schema info
Move "实体关联" from 结构化数据 tab to 品牌实体 tab as "文章实体关联",
consolidating all entity settings in one place. Replace schema tab
right sidebar with AI-focused explanation and output property table
showing all 12 auto-generated JSON-LD attributes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-10 12:42:27 +08:00
LinuxJoy
38c3a88de6 fix(geo): unify brand entity form layout and input widths
Group related fields into single cards instead of one card per field.
Add wpmind-brand-fields CSS for consistent full-width inputs and
uniform label/field spacing across all brand entity sections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-10 11:58:41 +08:00
LinuxJoy
192d3e4786 feat(geo): add Brand Entity sub-tab for Organization Schema
Add BrandEntity component that enriches article publisher Schema
with sameAs, description, contactPoint and outputs standalone
Organization JSON-LD on the front page. Includes settings UI with
org type, social profiles, contact info and Knowledge Graph links.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 21:26:26 +08:00
LinuxJoy
c623c69e95 docs: add v3.11.1 CSS refactoring and v3.11.0 Media Intelligence changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 19:31:08 +08:00
LinuxJoy
dd01bbfd0c refactor(css): extract shared module layout to components/module-layout.css
Unified CSS architecture for all module settings pages:
- Created assets/css/components/module-layout.css with wpmind-module-* naming
- Replaced scattered wpmind-geo-*, wpmind-mi-* shared classes across modules
- Scoped all module JS selectors to their panel containers (prevents cross-module interference)
- Each module CSS now contains only module-specific styles
- Bumped version to 3.11.1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 19:11:45 +08:00
LinuxJoy
634ad447e7 fix(media-intelligence): scope sub-tab selectors to prevent cross-module interference
Media Intelligence JS used global selectors (.wpmind-mi-subtab, .wpmind-mi-tab-panel)
that removed active class from all modules' panels, causing blank sub-tab content when
switching main tabs. Scoped all selectors to .wpmind-media-panel container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 18:47:02 +08:00
LinuxJoy
2087b61ea2 fix(auto-meta): self-contained CSS, no dependency on geo/media-intelligence
Subtab navigation and option/actions styles are now scoped to
.wpmind-auto-meta-panel, ensuring correct rendering even when
GEO or Media Intelligence modules are disabled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 18:08:55 +08:00
LinuxJoy
6b63a4da65 docs: add Auto-Meta v3.11.0 changelog entry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 13:50:18 +08:00
LinuxJoy
958d25e340 fix(auto-meta): address Codex review findings
- High: add preflight check to skip AI call when no features enabled
- Medium: allow retry on update when initial generation failed (source empty)
- Medium: inject_faq_schema now respects the FAQ toggle setting
- Low: manual generation returns clear error for non-published posts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 13:14:19 +08:00
LinuxJoy
c031592765 feat: Auto-Meta 模块 — 发布时自动生成摘要、标签、分类、FAQ Schema、SEO 描述
- 新增 modules/auto-meta/ 模块(MetaGenerator + AutoMetaAjaxController)
- transition_post_status + post_updated 触发,WP-Cron 异步执行
- 单次 wpmind_structured() 调用生成全部元数据
- FAQ Schema 通过 wpmind_article_schema filter 注入 GEO 模块
- 设置页双子标签:功能开关 + 手动生成
- 版本号 3.10.9 → 3.11.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 13:02:25 +08:00
LinuxJoy
71e49c1a7b fix: Vision API failover 限制为支持视觉的 Provider
ChatService.chat() 新增 failover_providers 选项,允许调用方约束
故障转移链。PublicAPI.vision() 传入 VisionHelper 的已配置视觉
Provider 列表,避免 failover 尝试不支持图片输入的 Provider
(如 DeepSeek/Moonshot/Doubao 等),减少无效重试。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 12:24:16 +08:00
LinuxJoy
574ae08f6d refactor: Media Intelligence 设置页重构为子标签页导航
将平铺布局拆分为三个 pill 子标签页(基本设置/批量处理/内容安全),
与 GEO 模块视觉风格一致,类名独立避免 JS 冲突。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 12:14:38 +08:00
LinuxJoy
5522c437dc feat: Media Intelligence 模块 — AI 图片 alt text/标题/描述自动生成
新增 Media Intelligence 模块 (v4.3),复用 chat API 多模态能力实现:
- wpmind_vision() 全局函数 + VisionHelper 静态类
- 上传时异步生成 alt text、标题、caption
- NSFW 内容检测(可选)
- 批量处理已有无 alt text 图片(防无限循环)
- 设置管理界面 + 统计面板

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 11:48:06 +08:00
LinuxJoy
ef5d8a3319 refactor: 拆分 admin.css 模块专属样式到独立文件
将 admin.css 从 2274 行精简至 1520 行(-33%),提取 3 个模块专属
样式到 assets/css/pages/ 下独立文件,由各模块自行 enqueue 加载:
- geo.css (350行): subtabs/sections/crawlers/options
- api-gateway.css (383行): subtabs/keys table/audit log/docs
- exact-cache.css (43行): trend chart/cache panel badge

共享设计系统组件(geo-header/stat-card/badge)保留在 admin.css。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 10:33:24 +08:00
LinuxJoy
73fe8a455a fix: 移除 services.php 重复缓存设置,缓存管理按钮移至标题栏右对齐
- 删除文本服务标签中重复的 Exact Cache 设置项(启用/TTL/容量上限)
- 清空缓存和重置统计按钮从底部移至标题栏,与数据统计风格一致
- badge 添加 margin-right:auto 实现按钮右对齐
- 清理废弃的 .wpmind-cache-actions CSS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 03:24:20 +08:00
LinuxJoy
6f663799e1 feat: Exact Cache 模块 — AI 请求精确缓存管理界面
新增 Exact Cache 模块,为已有的 ExactCache 核心类提供完整的管理界面和统计功能:

- 模块骨架:module.json + ExactCacheModule(实现 ModuleInterface 全部 7 个方法)
- AJAX 控制器:4 个 handler(保存设置/清空缓存/重置统计/获取统计),含安全三要素
- 成本估算器:基于 UsageTracker 历史数据估算缓存节省金额
- 日统计:7 天滚动统计,shutdown 批量写入,retry-once transient lock
- 管理界面:统计卡片 + Chart.js 7 天趋势图 + 设置表单 + 缓存管理
- settings-page.php:添加精确缓存 tab(与 GEO/API Gateway 相同模式)
- ExactCache.php:仅添加 get_entries() 只读方法,零核心影响
- uninstall.php:添加 option + transient 批量清理

经过 3 轮 Codex 架构审查(v1.0→v2.0→v2.1→v2.2),所有 P0/P1 问题已解决。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 03:09:57 +08:00
LinuxJoy
19cf8fd7c6 refactor: MCP Ability slug 统一为 mind/ 前缀
wpmind/chat → mind/chat
wpmind/get-providers → mind/get-providers
wpmind/get-usage-stats → mind/get-usage-stats
wpmind/get-budget-status → mind/get-budget-status
wpmind/switch-strategy → mind/switch-strategy

与 REST API namespace mind/v1 保持一致。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 01:58:50 +08:00
LinuxJoy
b269a344b8 style: 全标签页设计审查 — 统一标题区域和 Remixicon 图标
- API Gateway: 新增标题区域(ri-server-line) + 描述 + 统计卡片/子标签图标全部换为 Remixicon
- 服务配置: 新增标题区域(ri-cloud-line) + 描述
- 图片生成: 新增标题区域(ri-image-line) + 描述
- 模块管理: 标题添加图标(ri-puzzle-line)
- 新增 docs/api-gateway-use-cases.md 应用场景文档

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 01:49:43 +08:00
LinuxJoy
d43183641e fix: admin.css var() 语法错误导致 Gateway 样式失效
admin.css:1729 缺少右括号 var(--wpmind-text-xs → var(--wpmind-text-xs)
导致浏览器 CSS 解析器中断,后续所有 .wpmind-gw-* 样式不生效。
版本号 3.10.5 → 3.10.6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 01:29:56 +08:00
LinuxJoy
09ca7b1380 security: API Gateway 全量代码审计修复 (59 issues)
Codex 审计发现 124 个问题,修复 59 个 P0-P2 级别问题:

P0 (CRITICAL):
- TOCTOU 竞态: TransientRateStore/SseConcurrencyGuard 改用 wp_cache_add 原子锁
- REST 路由: __return_true → check_bearer_present 轻量鉴权
- Schema 验证: messages/tools/tool_choice 添加完整 validate_callback
- SQL 列名不匹配: upsert 查询对齐 SchemaManager 定义
- SQL 表名插值: 统一使用 %i 占位符

P1 (HIGH):
- IP hash 加盐 (HMAC) + IP 解析统一 (context 共享)
- Header 注入防护 (X-Request-Id/响应头 \r\n 过滤)
- 数值参数范围校验 (temperature/top_p/penalties/max_tokens)
- insert_key 错误处理 + revoke_key 审计日志
- Pipeline finally 保护 + RateLimiter rollback 双 store
- wp_unslash 补全 + strtotime 返回值检查
- RedisRateStore $rid 冒号验证
- UpstreamStreamClient catch \Throwable

P2 (MEDIUM):
- AuditLogRepository DRY (提取 build_where_clause)
- Token 估算 CJK 优化 (mb_strlen)
- ResponseTransformMiddleware null 检查
- 嵌入响应 usage 数据修复
- SchemaManager autoload 优化

25 files changed, ~530 insertions, ~190 deletions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 01:14:12 +08:00
LinuxJoy
40a65c740d feat: API Gateway 设置界面改版 — 统一设计系统 + 功能增强
- 重写 settings.php:4 个子标签(基础设置/Key 管理/接入文档/请求日志)
- 统一使用 CSS 变量和 Gutenberg 直角风格,移除内联样式
- 新增 Key 编辑功能(行内展开式编辑面板)
- 新增审计日志查看(分页 + 过滤)
- 新增 API 接入文档面板(端点列表 + curl 示例)
- 新增 AuditLogRepository 和 update_key() 方法

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 00:42:23 +08:00
LinuxJoy
a9fb6cdcba chore: 版本号 3.10.2 → 3.10.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 00:09:12 +08:00
LinuxJoy
a342719cc9 fix: API Gateway AJAX nonce 时序问题 — 创建 Key 失败
根因:内联 JS 在 body 中执行时 wpmindData(footer 注入)尚未加载,
nonce 被设为空字符串,导致所有 AJAX 请求 check_ajax_referer 失败返回 0。

修复:直接用 PHP 输出 ajaxurl 和 nonce,不依赖 wpmindData 加载时序。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 00:05:41 +08:00
LinuxJoy
16e929962c fix: API Gateway Admin JS 防御性编程 — 修复 AJAX 回调空值崩溃
- 保存设置: r.data.message → (r.data && r.data.message) || '保存失败'
- 创建 Key: r.data.message → (r.data && r.data.message) || '创建失败'
- 吊销 Key: r.data.message → (r.data && r.data.message) || '吊销失败'
- 加载列表: r.data.keys.length → r.data && r.data.keys && r.data.keys.length

修复 admin.php?page=wpmind:4220 TypeError: Cannot read properties of undefined

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 23:48:36 +08:00
LinuxJoy
03b276b332 fix: API Gateway 部署验证修复 — 3 个遗漏问题
- RequestTransformer: 添加 model_detail/status 到 match 分支(之前落入 default 报错)
- RouteMiddleware: wp_request() → rest_request()(方法名不存在导致 internal_error)
- AuthMiddleware: status 操作加入 API_OPERATION_PREFIXES(之前走管理端认证)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 23:45:47 +08:00
LinuxJoy
4a8eb94bc5 fix: API Gateway Codex 审查修复 — 6 个问题
HIGH:
1. generate_key_id: 改用固定字母表循环,保证始终 12 字符
2. Redis TPM: Lua 脚本改为遍历 member 求和 cost,而非 ZCARD 计数
3. IP 头安全: 仅在 REMOTE_ADDR 匹配可信代理时才读取 X-Forwarded-For

MEDIUM:
4. SSE 审计: 挂载 wpmind_gateway_sse_complete 回调写入审计日志+用量统计
5. /models/{id}: 新增 model_detail 操作,返回单模型或 404
6. ErrorMapper: 添加 sse_concurrency_exceeded/sse_lock_timeout/unsupported_operation 映射

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 23:12:38 +08:00
LinuxJoy
e7a35c2345 docs: 更新 CHANGELOG — API Gateway v3.10.2 完成记录
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 22:15:44 +08:00
LinuxJoy
1a3fd92854 fix: API Gateway 部署修复 — key_id 长度 + 设置页标签页
- generate_key_id: random_bytes(8)→(12) 确保 key_id 始终 12 字符
- settings-page.php: 添加 API Gateway 标签页到硬编码导航
- 版本号升级: 3.10.1 → 3.10.2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 22:10:57 +08:00
LinuxJoy
9d79cf157e feat: API Gateway Phase 10 — 测试套件 + 部署文档
- 5 个 PHPUnit 单元测试: ApiKeyHasher, ErrorMapper, ResponseTransformer, RateLimiter, ModelMapper
- 集成测试脚本: test-api-gateway.php (8 项检查全部通过)
- 部署文档: DEPLOYMENT.md (Nginx/Apache/Cloudflare 配置 + 故障排查)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 21:58:19 +08:00
LinuxJoy
e7502b8ac7 feat: API Gateway Phase 9 — Admin UI 设置页 + API Key 管理
- GatewayAjaxController: 4 个 AJAX handler (save/create/list/revoke)
- ApiKeyRepository: 新增 list_all_with_usage / count 查询方法
- settings.php: 完整设置页 (状态卡片 + 基础设置 + Key 管理)
- ApiGatewayModule: 集成 AJAX 控制器注册

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 21:32:27 +08:00
LinuxJoy
4a70e1bfce feat: API Gateway Phase 8 — REST 控制器 + 端点注册 (核心功能完成)
- RestController: 6 个 OpenAI 兼容端点注册
  POST /mind/v1/chat/completions
  POST /mind/v1/embeddings
  POST /mind/v1/responses
  GET  /mind/v1/models
  GET  /mind/v1/models/{model_id}
  GET  /mind/v1/status
- 8 阶段 Pipeline 完整串联
- RouteMiddleware: 添加 status + responses 操作处理
- ApiGatewayModule: 接入 RestController

API Gateway 核心功能 Phase 0-8 全部完成 (35 PHP 文件)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 20:41:13 +08:00
LinuxJoy
5054dac90b feat: API Gateway Phase 6+7 — SSE 流式处理 + 错误处理 + 审计日志
Phase 6 (SSE 流式处理):
- CancellationToken: 客户端断开检测
- SseConcurrencyGuard: 全局+Key 并发槽位管理
- UpstreamStreamClient: 上游 Provider 流式代理
- SseStreamController: SSE 生命周期编排
- RouteMiddleware: 集成 SSE 流式分支

Phase 7 (错误处理+审计):
- ErrorMapper: WP_Error → OpenAI 错误格式映射 (14种)
- ErrorMiddleware: 异常捕获 + OpenAI 兼容错误响应
- LogMiddleware: 审计日志写入 + 用量统计 upsert

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 20:37:10 +08:00
LinuxJoy
e7b2627d7e feat: API Gateway Phase 4+5 — 中间件 + 路由转换层
Phase 4 (认证+预算+限流):
- AuthMiddleware: Bearer Key (API) / Cookie+Nonce (管理端)
- BudgetMiddleware: 月度预算检查 (api_key_usage 表)
- QuotaMiddleware: RPM/TPM 双维度限流
- RateLimit 子系统: Redis 滑动窗口 + Transient 降级

Phase 5 (路由+转换):
- ModelMapper: 18 模型映射 + 别名 + auto 路由
- RequestTransformer: OpenAI → WPMind (体积/token 上限)
- ResponseTransformer: WPMind → OpenAI 格式
- RouteMiddleware: 分发到 PublicAPI
- RequestTransformMiddleware / ResponseTransformMiddleware

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 20:31:39 +08:00
LinuxJoy
0ebddc9918 feat: API Gateway Phase 0-3 — 模块骨架 + Auth 子系统 + 中间件框架
- Phase 0: 框架对齐 (module.json, ModuleInterface 契约)
- Phase 1: 模块入口 + SchemaManager (3张DB表)
- Phase 2: API Key 系统 (Hasher/Repository/Manager/AuthResult)
- Phase 3: 请求上下文 + 8阶段中间件 Pipeline
- uninstall.php: 添加 API Gateway 表清理

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 20:20:59 +08:00
LinuxJoy
996482fc19 feat: Phase 4.0 — ExactCache + MCP Gateway + API Gateway 设计方案
核心功能:
- ExactCache: 请求级精确缓存,批量写入优化 (shutdown hook)
- MCP Gateway: WordPress Abilities API 集成 (5 个能力)
- AbstractService: cache_ttl 三级语义 (负数禁用/零自动/正数指定)

API Gateway 模块设计 (Codex 三轮评审通过):
- 完整设计方案 + 5 个 P0 解决方案 (API Key/Pipeline/SSE/限流/错误映射)
- 11 阶段实施计划 (Phase 0-10),8 阶段中间件 Pipeline
- 3 张 DB 表设计 (api_keys/api_key_usage/api_audit_log)

其他:
- Provider 测试文档 (Qwen/Doubao 兼容性矩阵)
- GEO 设置页 badge 文本修正
- 版本号升级到 3.10.1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 19:06:30 +08:00
LinuxJoy
56bc98b2e6 feat: GEO 设置页各子标签添加知识块
每个子标签页右栏添加对应的概念讲解卡片:
- 基础设置:什么是 GEO?(补充知识库链接)
- 内容输出:AI 内容分发
- 结构化数据:语义理解与实体消歧
- AI 索引:AI 索引权限控制
- 爬虫管理:AI 爬虫生态

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 16:11:25 +08:00
LinuxJoy
0f2d327d9a fix: 右栏结构重构 + 中国 AI 爬虫补充
- 右栏(爬虫统计+GEO说明)移入基础设置 tab 内部 grid,其他 tab 自然全宽
- 移除 JS sidebar show/hide 逻辑,改为纯 HTML 结构控制
- robots.txt 管理新增: 百度/搜狗/360/神马/DuckAssistBot
- CrawlerTracker 同步新增中国搜索引擎爬虫识别

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 15:09:05 +08:00
LinuxJoy
db6b787349 feat: GEO 模块 v3.10 — 3 个新功能 + 设置页子导航重构
新增组件:
- RobotsTxtManager: robots.txt AI 爬虫管理 (15 个爬虫 Allow/Disallow)
- AiSummaryManager: AI 摘要编辑器字段 + meta 标签 + Schema.org abstract
- EntityLinker: Wikidata 实体关联 + Schema.org about.sameAs

设置页重构:
- 平铺 section 改为 5 个 pill 子导航 (基础/内容/结构化数据/AI索引/爬虫管理)
- 右栏爬虫统计仅在基础设置 tab 显示,其他 tab 全宽布局
- 子 tab 状态通过 sessionStorage 保持

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 15:02:58 +08:00
LinuxJoy
f52f10d137 fix: Codex 审查修复 — checkbox 值判断 + rewrite flush + 子设置保护
1. checkbox 开关改用值比较替代 isset(),修复无法关闭的 bug
2. rewrite flush 改为延迟到下次 admin_init 执行,确保路由已注册
3. AI 索引/Sitemap 子设置仅在对应功能启用时才保存,避免覆盖

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 12:02:13 +08:00
LinuxJoy
c64831c642 feat: AI Sitemap (/ai-sitemap.xml) — AI 爬虫专属站点地图
新增 AiSitemapGenerator,为 AI 爬虫提供带元数据的 XML Sitemap:
- 每个 URL 包含 ai:declaration(内容声明)和 ai:summary(摘要)
- 自动排除标记 noai 的内容(集成 AiIndexingManager)
- Transient 缓存 + 内容变更自动失效
- 后台可配置开关和最大条目数

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 11:49:45 +08:00
LinuxJoy
2a1c351584 feat: AI 索引指令功能 (noai/nollm meta + X-Robots-Tag + 内容声明)
新增 AiIndexingManager 类,支持全局和单篇控制 AI 爬虫索引权限:
- wp_head 输出 noai/nollm meta robots 和 ai-content-declaration meta
- send_headers 输出 X-Robots-Tag HTTP 头
- 编辑器侧边栏 metabox 支持单篇覆盖全局设置
- GEO 设置页新增 AI 索引指令 section(开关、默认声明、按 post type 排除)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 11:29:18 +08:00
LinuxJoy
746271ca44 style: 后台 UI 持续优化 — CSS 统一 + 概览页增强 + 版本号修正
统一全页面设计风格,优化概览页模板结构,修正版本号为 3.9.26。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 10:50:56 +08:00
LinuxJoy
a599ce385c style: 全页面设计统一 — 单色调 + GEO 风格标准化
以 GEO 页面为参考标准,统一所有页面的设计语言:
- 页面标题 14px + 18px primary 图标,section 标题 13px + 图标
- 卡片统一 gray-50 背景 + border,移除 box-shadow
- 统计卡片移除多色图标背景,统一 primary 单色
- 概览页 Hero 改为左侧强调线风格,移除大色块和水印图标
- 按钮基础样式统一(inline-flex + icon 对齐)
- 部署规则:每次 deploy 必须递增版本号

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 03:29:14 +08:00
LinuxJoy
bd2e6b50e7 refactor: Tab 标签重命名统一 + 排序优化 + CSS 变量修复
Tab 重命名:
- dashboard → analytics(ID + 标签"数据分析")
- "模块" → "模块管理"(导航标签统一)

Tab 重排序(功能 → 监控 → 设置):
  概览 → 文本服务 → 图像服务 → 智能路由
  → 数据分析 → 预算管理 → GEO 优化 → 模块管理

CSS 变量修复:
- 添加缺失的 --wpmind-space-7: 28px
- 移除 overview.css 中的 fallback 值

涉及文件: settings-page.php, admin-boot.js,
admin-analytics.js, AnalyticsModule.php, module.json,
admin.css, overview.css

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 00:59:00 +08:00
LinuxJoy
70a3f01393 feat: 概览页 UX 优化 — Hero 按钮 + 空状态引导 + 相对时间
- Hero 区块新增"添加 Provider"和"查看文档"操作按钮
- 移除渐变背景,改为纯色
- 移除卡片顶部彩色边线
- Provider 空状态:图标 + 引导文案 + "去配置"链接
- 活动空状态:图标 + 提示文案
- 最近活动时间改为相对时间(human_time_diff)
- 修复两处 HTML 多余字符
- 版本号升级到 v3.9.2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 00:44:45 +08:00
LinuxJoy
e5c2a9b764 feat: 概览页视觉增强 — Hero 区块 + 卡片图标背景
- 新增 Hero 品牌区块(渐变背景/标题/副标题/元信息)
- 本月摘要改为 2x2 网格布局,每项带彩色图标
- 最近活动加 Provider 图标 + 行 hover 效果
- 卡片顶部彩色边线(蓝/绿)区分类型
- 4 张卡片添加右下角半透明装饰图标
- 版本号升级到 v3.9.1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 00:30:39 +08:00
LinuxJoy
bb312e51f4 feat: 概览页增强 — 本月摘要 + 最近活动 + 底部链接
- 新增本月摘要卡片(最常用模型/响应时间/可用率/总Tokens)
- 新增最近活动卡片(最近5条API调用记录)
- 新增底部链接栏(文派社区/使用文档/版本号)
- 修复今日Tokens统计使用不存在的key
- 版本号升级到 v3.9.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 00:22:48 +08:00
LinuxJoy
d68546d102 style: JS 文件全面应用 WordPress JS 编码规范
- 括号内加空格: $( selector ), if ( condition ), function( arg )
- Yoda 条件: 'undefined' === typeof x
- 否定运算符后加空格: ! condition
- $.ajax( { ... } ) 花括号空格
- 数组下标空格: array[ index ]

涉及文件: admin-analytics, admin-budget, admin-endpoints,
admin-geo, admin-modules, admin-routing, admin-ui

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 23:48:57 +08:00
LinuxJoy
42f24f1a90 style: 全局缩进统一为 Tab + 修复概览快捷链接
- 所有 JS/CSS/PHP 模板文件缩进从 4 空格转换为 Tab
- admin-boot.js 完整 WordPress JS 编码规范化
  - 括号内空格: $( '.selector' ), if ( condition )
  - Yoda 条件: 'dashboard' === tabId
- 修复概览页快捷链接点击无效
  - 委托事件改为直接绑定 .wpmind-tab-link
  - .attr('data-tab-link') 替代 .data('tabLink')
  - return false 双重保险

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 23:41:42 +08:00
LinuxJoy
e0892f3fc7 feat: 概览标签页 — 始终可用的插件首页
- 新增 overview.php 模板:统计卡片 + Provider/模块状态 + 快捷入口
- 概览页始终作为默认首页,不依赖任何可选模块
- 修复 admin-boot.js 空白页 bug:fallback 到第一个可用 Tab
- 新增 overview.css:Gutenberg 设计语言,响应式布局

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 21:59:21 +08:00
LinuxJoy
7797e947bb fix: 核心模块 UI 优化 — 锁图标 + 徽章 + 状态提示
- 不可禁用模块显示蓝色"核心"徽章(带 tooltip)
- toggle 开关替换为锁图标(带 tooltip 说明原因)
- 底部状态显示"核心模块"(蓝色盾牌)替代普通"已启用"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 21:46:06 +08:00
LinuxJoy
81cfbd53c3 docs: CHANGELOG v3.8.0 兼容层清理 + 扩展点增强
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 20:23:41 +08:00
LinuxJoy
b0f8c569a7 refactor: 清理兼容层 + Provider 懒加载 + 路由策略可插拔
- 删除 10 个兼容层文件 (~55KB),调用方直接引用模块类
- cost-control 模块改为不可禁用,ModuleLoader 强制启用
- ProviderRegistrar 改用字符串 FQCN,添加 wpmind_provider_map filter
- IntelligentRouter 添加 wpmind_register_routing_strategies action
- routing.php 添加 class_exists() 守卫处理 analytics 模块禁用
- AjaxController analytics 端点添加 class_exists() 守卫
- 更新集成测试移除已删除文件引用

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 20:19:35 +08:00
LinuxJoy
0ce88c27b8 docs: 文档清理 + CHANGELOG v3.7.0 + ROADMAP 状态同步
- 归档 5 个过时文档到 docs/_archive/
  (FEATURE-ROADMAP, DEVELOPMENT-PLAN, public-api-design,
   ai-gateway-design, target-plugins)
- CHANGELOG: 添加 v3.7.0 记录 (Facade 拆分 + 安全加固)
- ROADMAP: Phase 3 GEO 标记已完成,新增 Phase 3.5 架构优化,
  更新技术架构图反映 Services 拆分

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 17:06:57 +08:00
LinuxJoy
7d230320af security: Codex 审计问题修复 + WordPress 插件开发指导文档
安全修复(Codex 审计 11 项):
- transcribe() SSRF 防护: wp_http_validate_url + 协议白名单
- transcribe() 本地文件读取限制: realpath + uploads 目录校验
- transcribe() 文件大小上限 25MB + 扩展名白名单
- ErrorHandler 响应体截断到 500 字符,防止信息泄露
- SDKAdapter getTokenUsage() 空值保护
- SDKAdapter 异常消息脱敏,仅 WP_DEBUG 记录详情
- stream() 添加 allow_url_fopen 检测
- embed() JSON 解码显式校验
- speech() file_put_contents 返回值检查

新增文档:
- WordPress 插件开发指导手册 v1.1.0 (1225 行)
- 覆盖项目准备、编码规范、架构设计、开发流程等 10 章
- 基于 Codex 审计反馈补充安全基线和 WP 生态兼容性

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 12:41:21 +08:00
LinuxJoy
b48a6da32f refactor: PublicAPI 拆分为 Facade + 6 个 Service 类
将 PublicAPI.php (2124行/76KB) 拆分为 Facade 模式架构:
- PublicAPI.php: 瘦 Facade (398行),保留单例、递归保护、状态方法
- Services/AbstractService.php: 共享基础设施 (provider解析/failover/缓存)
- Services/ChatService.php: chat + stream + SDK路由 + HTTP请求
- Services/TextProcessingService.php: translate + summarize + moderate
- Services/StructuredOutputService.php: structured + batch + schema验证
- Services/EmbeddingService.php: embed
- Services/AudioService.php: transcribe + speech
- Services/ImageService.php: generate_image (委托ImageRouter)

所有 15 个公共方法签名不变,wpmind_*() 全局函数兼容,
递归保护留在 Facade 层,Service 内部互调不触发递归检查。
已通过 7 项回归测试验证。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 12:32:36 +08:00
LinuxJoy
099d02d3c3 feat: 执行层统一到 WP AI Client SDK v3.6.0 (Phase C)
- C1: 新增 SDKAdapter 类,封装 SDK 调用和格式转换
- C2: execute_chat_request() 增加 SDK 优先路径 + HTTP 回退
- C3: should_use_sdk() 能力 gate,默认对 Anthropic/Google 启用

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 11:24:10 +08:00
LinuxJoy
a9170258eb feat: 模型重选 + 路由统一 v3.5.0 (Phase B)
- B1: failover 循环内动态模型重选,auto 下移 + 显式模型回退标注
- B2: stream() 接入 wpmind_select_provider + FailoverManager 故障转移
- B3: embed() 接入路由和故障转移,embed model 动态选择
- B4: transcribe/speech 接入路由,能力过滤不支持 audio API 的 provider

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 10:58:30 +08:00
LinuxJoy
0c6bedaa7c fix: AI 请求链路可靠性修复 v3.4.0 (Phase A)
- A1: 缓存键加入 provider/model,避免跨 Provider 缓存污染
- A2: 非 JSON 响应防护,json_decode 失败返回 WP_Error
- A3: stream() 默认 provider 与 chat() 统一
- A4: per-provider 重试逻辑,激活 ErrorHandler 死代码

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 10:46:04 +08:00
LinuxJoy
655a7ecfcd refactor: admin modularization and cleanup 2026-02-07 04:27:30 +08:00
LinuxJoy
afc5830969 chore: 版本号升至 3.3.0 (Phase 1 完成)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 03:28:51 +08:00
LinuxJoy
bcc5d50a54 refactor: 方法名 camelCase → snake_case (39文件, WordPress 编码规范)
将项目自有代码的方法名从 camelCase 统一为 snake_case,
符合 WordPress PHP 编码规范。涉及 Routing、Failover、Budget、
Analytics、Usage、ErrorHandler 等模块。外部库接口方法和
Image Provider 子系统保持不变。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 03:21:31 +08:00
LinuxJoy
e594327137 chore: 版本号升至 3.2.1 (Phase 0 完成)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 00:51:05 +08:00
LinuxJoy
bc634382c0 fix: Phase 0 - Chart.js 本地化、Tab 修复、可观测性
- Chart.js 4.5.0 本地化,移除 CDN 依赖 (cloudflare 国内被墙)
- 移除 admin.js 对 chartjs 的硬依赖,防止级联故障
- Chart.js 延迟加载支持:轮询重试 + 超时友好提示
- 修复 Tab CSS class 不匹配 (active → wpmind-tab-pane-active)
- 添加 JS 健康检查:body class + console.log + wpmindData 调试字段
- 更新 .gitignore 允许 assets/js/vendor/ 目录提交
- 新增后台重构方案文档 (ADMIN-REFRACTOR-PLAN.md)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 00:50:06 +08:00
LinuxJoy
d7e00840de revert: 回滚 camelCase→snake_case 重构及相关修补 (7个提交)
回滚 b3873f5..ba9ae05 的所有改动,恢复到 5ff7b1b 的稳定状态。
重构导致大量遗漏和前端功能异常,需要重新规划后再实施。

回滚的提交:
- b3873f5 refactor: 方法名 camelCase → snake_case
- 9977df0 fix: UsageTracker 静态调用遗漏
- 5bde389 fix: getCurrency/getProviderStatus/getAllHealth 遗漏
- 7f7936a fix: private/internal 方法遗漏
- ec91c24 fix: Chart.js 本地化 + Tab 切换修复
- f8fd64e fix: 移除 chartjs 硬依赖
- ba9ae05 fix: 再次移除 chartjs 硬依赖

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-06 22:38:34 +08:00
LinuxJoy
ba9ae0593c fix: 再次移除 admin.js 对 chartjs 的硬依赖
Codex 修改时误将 chartjs 加回了依赖列表。
Chart.js CDN 被墙时会阻止 admin.js 加载,导致标签切换等全部 JS 失效。
ensureChartJs() 已处理动态加载,无需 wp_enqueue_script 依赖。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-06 18:56:46 +08:00
LinuxJoy
f8fd64e799 fix: 移除 admin.js 对 chartjs 的硬依赖,防止 CDN 失败阻塞全部 JS
Chart.js 改为 ensureChartJs() 动态加载,不再作为 wp_enqueue_script 依赖。
CDN 被墙时不会阻止 admin.js 加载,标签切换等基础功能不受影响。
版本号升级到 3.2.2。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-06 17:39:38 +08:00
LinuxJoy
ec91c24e36 fix: Chart.js 本地化 + CDN fallback + Tab 切换修复
- Chart.js 优先本地加载,CDN 失败时自动 fallback (jsdelivr → unpkg → cdnjs)
- 修复 Tab 切换:Chart.js CDN 被墙时阻塞 admin.js 加载导致所有 JS 失效
- 修复底部初始化检查错误的 CSS 类名 (active → wpmind-tab-pane-active)
- 添加 resolveTabId 安全校验,防止无效 hash 导致 JS 错误
- 版本号升级到 3.2.1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-06 16:24:11 +08:00
LinuxJoy
7f7936a06f fix: 补全所有遗漏的 private/internal 方法 camelCase → snake_case
ProviderHealthTracker、CircuitBreaker、IntelligentRouter、RoutingHooks、
CompositeStrategy、LoadBalancedStrategy、BudgetAlert、BudgetChecker、
BudgetManager、AnalyticsManager 共 16 文件 151 处。
全项目零残留 camelCase 方法定义。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-06 16:02:46 +08:00
LinuxJoy
5bde389eb0 fix: 修复最后5处遗漏 getCurrency/getProviderStatus/getAllHealth
全项目扫描确认零残留 camelCase 调用。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-06 15:46:31 +08:00
LinuxJoy
9977df03b5 fix: 修复遗漏的 UsageTracker 静态调用 camelCase → snake_case
getStats/getHistory/formatTokens/formatCost/formatCostByCurrency/calculateCost
在模板和调用方中未同步重命名,导致 __callStatic 找不到方法。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-06 15:36:39 +08:00
LinuxJoy
b3873f5b8e refactor: 方法名 camelCase → snake_case (39文件, 107个方法)
Routing/Failover/Budget/Usage/Analytics 全模块方法定义和调用方统一重命名,
符合 WordPress 编码规范。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-06 15:28:40 +08:00
LinuxJoy
5ff7b1b321 chore: 补全 strict_types 声明 + CHANGELOG 2.5.0-3.2.1
- 29 个 PHP 文件添加 declare(strict_types=1)
- CHANGELOG 补全 2.5.0、3.0.0、3.1.0、3.2.0、3.2.1 版本记录

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-06 13:09:28 +08:00
LinuxJoy
f12e528e05 refactor: 模块依赖排序 + 定价数据去重 + GEO 设置去重
P1 修复:
- ModuleLoader 添加依赖排序 (resolve_load_order),确保 cost-control 先于 analytics 加载
- 提取共享 Pricing.php 类,消除 UsageTracker 两份 PRICING 常量重复 (~160行)
- Fallback 补全 baidu/minimax 定价数据

P2 修复:
- 从 wpmind.php 移除 5 个 GEO register_setting 重复注册,由 GeoModule 统一管理

净减少 112 行代码 (84+, 196-)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-06 12:59:32 +08:00
LinuxJoy
75c9029cc2 fix: 全面审查修复 - 15个安全/功能/质量问题
P0 紧急 (4):
- AnalyticsModule 私有构造函数导致 Fatal Error,移除单例模式
- 版本号统一为 3.2.0 (插件头部 + CLAUDE.md)
- 删除 wpmind.php 残留的损坏 PHPDoc 注释块
- 设置链接 URL 从 options-general.php 修正为 admin.php

P1 高 (7):
- ImageRouter 命名空间从 Routing\ 修正为 Providers\Image\
- AnalyticsModule nonce 名称从 wpmind_admin_nonce 修正为 wpmind_ajax
- 移除 wpmind_clear_usage_stats 重复 AJAX 注册
- uninstall.php 补充 GEO/模块状态等 13 个选项清理 + $wpdb->prepare()
- 测试端点添加 nonce 验证,移除 nopriv 未认证访问
- PublicAPI speech() 添加 uploads 目录路径验证防止路径遍历

P2 中 (4):
- admin.js errorCode 添加 escapeHtml() 防止 XSS
- GeoModule AJAX 使用 wp_unslash() 替代直接 $_POST 访问
- ModuleLoader 添加 WPMind\ 命名空间前缀验证
- stripslashes() 统一替换为 wp_unslash()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-06 12:47:30 +08:00
LinuxJoy
97e344a99b fix: AnalyticsModule 实现缺失的接口方法
- 添加 check_dependencies() 方法检查模块依赖
- 添加 get_settings_tab() 方法返回设置标签页

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 12:27:47 +08:00
LinuxJoy
3d30ddb688 fix: 修复模块管理设置项缺失问题
- 修复 Analytics 模块 module.json 格式(slug -> id)
- ModuleLoader 添加 settings_tab、requires、features 字段解析
- 统一模块配置格式

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 12:27:02 +08:00
LinuxJoy
919f3887db feat: 智能路由集成到真实请求链路
- 新增 RoutingHooks 类,将 IntelligentRouter 连接到 wpmind_select_provider filter
- 支持 auto 模式下的智能路由选择
- 支持指定 Provider 时的可用性检查和自动回退
- 根据请求上下文自动推断模型类型(chat/embedding/vision/completion)
- 添加路由决策和回退的 action hooks 用于日志记录
- 更新模块化计划文档,标记阶段 3 为已完成

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 12:23:41 +08:00
LinuxJoy
208333c111 feat: 实施 Analytics 模块化
- 创建 modules/analytics/ 模块结构
- 迁移 AnalyticsManager 到模块
- 添加 AnalyticsManagerFallback 兼容层
- 更新设置页面支持模块状态检查
- 模块禁用时隐藏仪表板标签页
- 更新模块化计划文档

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 12:17:33 +08:00
LinuxJoy
a01b520840 fix: 修复熔断器状态转换 bug
问题:当熔断器处于 open 状态且恢复时间已过时,
recordSuccess/recordFailure 没有正确触发状态转换到 half_open,
导致熔断器一直显示"熔断中"即使有成功请求。

修复:在 recordSuccess 和 recordFailure 方法开始时检查
是否应该从 open 转换到 half_open 状态。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 10:57:31 +08:00
LinuxJoy
81efbe943b feat: 添加集成测试脚本
tests/test-integration.php:
- 文件存在性检查
- PHP 语法检查
- 模板依赖方法检查
- Fallback 完整性检查

用于部署后验证,避免遗漏方法导致的运行时错误

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 10:52:22 +08:00
LinuxJoy
7ce6d87208 fix: 添加缺失的 getProviderColor() 方法
模块 UsageTracker 缺少 getProviderColor() 方法,
导致 dashboard.php 调用时报错

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 10:43:52 +08:00
LinuxJoy
8e0f551794 fix: 模块禁用时隐藏预算管理标签页
问题:Cost Control 模块停用后,预算管理标签页仍然显示

修复:
- settings-page.php: 添加 $cost_control_enabled 检查
- settings-page.php: 预算管理标签页和内容区域添加条件判断
- wpmind.php: ajax_save_budget_settings() 添加模块状态检查
- wpmind.php: ajax_get_budget_status() 添加模块状态检查

与 GEO 模块保持一致的实现方式

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 10:40:36 +08:00
LinuxJoy
bb23205d22 fix: 添加缺失的 getWeekStats() 方法
- 模块 UsageTracker: 添加 getWeekStats() 方法
- Fallback UsageTracker: 同步添加 getWeekStats() 方法
- 修复 dashboard.php 调用时的 undefined method 错误

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 10:34:01 +08:00
LinuxJoy
5128520de5 feat: 实施 Cost Control 模块 (高质量重构)
模块化架构:
- 创建 modules/cost-control/ 目录结构
- CostControlModule 实现 ModuleInterface
- 迁移 UsageTracker, BudgetManager, BudgetChecker, BudgetAlert

关键修复:
- BudgetManager: 实现 recursiveMerge() 替代 wp_parse_args()
  解决嵌套数组合并问题
- 模板: 添加 class_exists() 检查防止类未加载错误
- 模板: 使用 ?? 运算符安全访问数组键

事件驱动架构:
- wpmind.php 触发 wpmind_usage_record action
- CostControlModule 监听并处理用量记录
- 触发 wpmind_usage_recorded 防止重复记录

向后兼容:
- 兼容层委托给模块类
- Fallback 文件在模块未加载时提供回退实现
- 保持原有命名空间可用

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 10:28:10 +08:00
LinuxJoy
19e607eea5 refactor: 清理 GEO 双实现,统一使用 modules/geo
- 删除旧的 includes/GEO/ 目录
- 更新 templates/tabs/geo.php 使用新命名空间 WPMind\Modules\Geo
- 更新测试文件使用新命名空间
- 更新 modules/geo/includes @package 注释
- 添加模块化方案文档 docs/MODULARIZATION-PLAN.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 23:31:28 +08:00
LinuxJoy
e0717c4a73 fix: 修复 llms.txt rewrite rules 刷新问题
- 模块启用/禁用时自动刷新 rewrite rules
- 插件激活时延迟刷新 rewrite rules (通过 admin_init/wp_loaded)
- 修复 wpmind_llms_txt_enabled 选项检查,支持字符串和布尔值
- 插件停用时清理 rewrite rules

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 22:57:58 +08:00
LinuxJoy
04826c9124 fix: 移除错误的 \n 字面字符串 2026-02-05 22:37:16 +08:00
LinuxJoy
10e1f24168 fix: 模块禁用后隐藏对应的标签页
改进交互逻辑:
- GEO 模块禁用时,隐藏 GEO 优化标签页
- 避免用户点击到不可用的功能
- 简化代码,移除冗余的模块状态检查

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 22:35:43 +08:00
LinuxJoy
9ab03c4ea4 fix: 修复模块禁用后标签页无法点击的问题
问题原因:
当 GEO 模块被禁用时,模块类文件不会被加载,
但 settings-page.php 仍尝试加载 GEO 设置模板,
导致 PHP 致命错误(类不存在),页面渲染中断。

修复方案:
在加载 GEO 设置模板之前检查模块是否启用,
如果模块未启用,显示友好提示并提供跳转链接。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 22:32:53 +08:00
LinuxJoy
981d6e3ebb fix: 修复模块无法禁用的问题
根本原因:
WordPress update_option() 对 boolean false 值处理不一致,
可能导致 option 存储失败或被删除,使模块无法被禁用。

修复方案:
- 使用字符串 '1'/'0' 代替 boolean true/false
- is_module_enabled() 兼容旧格式 (boolean/integer/string)
- 所有设置保存使用字符串格式
- JavaScript 发送 '1'/'0' 代替 boolean

修改文件:
- includes/Core/ModuleLoader.php
- modules/geo/GeoModule.php
- modules/geo/templates/settings.php
- templates/tabs/modules.php
- templates/tabs/geo.php
- includes/GEO/MarkdownProcessor.php
- includes/GEO/MarkdownEnhancer.php

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 22:29:17 +08:00
LinuxJoy
8c69616200 feat: v3.2.0 模块化架构 - GEO 独立为可选模块
新增架构:
- includes/Core/ModuleInterface.php: 模块接口定义
- includes/Core/ModuleLoader.php: 模块发现、加载、管理
- modules/geo/: GEO 优化模块(独立目录)
  - module.json: 模块元数据
  - GeoModule.php: 模块入口类
  - includes/: 模块代码(从 includes/GEO 迁移)
  - templates/settings.php: 模块设置界面

新增功能:
- 模块管理界面 (templates/tabs/modules.php)
- 模块启用/禁用 AJAX 接口
- 模块自动发现机制

架构优势:
- GEO 功能可独立启用/禁用
- 为未来拆分为独立插件做准备
- 模块化设计,易于扩展

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 22:16:46 +08:00
LinuxJoy
ae3fabe6a6 feat(geo): v3.1.0 GEO 增强 - 统一核心管线、llms.txt、Schema.org
新增功能:
- MarkdownProcessor: 统一核心处理管线,支持幂等性
- ProcessOptions: 可配置的处理选项
- LlmsTxtGenerator: 符合官方规范的 llms.txt 生成器
- SchemaGenerator: Article Schema.org 结构化数据,支持 SEO 插件兼容模式
- GEO 设置界面: llms.txt 和 Schema.org 配置选项

改进:
- MarkdownFeed/MarkdownEnhancer 重构使用统一 MarkdownProcessor
- 中文阅读时间计算优化 (400字/分钟)
- 多站点缓存键支持
- Schema inLanguage 属性

文档:
- V31-TASK-PLAN.md: 完整的 v3.1 任务计划
- GEO-EVALUATION-REPORT.md: GEO 模块评估报告

Codex 评审: 8.5/10 通过

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 22:03:44 +08:00
LinuxJoy
b813f6869a feat: 添加 GEO 模块 - Markdown Feeds 支持 (v3.0.0)
新增功能:
- Markdown Feeds 支持 (/?feed=markdown, .md 后缀)
- 中文内容优化器 (中英文空格、标点规范化)
- GEO 信号注入 (权威性声明、引用格式)
- AI 爬虫追踪 (GPTBot, ClaudeBot 等)
- HTML 到 Markdown 转换器

技术实现:
- 双模式架构:增强模式 (官方插件) + 独立模式
- 与官方 PR #194 兼容的 Filter Hook
- PHP 7.4+ 兼容
- Codex 代码审查通过

文件:
- includes/GEO/ - 6 个核心类
- tests/GEO/ - 3 个测试文件
- docs/GEO-MARKDOWN-FEEDS.md - 设计文档

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 15:37:37 +08:00
LinuxJoy
c176323a01 feat: 对齐官方 WordPress AI 插件 filter hook
- 更新 filter hook 从 ai_experiments_preferred_models 到
  ai_experiments_preferred_models_for_text_generation
- 与官方 WordPress AI 插件保持兼容

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 14:50:48 +08:00
LinuxJoy
cf596fb9e5 chore: 更新版本号到 2.5.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 16:15:05 +08:00
LinuxJoy
98386f5b18 fix: 调整 Toast 通知位置到 wpmind-title 下方
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 15:00:49 +08:00
LinuxJoy
4240b098fa feat: Phase 2.5 UI 错误反馈优化
- 改进 AJAX 连接测试错误消息 (#25)
  - 显示 HTTP 状态码和重试信息
  - 延长错误消息显示时间到 10 秒
  - 添加 Toast 错误通知
  - 改进超时和网络错误提示

- UI 层面错误反馈优化 (#28)
  - Toast 通知添加图标支持
  - 错误消息显示时间延长到 8 秒
  - 添加 Toast 和测试结果的 CSS 样式
  - 改进视觉反馈效果

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 12:23:36 +08:00
LinuxJoy
b8a8e95f55 feat: Phase 2.5 稳定性增强 - 自动跳过不健康 Provider 和手动优先级设置
- 自动跳过不健康 Provider (#32)
  - PublicAPI::do_chat() 使用 FailoverManager 获取故障转移链
  - 自动尝试链中的下一个健康 Provider

- 手动设置 Provider 优先级 (#33)
  - IntelligentRouter 添加 getManualPriority/setManualPriority 方法
  - FailoverManager 优先使用手动优先级排序
  - 新增 AJAX handler: wpmind_set_provider_priority
  - 路由页面添加拖拽排序 UI
  - 支持保存和清除手动优先级

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 12:19:03 +08:00
LinuxJoy
ae5d2d2b74 fix: 修复 Codex 二次审计发现的问题
- 修复 PublicAPI::get_status 选项键名错误 (wpmind_endpoints → wpmind_custom_endpoints)
- 修复预算邮箱字段名不一致 (alert_email → email_address)
- 完善卸载脚本:添加 wpmind_budget_notices、wpmind_round_robin_index 清理
- 添加动态 transient 清理 (wpmind_cb_* 熔断器状态)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 11:57:24 +08:00
LinuxJoy
b8b23ea596 security: 修复 Codex 审计发现的安全和代码质量问题
- 修复测试端点安全漏洞:nopriv 端点需要 WP_DEBUG + WPMIND_DEV_MODE 双重条件
- 修复选项键名不一致:统一使用 custom_base_url
- 修复 XSS 风险:添加 escapeHtml() 函数转义服务器消息
- 完善卸载脚本:清理所有 9 个选项和 2 个 transient

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 11:45:39 +08:00
LinuxJoy
b369644f39 fix: 确保 ErrorHandler 在 PublicAPI 之前加载
在 load_public_api() 中添加 ErrorHandler 的 require_once,
确保 PublicAPI 使用 ErrorHandler 时类已经存在
2026-02-02 12:39:38 +08:00
LinuxJoy
d63d27ca23 feat: API 健壮性改进 - Phase 1-3 完成
Phase 1: 核心安全改进
- 添加全局循环调用保护 (call_stack 追踪)
- chat() 和 translate() 方法添加循环检测
- 使用 try-finally 确保调用栈正确清理

Phase 3: 错误处理标准化
- 新增 ErrorHandler 类
- 定义标准错误代码常量
- 实现快捷错误创建方法
- 添加错误日志记录和请求追踪

Phase 5: 测试
- 创建集成测试脚本
- 测试循环保护、翻译、拼音、缓存功能

文档
- 更新 API 改进计划
- 标记已完成任务
2026-02-02 12:34:29 +08:00
LinuxJoy
693fd22eb4 feat: 添加语义化拼音功能 (wpmind_pinyin)
- 新增 wpmind_pinyin() 公共函数
- 在 translate prompt 中添加 format=pinyin 支持
- AI 按词语分隔而非按字分隔('你好世界' → 'nihao-shijie')
- 更新 wpslug 集成文档
2026-02-02 11:59:44 +08:00
LinuxJoy
71728ff1c6 perf: 添加 is_available() 静态缓存优化
每个 HTTP 请求只检查一次端点可用性,避免重复遍历端点配置
2026-02-02 11:51:34 +08:00
LinuxJoy
33716c4e3e fix: translate 方法使用 sanitize_title_with_dashes 避免循环调用
使用 sanitize_title_with_dashes() 替代 sanitize_title()
- sanitize_title() 会触发 filter,可能被其他插件拦截
- 这会导致无限递归(如 WPSlug 插件)
- sanitize_title_with_dashes() 直接处理字符串,不触发任何 filter
2026-02-02 11:21:30 +08:00
LinuxJoy
27949053b9 feat: 实现 WPMind 公共 API v2.7.0
新增 API:
- wpmind_summarize() - 文本摘要(paragraph/bullet/title)
- wpmind_moderate() - 内容审核
- wpmind_transcribe() - 音频转录(语音转文字)
- wpmind_speech() - 文本转语音(TTS)

增强 chat() 支持:
- tools 参数 - Function Calling 工具定义
- tool_choice 参数 - 工具选择策略
- 响应包含 tool_calls 和 finish_reason

技术特性:
- 摘要支持多种风格和语言
- 审核使用结构化输出保证格式
- 转录支持文件路径和 URL
- 语音合成自动上传到媒体库
2026-02-02 00:34:48 +08:00
LinuxJoy
6126950796 feat: 实现 WPMind 公共 API v2.6.0 增强功能
新增 API:
- wpmind_stream() - 流式输出
- wpmind_structured() - 结构化输出(支持 JSON Schema + 自动重试)
- wpmind_batch() - 批量处理
- wpmind_embed() - 文本嵌入向量
- wpmind_count_tokens() - Token 计数(估算)

技术特性:
- 流式使用 PHP stream context
- 结构化支持自动重试和 Schema 验证
- 批量支持延迟控制和错误处理
- 嵌入支持多服务商自动模型选择
2026-02-02 00:28:23 +08:00
LinuxJoy
3598716753 fix: 修复公共 API 中的类引用和模型获取逻辑
修复:
- WPMind 类命名空间引用
- UsageTracker 静态方法调用
- is_available() 使用 WPMind 实例
- 模型获取从 models 数组取第一个

测试通过:
- wpmind_is_available(): true
- wpmind_get_status(): 正确返回状态
- wpmind_chat(): 1+1=2 (1433ms)
- wpmind_translate(): 你好世界 -> Hello world (1226ms)
2026-02-02 00:22:40 +08:00
188 changed files with 37740 additions and 5339 deletions

View 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
View file

@ -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


View file

@ -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()` 调用多模态 ProviderQwen-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 注入**: 优先级 15AiSummary: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 请求暂不走 SDKv3.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 仅 OpenAIspeech 支持 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 风格设计系统

View file

@ -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 */

View 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
View 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
View 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;
}
}

View 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);
}

View 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;
}

View 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
View 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);
}

View 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);
}

View file

@ -7,501 +7,466 @@

/* Header */
.wpmind-routing-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-100);
min-height: 54px;
box-sizing: border-box;
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-routing-desc {
color: var(--wpmind-gray-600);
margin: 0 0 var(--wpmind-space-6);
}

.wpmind-routing-title {
margin: 0;
padding: 0;
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;
padding: 0;
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-routing-title .dashicons {
color: var(--wpmind-primary);
font-size: 18px;
width: 18px;
height: 18px;
color: var(--wpmind-primary);
font-size: 18px;
width: 18px;
height: 18px;
}

.wpmind-refresh-routing {
display: inline-flex;
align-items: center;
gap: 4px;
display: inline-flex;
align-items: center;
gap: 4px;
}

.wpmind-refresh-routing .dashicons {
font-size: 14px;
width: 14px;
height: 14px;
font-size: 14px;
width: 14px;
height: 14px;
}

/* Stats Dashboard */
.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);
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--wpmind-space-4);
margin-bottom: var(--wpmind-space-6);
}

@media (max-width: 960px) {
.wpmind-routing-stats {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 1200px) {
.wpmind-routing-stats {
grid-template-columns: repeat(2, 1fr);
}
}

@media (max-width: 480px) {
.wpmind-routing-stats {
grid-template-columns: 1fr;
}
.wpmind-routing-stats {
grid-template-columns: 1fr;
}
}

.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);
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--wpmind-space-6);
}

@media (max-width: 960px) {
.wpmind-routing-grid {
grid-template-columns: 1fr;
}
@media (max-width: 1200px) {
.wpmind-routing-grid {
grid-template-columns: 1fr;
}
}

/* Section Styles */
.wpmind-routing-section {
margin-bottom: var(--wpmind-space-4);
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-routing-section:last-child {
margin-bottom: 0;
margin-bottom: 0;
}

.wpmind-routing-section-title {
font-size: var(--wpmind-text-sm);
font-weight: 600;
color: var(--wpmind-gray-900);
margin: 0 0 var(--wpmind-space-2) 0;
padding: 0;
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) 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 */
.wpmind-strategy-list {
display: flex;
flex-direction: column;
border: 1px solid var(--wpmind-gray-200);
border-radius: 4px;
background: #fff;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--wpmind-gray-200);
border-radius: 4px;
background: #fff;
overflow: hidden;
}

.wpmind-strategy-item {
display: flex;
align-items: center;
padding: var(--wpmind-space-3) var(--wpmind-space-4);
cursor: pointer;
border-bottom: 1px solid var(--wpmind-gray-100);
transition: background-color 0.15s ease;
gap: var(--wpmind-space-3);
position: relative;
display: flex;
align-items: center;
padding: var(--wpmind-space-3) var(--wpmind-space-4);
cursor: pointer;
border-bottom: 1px solid var(--wpmind-gray-100);
transition: background-color 0.15s ease;
gap: var(--wpmind-space-3);
position: relative;
}

.wpmind-strategy-item:last-child {
border-bottom: none;
border-bottom: none;
}

.wpmind-strategy-item:hover {
background: var(--wpmind-gray-50);
background: var(--wpmind-gray-50);
}

.wpmind-strategy-item.is-active {
background: var(--wpmind-primary-light);
background: var(--wpmind-primary-light);
}

.wpmind-strategy-item input[type="radio"] {
position: absolute;
opacity: 0;
pointer-events: none;
position: absolute;
opacity: 0;
pointer-events: none;
}

/* Icon */
.wpmind-strategy-item-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--wpmind-gray-100);
border-radius: 6px;
flex-shrink: 0;
color: var(--wpmind-gray-600);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--wpmind-gray-100);
border-radius: 6px;
flex-shrink: 0;
color: var(--wpmind-gray-600);
}

.wpmind-strategy-item.is-active .wpmind-strategy-item-icon {
background: #fff;
color: var(--wpmind-primary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
background: #fff;
color: var(--wpmind-primary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}

.wpmind-strategy-item-icon .dashicons {
font-size: 18px;
width: 18px;
height: 18px;
font-size: 18px;
width: 18px;
height: 18px;
}

/* Content */
.wpmind-strategy-item-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}

.wpmind-strategy-item-title {
font-size: 13px;
font-weight: 600;
color: var(--wpmind-gray-800);
margin-bottom: 2px;
font-size: 13px;
font-weight: 600;
color: var(--wpmind-gray-800);
margin-bottom: 2px;
}

.wpmind-strategy-item-desc {
font-size: 12px;
color: var(--wpmind-gray-500);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--wpmind-gray-500);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.wpmind-strategy-item.is-active .wpmind-strategy-item-title {
color: var(--wpmind-primary-dark);
color: var(--wpmind-primary-dark);
}

.wpmind-strategy-item.is-active .wpmind-strategy-item-desc {
color: var(--wpmind-primary);
opacity: 0.8;
color: var(--wpmind-primary);
opacity: 0.8;
}

/* Check Mark */
.wpmind-strategy-item-check {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
color: var(--wpmind-primary);
opacity: 0;
transform: scale(0.8);
transition: all 0.2s ease;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
color: var(--wpmind-primary);
opacity: 0;
transform: scale(0.8);
transition: all 0.2s ease;
}

.wpmind-strategy-item.is-active .wpmind-strategy-item-check {
opacity: 1;
transform: scale(1);
opacity: 1;
transform: scale(1);
}

.wpmind-strategy-item-check .dashicons {
font-size: 20px;
width: 20px;
height: 20px;
font-size: 20px;
width: 20px;
height: 20px;
}

/* Responsive */
@media (max-width: 600px) {
.wpmind-strategy-item-desc {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.wpmind-strategy-item-desc {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}

/* Status Card - 推荐 Provider 卡片 */
.wpmind-routing-status-card {
background: linear-gradient(135deg, var(--wpmind-success-light) 0%, #d1fae5 100%);
border: 1px solid #a7f3d0;
padding: var(--wpmind-space-4);
margin-bottom: var(--wpmind-space-4);
background: var(--wpmind-success-light);
border: 1px solid var(--wpmind-success-light);
padding: var(--wpmind-space-4);
margin-bottom: var(--wpmind-space-4);
}

.wpmind-routing-status-header {
margin-bottom: var(--wpmind-space-2);
margin-bottom: var(--wpmind-space-2);
}

.wpmind-routing-status-badge {
display: inline-block;
padding: 2px 8px;
background: var(--wpmind-success);
color: #fff;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
border-radius: 2px;
display: inline-block;
padding: 2px 8px;
background: var(--wpmind-success);
color: #fff;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
border-radius: 2px;
}

.wpmind-routing-status-main {
display: flex;
align-items: center;
gap: var(--wpmind-space-2);
margin-bottom: var(--wpmind-space-3);
display: flex;
align-items: center;
gap: var(--wpmind-space-2);
margin-bottom: var(--wpmind-space-3);
}

.wpmind-routing-status-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--wpmind-success);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--wpmind-success);
border-radius: 50%;
}

.wpmind-routing-status-icon .dashicons {
font-size: 18px;
width: 18px;
height: 18px;
color: #fff;
font-size: 18px;
width: 18px;
height: 18px;
color: #fff;
}

.wpmind-routing-status-provider {
font-size: var(--wpmind-text-lg);
font-weight: 700;
color: #065f46;
font-size: var(--wpmind-text-lg);
font-weight: 700;
color: var(--wpmind-success);
}

.wpmind-routing-status-score {
display: flex;
align-items: baseline;
gap: var(--wpmind-space-2);
display: flex;
align-items: baseline;
gap: var(--wpmind-space-2);
}

.wpmind-routing-status-score-label {
font-size: var(--wpmind-text-xs);
color: #047857;
font-size: var(--wpmind-text-xs);
color: var(--wpmind-success);
}

.wpmind-routing-status-score-value {
font-size: var(--wpmind-text-xl);
font-weight: 700;
color: #065f46;
font-size: var(--wpmind-text-xl);
font-weight: 700;
color: var(--wpmind-success);
}

/* Failover Flow - 故障转移链可视化 */
.wpmind-routing-failover-flow {
display: flex;
flex-direction: column;
padding: var(--wpmind-space-3) 0;
display: flex;
flex-direction: column;
padding: var(--wpmind-space-3) 0;
}

.wpmind-routing-failover-node {
display: flex;
align-items: center;
gap: var(--wpmind-space-3);
padding: var(--wpmind-space-2) 0;
position: relative;
display: flex;
align-items: center;
gap: var(--wpmind-space-3);
padding: var(--wpmind-space-2) 0;
position: relative;
}

.wpmind-routing-failover-dot {
width: 12px;
height: 12px;
background: var(--wpmind-gray-300);
border-radius: 50%;
flex-shrink: 0;
position: relative;
z-index: 1;
width: 12px;
height: 12px;
background: var(--wpmind-gray-300);
border-radius: 50%;
flex-shrink: 0;
position: relative;
z-index: 1;
}

.wpmind-routing-failover-node.is-active .wpmind-routing-failover-dot {
background: var(--wpmind-success);
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.2);
background: var(--wpmind-success);
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.2);
}

.wpmind-routing-failover-name {
font-size: var(--wpmind-text-sm);
color: var(--wpmind-gray-600);
font-size: var(--wpmind-text-sm);
color: var(--wpmind-gray-600);
}

.wpmind-routing-failover-node.is-active .wpmind-routing-failover-name {
font-weight: 600;
color: var(--wpmind-gray-900);
font-weight: 600;
color: var(--wpmind-gray-900);
}

.wpmind-routing-failover-badge {
display: inline-block;
padding: 1px 6px;
background: var(--wpmind-success);
color: #fff;
font-size: 10px;
font-weight: 600;
border-radius: 2px;
display: inline-block;
padding: 1px 6px;
background: var(--wpmind-success);
color: #fff;
font-size: 10px;
font-weight: 600;
border-radius: 2px;
}

.wpmind-routing-failover-line {
width: 2px;
height: 16px;
background: var(--wpmind-gray-200);
margin-left: 5px;
width: 2px;
height: 16px;
background: var(--wpmind-gray-200);
margin-left: 5px;
}

/* Provider Scores - 排名区域 */
.wpmind-routing-ranking {
padding: var(--wpmind-space-5);
border-top: 1px solid var(--wpmind-gray-100);
margin: 0;
margin: 0;
}

.wpmind-routing-scores {
display: flex;
flex-direction: column;
gap: var(--wpmind-space-2);
display: flex;
flex-direction: column;
gap: var(--wpmind-space-2);
}

.wpmind-routing-score-item {
display: flex;
align-items: center;
gap: var(--wpmind-space-3);
padding: var(--wpmind-space-3) var(--wpmind-space-4);
background: var(--wpmind-gray-50);
border: 1px solid var(--wpmind-gray-200);
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);
}

.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 {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--wpmind-gray-200);
font-size: var(--wpmind-text-sm);
font-weight: 700;
color: var(--wpmind-gray-600);
border-radius: 2px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--wpmind-gray-200);
font-size: var(--wpmind-text-sm);
font-weight: 700;
color: var(--wpmind-gray-600);
border-radius: 2px;
flex-shrink: 0;
}

.wpmind-routing-score-item.is-top .wpmind-routing-rank {
background: #f59e0b;
color: #fff;
background: var(--wpmind-warning);
color: #fff;
}

.wpmind-routing-provider-name {
font-size: var(--wpmind-text-sm);
font-weight: 600;
color: var(--wpmind-gray-900);
min-width: 100px;
font-size: var(--wpmind-text-sm);
font-weight: 600;
color: var(--wpmind-gray-900);
min-width: 100px;
}

.wpmind-routing-latency {
font-size: 11px;
color: var(--wpmind-gray-500);
background: var(--wpmind-gray-100);
padding: 2px 6px;
border-radius: 3px;
flex-shrink: 0;
font-size: 11px;
color: var(--wpmind-gray-500);
background: var(--wpmind-gray-100);
padding: 2px 6px;
border-radius: 3px;
flex-shrink: 0;
}

.wpmind-routing-score-bar {
flex: 1;
height: 6px;
background: var(--wpmind-gray-200);
overflow: hidden;
flex: 1;
height: 6px;
background: var(--wpmind-gray-200);
overflow: hidden;
}

.wpmind-routing-score-fill {
height: 100%;
background: var(--wpmind-primary);
transition: width var(--wpmind-transition-slow);
height: 100%;
background: var(--wpmind-primary);
transition: width var(--wpmind-transition-slow);
}

.wpmind-routing-score-item.is-top .wpmind-routing-score-fill {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
background: var(--wpmind-warning);
}

.wpmind-routing-score-value {
font-size: var(--wpmind-text-sm);
font-weight: 600;
color: var(--wpmind-gray-600);
min-width: 40px;
text-align: right;
font-size: var(--wpmind-text-sm);
font-weight: 600;
color: var(--wpmind-gray-600);
min-width: 40px;
text-align: right;
}

.wpmind-routing-score-item.is-top .wpmind-routing-score-value {
color: #b45309;
color: var(--wpmind-warning);
}

View file

@ -9,496 +9,548 @@
Status Panel - 服务状态面板block-visibility 风格)
======================================== */
.wpmind-status-panel {
background: #fff;
box-shadow: var(--wpmind-shadow);
margin: var(--wpmind-space-6) 0;
background: #fff;
box-shadow: var(--wpmind-shadow);
margin: var(--wpmind-space-6) 0;
}

.wpmind-status-panel .title {
margin: 0;
padding: var(--wpmind-space-4) var(--wpmind-space-6);
border-bottom: 1px solid #ccd0d4;
display: flex;
align-items: center;
gap: var(--wpmind-space-3);
font-size: var(--wpmind-text-md);
font-weight: 500;
color: #1e1e1e;
min-height: 54px;
box-sizing: border-box;
margin: 0;
padding: var(--wpmind-space-4) var(--wpmind-space-6);
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: 600;
color: var(--wpmind-gray-900);
min-height: 54px;
box-sizing: border-box;
}

.wpmind-status-panel .title .button {
min-height: 32px;
display: inline-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);
min-height: 32px;
display: inline-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);
}

.wpmind-status-panel .title .button:hover {
background: var(--wpmind-gray-100);
border-color: var(--wpmind-gray-400);
background: var(--wpmind-gray-100);
border-color: var(--wpmind-gray-400);
}

.wpmind-status-panel .title .dashicons {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}

.wpmind-refresh-status.is-loading {
pointer-events: none;
opacity: 0.7;
pointer-events: none;
opacity: 0.7;
}

.wpmind-refresh-status.is-loading .dashicons {
animation: wpmind-spin 0.8s linear infinite;
animation: wpmind-spin 0.8s linear infinite;
}

.wpmind-reset-all-breakers {
margin-left: auto;
color: var(--wpmind-error);
border-color: var(--wpmind-error);
margin-left: auto;
color: var(--wpmind-error);
border-color: var(--wpmind-error);
}

.wpmind-reset-all-breakers:hover {
background: var(--wpmind-error-light) !important;
border-color: var(--wpmind-error) !important;
color: var(--wpmind-error);
background: var(--wpmind-error-light) !important;
border-color: var(--wpmind-error) !important;
color: var(--wpmind-error);
}

.wpmind-status-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--wpmind-space-6);
padding: var(--wpmind-space-6);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--wpmind-space-6);
padding: var(--wpmind-space-6);
}

.wpmind-status-item {
display: flex;
align-items: center;
gap: var(--wpmind-space-3);
padding: var(--wpmind-space-3) var(--wpmind-space-4);
background: #fff;
box-shadow: var(--wpmind-shadow);
font-size: var(--wpmind-text-base);
transition: all var(--wpmind-transition-normal);
display: flex;
align-items: center;
gap: var(--wpmind-space-3);
padding: var(--wpmind-space-3) var(--wpmind-space-4);
background: #fff;
box-shadow: var(--wpmind-shadow);
font-size: var(--wpmind-text-base);
transition: all var(--wpmind-transition-normal);
}

.wpmind-status-item:hover {
border-color: var(--wpmind-gray-300);
box-shadow: var(--wpmind-shadow-sm);
border-color: var(--wpmind-gray-300);
box-shadow: var(--wpmind-shadow-sm);
}

.wpmind-status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}

.wpmind-status-indicator.wpmind-status-closed {
background: var(--wpmind-success);
box-shadow: 0 0 0 3px var(--wpmind-success-light);
background: var(--wpmind-success);
box-shadow: 0 0 0 3px var(--wpmind-success-light);
}

.wpmind-status-indicator.wpmind-status-open {
background: var(--wpmind-error);
box-shadow: 0 0 0 3px var(--wpmind-error-light);
animation: wpmind-pulse 2s ease-in-out infinite;
background: var(--wpmind-error);
box-shadow: 0 0 0 3px var(--wpmind-error-light);
animation: wpmind-pulse 2s ease-in-out infinite;
}

.wpmind-status-indicator.wpmind-status-half_open {
background: var(--wpmind-warning);
box-shadow: 0 0 0 3px var(--wpmind-warning-light);
background: var(--wpmind-warning);
box-shadow: 0 0 0 3px var(--wpmind-warning-light);
}

.wpmind-status-name {
font-weight: 600;
color: var(--wpmind-gray-900);
font-weight: 600;
color: var(--wpmind-gray-900);
}

.wpmind-status-label {
color: var(--wpmind-gray-500);
font-size: var(--wpmind-text-xs);
padding: 2px var(--wpmind-space-2);
background: #fff;
border-radius: 0;
border: 1px solid var(--wpmind-gray-200);
color: var(--wpmind-gray-500);
font-size: var(--wpmind-text-xs);
padding: 2px var(--wpmind-space-2);
background: #fff;
border-radius: 0;
border: 1px solid var(--wpmind-gray-200);
}

.wpmind-status-score {
margin-left: auto;
background: var(--wpmind-primary);
color: #fff;
padding: 3px var(--wpmind-space-2);
border-radius: 0;
font-size: var(--wpmind-text-xs);
font-weight: 600;
margin-left: auto;
background: var(--wpmind-primary);
color: #fff;
padding: 3px var(--wpmind-space-2);
border-radius: 0;
font-size: var(--wpmind-text-xs);
font-weight: 600;
}

.wpmind-status-recovery {
color: var(--wpmind-error);
font-size: var(--wpmind-text-xs);
font-weight: 500;
margin-left: var(--wpmind-space-1);
color: var(--wpmind-error);
font-size: var(--wpmind-text-xs);
font-weight: 500;
margin-left: var(--wpmind-space-1);
}

.wpmind-status-official {
background: var(--wpmind-info-light);
color: var(--wpmind-info);
background: var(--wpmind-info-light);
color: var(--wpmind-info);
}

/* ========================================
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);
display: flex;
align-items: center;
gap: var(--wpmind-space-2);
font-size: var(--wpmind-text-lg);
font-weight: 600;
color: var(--wpmind-gray-900);
.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-panel .title .dashicons {
color: var(--wpmind-primary);
.wpmind-budget-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-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);
}

.wpmind-budget-status-badge {
font-size: var(--wpmind-text-xs);
font-weight: 500;
padding: 2px var(--wpmind-space-2);
border-radius: 0;
margin-left: auto;
font-size: var(--wpmind-text-xs);
font-weight: 500;
padding: 2px var(--wpmind-space-2);
border-radius: 0;
margin-left: auto;
}

.wpmind-budget-status-badge.wpmind-budget-enabled {
background: var(--wpmind-success-light);
color: var(--wpmind-success);
background: var(--wpmind-success-light);
color: var(--wpmind-success);
}

.wpmind-budget-toggle-row {
margin-bottom: var(--wpmind-space-5);
margin-bottom: var(--wpmind-space-5);
}

.wpmind-budget-toggle-row .description {
margin-top: var(--wpmind-space-2);
margin-left: 50px;
margin-top: var(--wpmind-space-2);
margin-left: 50px;
}

.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 {
margin-bottom: 0;
margin-bottom: 0;
}

.wpmind-budget-section h3 {
font-size: var(--wpmind-text-sm);
font-weight: 600;
color: var(--wpmind-gray-900);
margin: 0 0 var(--wpmind-space-3) 0;
padding: 0;
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-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));
gap: var(--wpmind-space-4);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--wpmind-space-4);
}

.wpmind-budget-row {
display: flex;
gap: var(--wpmind-space-6);
flex-wrap: wrap;
display: flex;
gap: var(--wpmind-space-6);
flex-wrap: wrap;
}

.wpmind-budget-field {
flex: 1;
min-width: 180px;
flex: 1;
min-width: 180px;
}

.wpmind-budget-field label {
display: block;
font-size: var(--wpmind-text-sm);
font-weight: 500;
color: var(--wpmind-gray-900);
margin-bottom: var(--wpmind-space-2);
display: block;
font-size: var(--wpmind-text-sm);
font-weight: 500;
color: var(--wpmind-gray-900);
margin-bottom: var(--wpmind-space-2);
}

.wpmind-budget-input-group {
display: flex;
align-items: center;
gap: var(--wpmind-space-1);
display: flex;
align-items: center;
gap: var(--wpmind-space-1);
}

.wpmind-budget-currency {
font-size: var(--wpmind-text-base);
font-weight: 600;
color: var(--wpmind-gray-600);
min-width: 16px;
font-size: var(--wpmind-text-base);
font-weight: 600;
color: var(--wpmind-gray-600);
min-width: 16px;
}

.wpmind-budget-suffix {
font-size: var(--wpmind-text-sm);
color: var(--wpmind-gray-600);
font-size: var(--wpmind-text-sm);
color: var(--wpmind-gray-600);
}

.wpmind-budget-field input[type="number"] {
width: 100px;
width: 100px;
}

.wpmind-budget-field select {
width: 100%;
max-width: 400px;
width: 100%;
max-width: 400px;
}

.wpmind-budget-field .description {
margin-top: var(--wpmind-space-1);
font-size: var(--wpmind-text-xs);
margin-top: var(--wpmind-space-1);
font-size: var(--wpmind-text-xs);
}

/* Budget Progress Bar */
.wpmind-budget-progress-wrap {
display: flex;
align-items: center;
gap: var(--wpmind-space-2);
margin-top: var(--wpmind-space-2);
display: flex;
align-items: center;
gap: var(--wpmind-space-2);
margin-top: var(--wpmind-space-2);
}

.wpmind-budget-progress {
flex: 1;
height: 6px;
background: var(--wpmind-gray-200);
border-radius: 0;
overflow: hidden;
flex: 1;
height: 6px;
background: var(--wpmind-gray-200);
border-radius: 0;
overflow: hidden;
}

.wpmind-budget-progress-bar {
height: 100%;
background: var(--wpmind-primary);
border-radius: 0;
transition: width var(--wpmind-transition-slow);
height: 100%;
background: var(--wpmind-primary);
border-radius: 0;
transition: width var(--wpmind-transition-slow);
}

.wpmind-budget-progress-bar.wpmind-budget-warning {
background: var(--wpmind-warning);
background: var(--wpmind-warning);
}

.wpmind-budget-progress-bar.wpmind-budget-exceeded {
background: var(--wpmind-error);
background: var(--wpmind-error);
}

.wpmind-budget-percentage {
font-size: var(--wpmind-text-xs);
font-weight: 600;
color: var(--wpmind-gray-600);
min-width: 40px;
text-align: right;
font-size: var(--wpmind-text-xs);
font-weight: 600;
color: var(--wpmind-gray-600);
min-width: 40px;
text-align: right;
}

/* Budget Checkboxes */
.wpmind-budget-checkboxes {
display: flex;
flex-direction: column;
gap: var(--wpmind-space-3);
display: flex;
flex-direction: column;
gap: var(--wpmind-space-3);
}

.wpmind-budget-checkboxes label {
display: flex;
align-items: center;
gap: var(--wpmind-space-2);
font-size: var(--wpmind-text-sm);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--wpmind-space-2);
font-size: var(--wpmind-text-sm);
cursor: pointer;
}

.wpmind-budget-email-field {
margin-left: var(--wpmind-space-6);
margin-top: var(--wpmind-space-2);
margin-left: var(--wpmind-space-6);
margin-top: var(--wpmind-space-2);
}

/* Budget Actions */
.wpmind-budget-actions {
display: flex;
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);
display: flex;
align-items: center;
gap: var(--wpmind-space-3);
margin-top: var(--wpmind-space-5);
}

.wpmind-budget-actions .spinner {
float: none;
margin: 0;
float: none;
margin: 0;
}

/* Budget Status Classes */
.wpmind-budget-ok {
color: var(--wpmind-success);
color: var(--wpmind-success);
}

.wpmind-budget-warning {
color: var(--wpmind-warning);
color: var(--wpmind-warning);
}

.wpmind-budget-exceeded {
color: var(--wpmind-error);
color: var(--wpmind-error);
}

/* Budget Badge */
.wpmind-budget-badge {
display: inline-block;
font-size: var(--wpmind-text-xs);
font-weight: 500;
padding: 2px var(--wpmind-space-2);
border-radius: 0;
display: inline-block;
font-size: var(--wpmind-text-xs);
font-weight: 500;
padding: 2px var(--wpmind-space-2);
border-radius: 0;
}

.wpmind-budget-badge.wpmind-budget-ok {
background: var(--wpmind-success-light);
color: var(--wpmind-success);
background: var(--wpmind-success-light);
color: var(--wpmind-success);
}

.wpmind-budget-badge.wpmind-budget-warning {
background: var(--wpmind-warning-light);
color: var(--wpmind-warning);
background: var(--wpmind-warning-light);
color: var(--wpmind-warning);
}

.wpmind-budget-badge.wpmind-budget-exceeded {
background: var(--wpmind-error-light);
color: var(--wpmind-error);
background: var(--wpmind-error-light);
color: var(--wpmind-error);
}

/* ========================================
Analytics Panel - 分析仪表板
======================================== */
.wpmind-analytics-panel {
background: #fff;
border: 1px solid var(--wpmind-gray-200);
border-radius: 0;
padding: var(--wpmind-space-5);
margin-bottom: var(--wpmind-space-5);
background: #fff;
border: 1px solid var(--wpmind-gray-200);
border-radius: 0;
padding: var(--wpmind-space-5);
margin-bottom: var(--wpmind-space-5);
}

.wpmind-analytics-panel .title {
display: flex;
align-items: center;
gap: var(--wpmind-space-2);
margin: 0 0 var(--wpmind-space-5) 0;
padding-bottom: var(--wpmind-space-3);
border-bottom: 1px solid var(--wpmind-gray-200);
display: flex;
align-items: center;
gap: var(--wpmind-space-2);
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 {
margin-left: auto;
padding: var(--wpmind-space-2) var(--wpmind-space-3);
border: 1px solid var(--wpmind-gray-200);
border-radius: 0;
font-size: var(--wpmind-text-sm);
color: var(--wpmind-gray-700);
background: #fff;
cursor: pointer;
margin-left: auto;
padding: var(--wpmind-space-2) var(--wpmind-space-3);
border: 1px solid var(--wpmind-gray-200);
border-radius: 0;
font-size: var(--wpmind-text-sm);
color: var(--wpmind-gray-700);
background: #fff;
cursor: pointer;
}

.wpmind-analytics-range-select:focus {
outline: none;
border-color: var(--wpmind-primary);
outline: none;
border-color: var(--wpmind-primary);
}

.wpmind-refresh-analytics {
padding: var(--wpmind-space-2) !important;
min-height: auto !important;
padding: var(--wpmind-space-2) !important;
min-height: auto !important;
}

.wpmind-analytics-content {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--wpmind-space-5);
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--wpmind-space-5);
}

.wpmind-chart-container {
background: #fff;
border: 1px solid var(--wpmind-gray-200);
border-radius: 0;
padding: var(--wpmind-space-5);
background: #fff;
border: 1px solid var(--wpmind-gray-200);
border-radius: 0;
padding: var(--wpmind-space-5);
}

.wpmind-chart-container h3 {
margin: 0 0 var(--wpmind-space-4) 0;
font-size: var(--wpmind-text-sm);
font-weight: 600;
color: var(--wpmind-gray-700);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 var(--wpmind-space-4) 0;
font-size: var(--wpmind-text-sm);
font-weight: 600;
color: var(--wpmind-gray-700);
text-transform: uppercase;
letter-spacing: 0.5px;
}

.wpmind-chart-wrapper {
position: relative;
height: 280px;
position: relative;
height: 280px;
}

.wpmind-chart-wrapper canvas {
max-width: 100%;
max-width: 100%;
}

.wpmind-chart-container.is-loading {
position: relative;
position: relative;
}

.wpmind-chart-container.is-loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 24px;
height: 24px;
margin: -12px 0 0 -12px;
border: 2px solid var(--wpmind-gray-200);
border-top-color: var(--wpmind-primary);
border-radius: 50%;
animation: wpmind-spin 0.8s linear infinite;
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 24px;
height: 24px;
margin: -12px 0 0 -12px;
border: 2px solid var(--wpmind-gray-200);
border-top-color: var(--wpmind-primary);
border-radius: 50%;
animation: wpmind-spin 0.8s linear infinite;
}

.wpmind-chart-container.is-loading .wpmind-chart-wrapper {
opacity: 0.3;
opacity: 0.3;
}

/* ========================================
Routing Panel - 智能路由面板
======================================== */
.wpmind-routing-panel {
background: #fff;
box-shadow: var(--wpmind-shadow);
padding: 0;
margin: var(--wpmind-space-5) 0;
background: #fff;
box-shadow: var(--wpmind-shadow);
padding: 0;
margin: var(--wpmind-space-5) 0;
}


@ -507,121 +559,121 @@
Dialog - 确认对话框
======================================== */
.wpmind-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 100200;
opacity: 0;
transition: opacity var(--wpmind-transition-slow);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 100200;
opacity: 0;
transition: opacity var(--wpmind-transition-slow);
}

.wpmind-dialog-overlay.is-visible {
opacity: 1;
opacity: 1;
}

.wpmind-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.9);
background: #fff;
border-radius: 0;
box-shadow: var(--wpmind-shadow-lg);
z-index: 100201;
min-width: 360px;
max-width: 480px;
opacity: 0;
transition: all var(--wpmind-transition-slow);
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.9);
background: #fff;
border-radius: 0;
box-shadow: var(--wpmind-shadow-lg);
z-index: 100201;
min-width: 360px;
max-width: 480px;
opacity: 0;
transition: all var(--wpmind-transition-slow);
}

.wpmind-dialog.is-visible {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}

.wpmind-dialog-header {
display: flex;
align-items: center;
gap: var(--wpmind-space-3);
padding: var(--wpmind-space-5) var(--wpmind-space-6) var(--wpmind-space-4);
border-bottom: 1px solid var(--wpmind-gray-100);
display: flex;
align-items: center;
gap: var(--wpmind-space-3);
padding: var(--wpmind-space-5) var(--wpmind-space-6) var(--wpmind-space-4);
border-bottom: 1px solid var(--wpmind-gray-100);
}

.wpmind-dialog-header .dashicons {
font-size: 24px;
width: 24px;
height: 24px;
font-size: 24px;
width: 24px;
height: 24px;
}

.wpmind-dialog-title {
font-size: var(--wpmind-text-lg);
font-weight: 600;
color: var(--wpmind-gray-900);
font-size: var(--wpmind-text-lg);
font-weight: 600;
color: var(--wpmind-gray-900);
}

.wpmind-dialog-body {
padding: var(--wpmind-space-5) var(--wpmind-space-6);
padding: var(--wpmind-space-5) var(--wpmind-space-6);
}

.wpmind-dialog-body p {
margin: 0;
font-size: var(--wpmind-text-base);
line-height: var(--wpmind-leading-relaxed);
color: var(--wpmind-gray-600);
margin: 0;
font-size: var(--wpmind-text-base);
line-height: var(--wpmind-leading-relaxed);
color: var(--wpmind-gray-600);
}

.wpmind-dialog-footer {
display: flex;
justify-content: flex-end;
gap: var(--wpmind-space-3);
padding: var(--wpmind-space-4) var(--wpmind-space-6) var(--wpmind-space-5);
border-top: 1px solid var(--wpmind-gray-100);
display: flex;
justify-content: flex-end;
gap: var(--wpmind-space-3);
padding: var(--wpmind-space-4) var(--wpmind-space-6) var(--wpmind-space-5);
border-top: 1px solid var(--wpmind-gray-100);
}

.wpmind-dialog-footer .button {
min-height: 36px;
padding: 0 var(--wpmind-space-5);
font-size: var(--wpmind-text-base);
border-radius: 0;
min-height: 36px;
padding: 0 var(--wpmind-space-5);
font-size: var(--wpmind-text-base);
border-radius: 0;
}

/* Dialog Types */
.wpmind-dialog-warning .wpmind-dialog-header .dashicons {
color: var(--wpmind-warning);
color: var(--wpmind-warning);
}

.wpmind-dialog-danger .wpmind-dialog-header .dashicons {
color: var(--wpmind-error);
color: var(--wpmind-error);
}

.wpmind-dialog-danger .wpmind-dialog-confirm {
background: var(--wpmind-error);
border-color: var(--wpmind-error);
background: var(--wpmind-error);
border-color: var(--wpmind-error);
}

.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 {
color: var(--wpmind-info);
color: var(--wpmind-info);
}

.wpmind-dialog-success .wpmind-dialog-header .dashicons {
color: var(--wpmind-success);
color: var(--wpmind-success);
}

/* ========================================
Notice Container
======================================== */
.wpmind-notice-container {
margin-bottom: var(--wpmind-space-5);
margin-bottom: var(--wpmind-space-5);
}

.wpmind-notice-container .notice {
margin: var(--wpmind-space-1) 0;
margin: var(--wpmind-space-1) 0;
}

View file

@ -9,182 +9,182 @@
Tablet (max-width: 1200px)
======================================== */
@media (max-width: 1200px) {
.wpmind-analytics-content {
grid-template-columns: 1fr;
}
.wpmind-analytics-content {
grid-template-columns: 1fr;
}
}

/* ========================================
Mobile Landscape (max-width: 960px)
======================================== */
@media (max-width: 960px) {
.wpmind-endpoints-grid {
grid-template-columns: 1fr;
}
.wpmind-endpoints-grid {
grid-template-columns: 1fr;
}
}

/* ========================================
Tablet Portrait (max-width: 782px)
======================================== */
@media (max-width: 782px) {
/* Tabs */
.wpmind-tabs {
display: flex;
flex-wrap: wrap;
gap: var(--wpmind-space-1);
}
/* Tabs */
.wpmind-tabs {
display: flex;
flex-wrap: wrap;
gap: var(--wpmind-space-1);
}

.wpmind-tabs .nav-tab {
flex: 1;
justify-content: center;
padding: var(--wpmind-space-2) var(--wpmind-space-3);
font-size: var(--wpmind-text-sm);
min-width: 0;
}
.wpmind-tabs .nav-tab {
flex: 1;
justify-content: center;
padding: var(--wpmind-space-2) var(--wpmind-space-3);
font-size: var(--wpmind-text-sm);
min-width: 0;
}

.wpmind-tabs .nav-tab .dashicons {
font-size: 16px;
width: 16px;
height: 16px;
}
.wpmind-tabs .nav-tab .dashicons {
font-size: 16px;
width: 16px;
height: 16px;
}

.wpmind-tab-content {
padding: var(--wpmind-space-4);
}
.wpmind-tab-content {
padding: var(--wpmind-space-4);
}

/* Usage Panel */
.wpmind-usage-cards {
grid-template-columns: 1fr 1fr;
}
/* Usage Panel */
.wpmind-usage-cards {
grid-template-columns: 1fr 1fr;
}

.wpmind-usage-card-body {
flex-direction: column;
gap: var(--wpmind-space-2);
}
.wpmind-usage-card-body {
flex-direction: column;
gap: var(--wpmind-space-2);
}

.wpmind-usage-stat {
display: flex;
justify-content: space-between;
align-items: center;
text-align: left;
}
.wpmind-usage-stat {
display: flex;
justify-content: space-between;
align-items: center;
text-align: left;
}

.wpmind-usage-value {
font-size: var(--wpmind-text-lg);
}
.wpmind-usage-value {
font-size: var(--wpmind-text-lg);
}

.wpmind-usage-value.wpmind-usage-cost {
font-size: var(--wpmind-text-sm);
}
.wpmind-usage-value.wpmind-usage-cost {
font-size: var(--wpmind-text-sm);
}

.wpmind-usage-label {
margin-top: 0;
order: -1;
}
.wpmind-usage-label {
margin-top: 0;
order: -1;
}

.wpmind-usage-panel .title {
flex-wrap: wrap;
}
.wpmind-usage-header {
flex-wrap: wrap;
}

.wpmind-last-updated {
width: 100%;
order: 10;
margin-top: var(--wpmind-space-2);
}
.wpmind-last-updated {
width: 100%;
order: 10;
margin-top: var(--wpmind-space-2);
}

/* Budget Panel */
.wpmind-budget-grid {
grid-template-columns: 1fr 1fr;
}
/* Budget Panel */
.wpmind-budget-grid {
grid-template-columns: 1fr 1fr;
}

.wpmind-budget-row {
flex-direction: column;
gap: var(--wpmind-space-4);
}
.wpmind-budget-row {
flex-direction: column;
gap: var(--wpmind-space-4);
}

/* Analytics Panel */
.wpmind-analytics-panel .title {
flex-wrap: wrap;
}
/* Analytics Panel */
.wpmind-analytics-panel .title {
flex-wrap: wrap;
}

.wpmind-analytics-range-select {
margin-left: 0;
order: 3;
width: 100%;
margin-top: var(--wpmind-space-3);
}
.wpmind-analytics-range-select {
margin-left: 0;
order: 3;
width: 100%;
margin-top: var(--wpmind-space-3);
}

.wpmind-chart-wrapper {
height: 200px;
}
.wpmind-chart-wrapper {
height: 200px;
}

/* Routing Panel */
.wpmind-routing-strategies {
grid-template-columns: 1fr;
}
/* Routing Panel */
.wpmind-routing-strategies {
grid-template-columns: 1fr;
}

.wpmind-routing-score-item {
flex-wrap: wrap;
}
.wpmind-routing-score-item {
flex-wrap: wrap;
}

.wpmind-routing-score-bar {
order: 4;
width: 100%;
margin-top: var(--wpmind-space-2);
}
.wpmind-routing-score-bar {
order: 4;
width: 100%;
margin-top: var(--wpmind-space-2);
}

/* Grid System */
.wpmind-grid-2,
.wpmind-grid-3,
.wpmind-grid-4 {
grid-template-columns: 1fr 1fr;
}
/* Grid System */
.wpmind-grid-2,
.wpmind-grid-3,
.wpmind-grid-4 {
grid-template-columns: 1fr 1fr;
}
}

/* ========================================
Mobile Portrait (max-width: 480px)
======================================== */
@media (max-width: 480px) {
/* Tabs */
.wpmind-tabs .nav-tab {
flex-direction: column;
gap: var(--wpmind-space-1);
padding: var(--wpmind-space-2);
font-size: var(--wpmind-text-xs);
}
/* Tabs */
.wpmind-tabs .nav-tab {
flex-direction: column;
gap: var(--wpmind-space-1);
padding: var(--wpmind-space-2);
font-size: var(--wpmind-text-xs);
}

/* Usage Panel */
.wpmind-usage-cards {
grid-template-columns: 1fr;
}
/* Usage Panel */
.wpmind-usage-cards {
grid-template-columns: 1fr;
}

.wpmind-provider-usage-grid {
grid-template-columns: 1fr;
}
.wpmind-provider-usage-grid {
grid-template-columns: 1fr;
}

/* Budget Panel */
.wpmind-budget-grid {
grid-template-columns: 1fr;
}
/* Budget Panel */
.wpmind-budget-grid {
grid-template-columns: 1fr;
}

/* Dialog */
.wpmind-dialog {
min-width: auto;
left: var(--wpmind-space-5);
right: var(--wpmind-space-5);
transform: translateY(-50%) scale(0.9);
}
/* Dialog */
.wpmind-dialog {
min-width: auto;
left: var(--wpmind-space-5);
right: var(--wpmind-space-5);
transform: translateY(-50%) scale(0.9);
}

.wpmind-dialog.is-visible {
transform: translateY(-50%) scale(1);
}
.wpmind-dialog.is-visible {
transform: translateY(-50%) scale(1);
}

/* Grid System */
.wpmind-grid-2,
.wpmind-grid-3,
.wpmind-grid-4 {
grid-template-columns: 1fr;
}
/* Grid System */
.wpmind-grid-2,
.wpmind-grid-3,
.wpmind-grid-4 {
grid-template-columns: 1fr;
}
}

/* ========================================
@ -194,22 +194,24 @@
.wpmind-tab-pane .wpmind-analytics-panel,
.wpmind-tab-pane .wpmind-status-panel,
.wpmind-tab-pane .wpmind-routing-panel,
.wpmind-tab-pane .wpmind-budget-panel {
border: none;
box-shadow: none;
padding: 0;
margin: 0 0 var(--wpmind-space-8) 0;
background: transparent;
.wpmind-tab-pane .wpmind-budget-panel,
.wpmind-tab-pane .wpmind-geo-panel {
border: none;
box-shadow: none;
padding: 0;
margin: 0 0 var(--wpmind-space-8) 0;
background: transparent;
}

.wpmind-tab-pane .wpmind-usage-panel:last-child,
.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 {
margin-bottom: 0;
.wpmind-tab-pane .wpmind-budget-panel:last-child,
.wpmind-tab-pane .wpmind-geo-panel:last-child {
margin-bottom: 0;
}

.wpmind-tab-pane .title {
margin-top: 0;
margin-top: 0;
}

View 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 );

View 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
View 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
View 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 );

View 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 );

View 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
View 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 );

View 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 );

View 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
View 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
View 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 );

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -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 \

View 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');
}
}

File diff suppressed because it is too large Load diff

View 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();
}
}

View 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);
}
}

View 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);
}
}

View 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,
];
});
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View file

@ -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 缓存时间(秒),默认 6048007天
* }
* @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);
}
}

View 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,
] );
}
}

View 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;
}
}

View 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;
}
}

View 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 . ')',
];
}
}

View 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 次 DBstats + 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' => '',
];
}
}

View 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;
}

View 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,
)
);
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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 [];
}

// 按健康分数排序
usort($available, function ($a, $b) {
return ProviderHealthTracker::getHealthScore($b) - ProviderHealthTracker::getHealthScore($a);
});
// 检查是否有手动优先级设置
$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::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;
}

View file

@ -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
View 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(),
]
);
}
}

View file

@ -6,6 +6,8 @@
* @since 2.4.0
*/

declare(strict_types=1);

namespace WPMind\Providers\Image;

defined( 'ABSPATH' ) || exit;

View file

@ -6,6 +6,8 @@
* @since 2.4.0
*/

declare(strict_types=1);

namespace WPMind\Providers\Image;

defined( 'ABSPATH' ) || exit;

View file

@ -6,6 +6,8 @@
* @since 2.4.0
*/

declare(strict_types=1);

namespace WPMind\Providers\Image;

defined( 'ABSPATH' ) || exit;

View file

@ -6,6 +6,8 @@
* @since 2.4.0
*/

declare(strict_types=1);

namespace WPMind\Providers\Image;

defined( 'ABSPATH' ) || exit;

View file

@ -6,6 +6,8 @@
* @since 2.4.0
*/

declare(strict_types=1);

namespace WPMind\Providers\Image;

defined( 'ABSPATH' ) || exit;

View file

@ -8,6 +8,8 @@
* @since 2.4.0
*/

declare(strict_types=1);

namespace WPMind\Providers\Image;

defined( 'ABSPATH' ) || exit;

View file

@ -6,6 +6,8 @@
* @since 2.4.0
*/

declare(strict_types=1);

namespace WPMind\Providers\Image;

defined( 'ABSPATH' ) || exit;

View file

@ -6,6 +6,8 @@
* @since 2.4.0
*/

declare(strict_types=1);

namespace WPMind\Providers\Image;

defined( 'ABSPATH' ) || exit;

View file

@ -6,6 +6,8 @@
* @since 2.4.0
*/

declare(strict_types=1);

namespace WPMind\Providers\Image;

defined( 'ABSPATH' ) || exit;

View file

@ -6,6 +6,8 @@
* @since 2.4.0
*/

declare(strict_types=1);

namespace WPMind\Providers\Image;

defined( 'ABSPATH' ) || exit;

View file

@ -6,6 +6,8 @@
* @since 2.4.0
*/

declare(strict_types=1);

namespace WPMind\Providers\Image;

defined( 'ABSPATH' ) || exit;

View file

@ -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;
}
}

View file

@ -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

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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,

View 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);
}
}

View file

@ -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;
}

View file

@ -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),
];
}


View file

@ -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);
}
}

View file

@ -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),
];
}


View file

@ -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),
];
}


View file

@ -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
View 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
View 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);
}
}

View 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

File diff suppressed because it is too large Load diff

View 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
{
// 模块卸载时删除数据(可选)
}
}

View file

@ -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',

View 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": [
"用量趋势图表",
"服务商对比分析",
"成本统计报表",
"实时监控面板"
]
}

View 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; ?>

View 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
) );
}
}

View 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 ) );
}
}

View 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 ),
] );
}
}

View 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 : [];
}
}

View 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 );
}
}

View 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 ) ), '+/', '-_' ), '=' );
}
}

View 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 : [];
}
}

View 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,
],
];
}
}

View 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 ] );
}
}

View 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';
}
}

View 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;
}
}

View 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 );
}
}

View 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();
}
}

View 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 );
}
}

View file

@ -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;
}

View 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' ) );
}
}

View 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