feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled

Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X.

WordPress media pipeline
- Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries.

Capability discovery (F.7e)
- Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error.

Companion plugin overhaul
- Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge.
- Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach.
- wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL.

Other
- Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork.
- Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8).
- n8n refactor: structured errors, capability probe, missing tools backfilled.
- Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites.

Docs
- README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it.
- Tools/Tests badges removed in favour of "Plugins: 10".

Deployment
- PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant.

CI
- Black + Ruff clean on Python 3.12; pytest tests/ green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:25:58 +02:00
parent 788439e377
commit f203ca88de
140 changed files with 23802 additions and 2253 deletions

View File

@@ -143,13 +143,13 @@
<div id="wp-seo-note" style="display:none" class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
<p class="text-sm text-amber-700 dark:text-amber-400">
{% if lang == 'fa' %}
<strong>وردپرس:</strong> برای استفاده از ابزارهای SEO، افزونه
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
را روی سایت وردپرسی خود نصب کنید.
<strong>وردپرس:</strong> برای قابلیت‌های SEO، رسانه، و سایر ابزارهای کمکی، افزونه‌ی همراه
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
را دانلود و روی سایت وردپرسی خود نصب کنید.
{% else %}
<strong>WordPress:</strong> For SEO tools, install the
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
plugin on your WordPress site.
<strong>WordPress:</strong> For SEO, media, and other helper tools, download and install the
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
companion plugin on your WordPress site.
{% endif %}
</p>
</div>

View File

@@ -259,13 +259,13 @@
<div id="wp-seo-note" style="display:none" class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
<p class="text-sm text-amber-700 dark:text-amber-400">
{% if lang == 'fa' %}
<strong>وردپرس:</strong> برای استفاده از ابزارهای SEO، افزونه
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
را روی سایت وردپرسی خود نصب کنید.
<strong>وردپرس:</strong> برای قابلیت‌های SEO، رسانه، و سایر ابزارهای کمکی، افزونه‌ی همراه
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
را دانلود و روی سایت وردپرسی خود نصب کنید.
{% else %}
<strong>WordPress:</strong> For SEO tools, install the
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
plugin on your WordPress site.
<strong>WordPress:</strong> For SEO, media, and other helper tools, download and install the
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
companion plugin on your WordPress site.
{% endif %}
</p>
</div>

View File

@@ -134,6 +134,57 @@
{% endif %}
</div>
<!-- F.20 prep: Companion plugin download hint (WP / WC only) -->
{% if companion_download_url %}
<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-xl p-5">
<div class="flex items-start gap-3">
<div class="w-10 h-10 bg-indigo-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-indigo-500 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
{% if lang == 'fa' %}
افزونه همراه Airano MCP Bridge (اختیاری اما توصیه‌شده)
{% else %}
Airano MCP Bridge — companion plugin (optional but recommended)
{% endif %}
</h4>
<p class="mt-1 text-xs text-indigo-800/90 dark:text-indigo-300/90 leading-relaxed">
{% if lang == 'fa' %}
نصب افزونه همراه، این قابلیت‌ها را فعال می‌کند: آپلود فایل بزرگ‌تر از
<code class="font-mono">upload_max_filesize</code>، گزارش یکپارچه‌ی سلامت سایت،
purge کش، پاکسازی transient، متاهای bulk، خروجی ساختاریافته، probe قابلیت‌ها،
و webhook لاگ ممیزی. بدون آن، ابزارهای پایه کار می‌کنند اما موارد فوق غیرفعال خواهند بود.
{% else %}
Installing the companion plugin unlocks: uploads larger than
<code class="font-mono">upload_max_filesize</code>, unified site-health snapshot,
cache purge, transient flush, bulk meta writes, structured export, capability probe,
and audit-hook webhooks. Without it, basic tools still work but those features stay disabled.
{% endif %}
</p>
<div class="mt-3 flex flex-wrap items-center gap-3">
<a href="{{ companion_download_url }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{% if lang == 'fa' %}دانلود airano-mcp-bridge.zip{% else %}Download airano-mcp-bridge.zip{% endif %}
</a>
<span class="text-xs text-indigo-700/70 dark:text-indigo-300/70">
{% if lang == 'fa' %}
نصب: WP Admin → افزونه‌ها → افزودن → Upload Plugin → انتخاب فایل → نصب و فعال‌سازی
{% else %}
Install via WP Admin → Plugins → Add New → Upload Plugin → select the zip → Activate
{% endif %}
</span>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Setup Requirements -->
{% if service.credential_fields %}
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">

View File

@@ -0,0 +1,173 @@
{# F.X.fix #9 — capability-badge partial, HTMX-swappable.
Rendered both (a) inline by manage.html on first page load and (b) as
the response body of GET /api/sites/{id}/capabilities/badge when the
Re-check button is clicked. Swapping this element with hx-swap=
"outerHTML" avoids the full-page reload the legacy button triggered.
F.X.fix-pass2 — wording corrected for WordPress: application passwords
inherit ALL capabilities from the user account, so the "credential
tier" framing is misleading. WP-specific branches reference the WP
user's role instead. AI-provider availability is surfaced here too
so removing a provider key gives visible feedback on Re-check.
Required context:
capability_probe — probe payload (with .fit, .granted, .reason,
.ai_providers_configured, optionally .install_hint)
site — site dict (for .plugin_type, .tool_scope)
lang — "fa" | "en"
companion_download_url — string, may be empty
#}
{% set fit_status = capability_probe.fit.status %}
{% set is_wp = site.plugin_type in ['wordpress', 'wordpress_advanced'] %}
{% set is_wc = site.plugin_type == 'woocommerce' %}
{% set is_wp_like = is_wp or is_wc %}
{% set ai_providers = capability_probe.ai_providers_configured or [] %}
{% set ai_provider_missing = is_wp_like and (ai_providers | length == 0) %}
<div id="capability-badge"
class="border rounded-lg px-4 py-3 text-sm
{% if fit_status == 'ok' %}
bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/30 text-green-800 dark:text-green-300
{% elif fit_status == 'warning' %}
bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/30 text-amber-800 dark:text-amber-300
{% elif fit_status == 'unknown_tier' %}
bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300
{% else %}
bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400
{% endif %}">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
{% if fit_status == 'ok' %}
<div class="font-semibold text-sm">
{% if lang == 'fa' %}✓ دسترسی credential برای سطح انتخاب‌شده کافی است{% else %}✓ Credential grants what this tier needs{% endif %}
</div>
{% if capability_probe.granted %}
<div class="mt-1 text-xs opacity-80">
{% if lang == 'fa' %}دسترسی‌های احراز شده:{% else %}Granted:{% endif %}
<code class="font-mono text-xs">{{ capability_probe.granted | join(', ') }}</code>
</div>
{% endif %}
{% elif fit_status == 'warning' %}
<div class="font-semibold text-sm">
{% if is_wc %}
{% if lang == 'fa' %}
⚠ سطح دسترسی Consumer Key ووکامرس برای سطح «{{ site.tool_scope }}» کافی نیست
{% else %}
⚠ The WooCommerce REST API key permission is below the selected "{{ site.tool_scope }}" tier
{% endif %}
{% elif is_wp %}
{% if lang == 'fa' %}
⚠ کاربر وردپرس این credential نقش لازم برای سطح «{{ site.tool_scope }}» را ندارد
{% else %}
⚠ The WordPress user behind this application password lacks the role for the "{{ site.tool_scope }}" tier
{% endif %}
{% else %}
{% if lang == 'fa' %}
⚠ credential ذخیره‌شده برای سطح «{{ site.tool_scope }}» کافی نیست
{% else %}
⚠ Saved credential is below the selected "{{ site.tool_scope }}" tier
{% endif %}
{% endif %}
</div>
<div class="mt-1 text-xs opacity-90">
{% if lang == 'fa' %}دسترسی‌های ناموجود:{% else %}Missing:{% endif %}
<code class="font-mono text-xs">{{ capability_probe.fit.missing | join(', ') }}</code>
</div>
{% if capability_probe.granted %}
<div class="mt-1 text-xs opacity-80">
{% if lang == 'fa' %}آنچه در دسترس است:{% else %}Currently granted:{% endif %}
<code class="font-mono text-xs">{{ capability_probe.granted | join(', ') }}</code>
</div>
{% endif %}
<div class="mt-2 text-xs">
{% if is_wc %}
{% if lang == 'fa' %}
کلید ووکامرس (Consumer Key + Secret) دارای فیلد <code class="font-mono">permissions</code> با مقادیر
<code class="font-mono">read</code> یا <code class="font-mono">read_write</code> است. گزینه‌ها:
(۱) سطح پایین‌تر انتخاب کنید؛
(۲) در WP Admin → WooCommerce → Settings → Advanced → REST API یک کلید جدید با Permission مناسب (Read / Write یا Read/Write) بسازید و در Connection Settings ذخیره کنید؛
(۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ می‌دهند.
{% else %}
WooCommerce REST API keys carry a <code class="font-mono">permissions</code> field
(<code class="font-mono">read</code> / <code class="font-mono">read_write</code>). Options:
(1) pick a lower access level;
(2) regenerate the REST key with Read/Write permission in WP Admin → WooCommerce → Settings → Advanced → REST API and re-paste it in Connection Settings;
(3) continue — tools needing missing caps will return 403 at call time.
{% endif %}
{% elif is_wp %}
{% if lang == 'fa' %}
application password در وردپرس همیشه تمام دسترسی‌های کاربر صاحب آن را می‌گیرد و قابل محدودکردن نیست. گزینه‌ها:
(۱) سطح دسترسی پایین‌تری انتخاب کنید؛
(۲) application password را از کاربری با نقش بالاتر (مثلاً Administrator یا Editor) بسازید؛
(۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ می‌دهند.
{% else %}
WordPress application passwords inherit ALL capabilities of the user account and cannot be scoped down.
Options: (1) pick a lower access level;
(2) create the application password from a higher-role user (e.g. Administrator or Editor);
(3) continue — tools needing missing caps will return 403 at call time.
{% endif %}
{% else %}
{% if lang == 'fa' %}
گزینه‌ها: (۱) انتخاب سطح پایین‌تر؛ (۲) استفاده از credential با دسترسی بالاتر؛ (۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ می‌دهند.
{% else %}
Options: (1) pick a lower tier; (2) use a higher-privileged credential; (3) continue — tools needing missing caps will return 403 at call time.
{% endif %}
{% endif %}
</div>
{% elif fit_status == 'probe_unavailable' %}
<div class="font-semibold text-sm">
{% if lang == 'fa' %} امکان بررسی خودکار credential در این سایت وجود ندارد{% else %} Capability probe is unavailable for this site{% endif %}
</div>
<div class="mt-1 text-xs opacity-80 font-mono">
{{ capability_probe.reason or 'probe_unavailable' }}
</div>
{% if site.plugin_type in ['wordpress', 'woocommerce'] and companion_download_url %}
<div class="mt-2 text-xs">
{% if lang == 'fa' %}
برای فعال‌سازی probe، افزونه
<a href="{{ companion_download_url }}" class="underline font-medium">Airano MCP Bridge</a>
را روی وردپرس نصب کنید.
{% else %}
Install the
<a href="{{ companion_download_url }}" class="underline font-medium">Airano MCP Bridge</a>
plugin on your WordPress to enable the probe.
{% endif %}
</div>
{% endif %}
{% elif fit_status == 'unknown_tier' %}
<div class="font-semibold text-sm">
{% if lang == 'fa' %} probe برای این plugin + سطح پیاده‌سازی نشده{% else %} Probe not implemented for this plugin/tier combination{% endif %}
</div>
{% endif %}
{# F.X.fix-pass2 — AI provider status line. Independent of the
tier-fit state: even a perfectly-fit credential cannot run
the AI image tool without an OpenRouter / OpenAI / … key. #}
{% if ai_provider_missing %}
<div class="mt-3 pt-2 border-t border-current/20 text-xs">
<span class="font-semibold">{% if lang == 'fa' %}⚠ هیچ کلید AI Provider تنظیم نشده{% else %}⚠ No AI provider key configured{% endif %}</span>
<span class="opacity-80">
{% if lang == 'fa' %}
— ابزار <code class="font-mono">wordpress_generate_and_upload_image</code> غیرفعال است. کلید را در بخش «تولید تصویر با هوش مصنوعی» بالا ذخیره کنید.
{% else %}
<code class="font-mono">wordpress_generate_and_upload_image</code> is disabled. Add a key in the "AI Image Generation" section above.
{% endif %}
</span>
</div>
{% elif is_wp_like and ai_providers %}
<div class="mt-3 pt-2 border-t border-current/20 text-xs opacity-80">
{% if lang == 'fa' %}کلیدهای AI Provider فعال:{% else %}Configured AI providers:{% endif %}
<code class="font-mono text-xs">{{ ai_providers | join(', ') }}</code>
</div>
{% endif %}
</div>
<button type="button"
hx-get="/api/sites/{{ site.id }}/capabilities/badge?force=1"
hx-target="#capability-badge"
hx-swap="outerHTML"
hx-disabled-elt="this"
class="flex-shrink-0 px-2.5 py-1 rounded border border-current/30 text-xs font-medium hover:bg-white/20 dark:hover:bg-black/20 transition-colors disabled:opacity-50">
{% if lang == 'fa' %}بررسی دوباره{% else %}Re-check{% endif %}
</button>
</div>
</div>

View File

@@ -62,13 +62,20 @@
{% set fields = plugin_fields.get(site.plugin_type, []) %}
{% for field in fields %}
{% if not field.get('advanced', False) %}
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{{ field.label }}{% if field.required %} *{% endif %}
{% set is_stored = cred_states.get(field.name, False) %}
<div data-cred-row="{{ field.name }}">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1 flex items-center gap-2">
<span>{{ field.label }}{% if field.required %} *{% endif %}</span>
{% if is_stored %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300" data-cred-stored>
✓ {% if lang == 'fa' %}ذخیره‌شده{% else %}Stored{% endif %}
</span>
{% endif %}
</label>
<input type="{{ field.type }}" name="cred_{{ field.name }}"
placeholder="{% if field.required %}{{ t.keep_existing }}{% else %}{{ t.leave_blank_to_clear }}{% endif %}"
placeholder="{% if is_stored %}{{ t.keep_existing }}{% elif field.required %}{{ t.keep_existing }}{% else %}{% if lang == 'fa' %}اختیاری — خالی بگذارید{% else %}Optional — leave empty{% endif %}{% endif %}"
data-required="{{ field.required | lower }}"
data-stored="{{ is_stored | lower }}"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
{% if field.hint %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
@@ -81,15 +88,30 @@
{% if has_advanced %}
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
Advanced Settings
{% if lang == 'fa' %}تنظیمات پیشرفته{% else %}Advanced Settings{% endif %}
</summary>
<div class="px-4 pb-4 pt-2 space-y-4">
{% for field in fields %}
{% if field.get('advanced', False) %}
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{{ field.label }}</label>
{% set is_stored = cred_states.get(field.name, False) %}
<div data-cred-row="{{ field.name }}">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1 flex items-center gap-2">
<span>{{ field.label }}</span>
{% if is_stored %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300" data-cred-stored>
✓ {% if lang == 'fa' %}ذخیره‌شده{% else %}Stored{% endif %}
</span>
<button type="button"
data-cred-clear="{{ field.name }}"
class="ml-auto inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border border-red-300 dark:border-red-500/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10">
{% if lang == 'fa' %}🗑 پاک کن{% else %}🗑 Clear{% endif %}
</button>
{% endif %}
</label>
<input type="{{ field.type }}" name="cred_{{ field.name }}"
placeholder="{{ t.leave_blank_to_clear }}" data-required="false"
placeholder="{% if is_stored %}{{ t.keep_existing }}{% else %}{% if lang == 'fa' %}اختیاری — خالی بگذارید{% else %}Optional — leave empty{% endif %}{% endif %}"
data-required="false"
data-stored="{{ is_stored | lower }}"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
{% if field.hint %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
@@ -118,6 +140,113 @@
</div>
</div>
{% if provider_keys_supported %}
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Section 1b: AI Image Generation (per-site provider keys) -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<button type="button" onclick="toggleSection('aikeys')" class="w-full flex items-center justify-between p-6 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-pink-500/20 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}تولید تصویر با هوش مصنوعی{% else %}AI Image Generation{% endif %}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{% if lang == 'fa' %}
کلید API هر ارائه‌دهنده را تنها برای این سایت ذخیره کنید. بدون کلید، ابزار <code class="font-mono">wordpress_generate_and_upload_image</code> غیرفعال است.
{% else %}
Store an API key per provider, scoped only to this site. Without a key, <code class="font-mono">wordpress_generate_and_upload_image</code> is disabled for this site.
{% endif %}
</p>
</div>
</div>
<svg id="aikeys-chevron" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="aikeys-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6 space-y-3">
<div id="aikeys-flash" class="hidden rounded-lg p-3 text-sm"></div>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{% for row in provider_key_rows %}
<div class="py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3" data-site-ai-row="{{ row.provider }}">
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-3 {% if lang == 'fa' %}space-x-reverse{% endif %}">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">{{ row.label }}</h4>
{% if row.status == 'set' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300">
{% if lang == 'fa' %}تنظیم‌شده{% else %}Set{% endif %}
</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}تنظیم نشده{% else %}Unset{% endif %}
</span>
{% endif %}
</div>
</div>
<div class="flex items-center space-x-2 {% if lang == 'fa' %}space-x-reverse{% endif %}">
<form class="flex items-center gap-2"
data-site-ai-set="{{ row.provider }}"
onsubmit="return window.__siteAiKeys.setKey(event, '{{ row.provider }}')">
<input type="password"
name="api_key"
required
minlength="8"
autocomplete="new-password"
placeholder="{% if lang == 'fa' %}کلید API{% else %}New API key{% endif %}"
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-pink-500 focus:border-transparent w-48"/>
<button type="submit"
class="px-3 py-2 rounded-lg bg-pink-600 hover:bg-pink-500 text-white text-sm font-medium transition-colors">
{% if lang == 'fa' %}ذخیره{% else %}Save{% endif %}
</button>
</form>
<button type="button"
data-site-ai-remove="{{ row.provider }}"
onclick="return window.__siteAiKeys.deleteKey('{{ row.provider }}')"
class="px-3 py-2 rounded-lg border border-red-300 dark:border-red-500/50 text-red-600 dark:text-red-400 text-sm font-medium hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors {% if row.status != 'set' %}hidden{% endif %}">
{% if lang == 'fa' %}حذف{% else %}Remove{% endif %}
</button>
</div>
</div>
{% if row.provider == 'openrouter' %}
<details class="ml-0 sm:ml-0 mt-1 text-xs"
data-site-ai-models="openrouter"
data-current-default="{{ row.default_model or '' }}">
<summary class="cursor-pointer inline-block px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 select-none">
{% if lang == 'fa' %}🖼 مدل‌های تصویری در دسترس{% if row.default_model %} — پیش‌فرض: <code class="font-mono">{{ row.default_model }}</code>{% endif %}{% else %}🖼 Available image models{% if row.default_model %} — default: <code class="font-mono">{{ row.default_model }}</code>{% endif %}{% endif %}
</summary>
<div class="mt-2 rounded-lg border border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-200">
<div class="text-gray-500 dark:text-gray-400" data-models-placeholder>
{% if lang == 'fa' %}در حال بارگذاری…{% else %}Loading…{% endif %}
</div>
<ul class="space-y-2 hidden" data-models-list></ul>
<p class="mt-2 text-[11px] text-gray-500 dark:text-gray-400" data-models-hint>
{% if lang == 'fa' %}
با کلیک روی «پیش‌فرض» مدل به‌عنوان مدل پیش‌فرض این سایت ذخیره می‌شود — اگر <code class="font-mono">model</code> در فراخوانی <code class="font-mono">wordpress_generate_and_upload_image</code> داده نشود، از این مقدار استفاده می‌شود.
{% else %}
Click "Set default" to make a model the site's default — used when <code class="font-mono">wordpress_generate_and_upload_image</code> is called without an explicit <code class="font-mono">model</code>.
{% endif %}
</p>
</div>
</details>
{% endif %}
{% endfor %}
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 pt-2">
{% if lang == 'fa' %}
کلیدها با AES-256-GCM رمزگذاری می‌شوند و فقط برای همین سایت قابل استفاده‌اند.
{% else %}
Keys are encrypted at rest (AES-256-GCM) and scoped to this site only.
{% endif %}
</p>
</div>
</div>
{% endif %}
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Section 2: Tool Access -->
<!-- ═══════════════════════════════════════════════════════════════ -->
@@ -141,6 +270,31 @@
</svg>
</button>
<div id="tools-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
{% if companion_download_url %}
<!-- F.20 prep: companion-plugin hint (compact, WP/WC only) -->
<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-lg p-3 text-xs text-indigo-800 dark:text-indigo-300">
{% if lang == 'fa' %}
برای قابلیت‌های کامل (آپلود بزرگ، cache purge، site-health، و…) افزونه‌ی
<a href="{{ companion_download_url }}" class="underline font-medium hover:no-underline">Airano MCP Bridge</a>
را در وردپرس نصب کنید (اختیاری).
{% else %}
For full capability (large uploads, cache purge, site-health, bulk meta, audit hooks, …), install the
<a href="{{ companion_download_url }}" class="underline font-medium hover:no-underline">Airano MCP Bridge</a>
plugin on your WordPress (optional).
{% endif %}
</div>
{% endif %}
{# F.7e: capability probe badge — tells the user whether the
saved credential can actually back the selected tier,
before they hit a 403 from a real tool call. #}
{# F.X.fix #9: badge is an HTMX-swappable partial. Re-check
button hits /api/sites/{id}/capabilities/badge and swaps
this element in place — no full-page reload. #}
{% if capability_probe %}
{% include "dashboard/sites/_capability_badge.html" %}
{% endif %}
<div id="tool-access-loading" class="text-gray-400 dark:text-gray-500 text-sm">
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
</div>
@@ -303,6 +457,30 @@
}
// ── Connection form ─────────────────────────────────────────────
// F.X.fix-pass5 — Clear-button wiring. Marks the matching input
// with data-clear="1" + visually flashes the row so the user
// knows the next Save will wipe the stored value.
document.querySelectorAll('[data-cred-clear]').forEach(btn => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
const fieldName = btn.dataset.credClear;
const input = document.querySelector(`[name="cred_${fieldName}"]`);
if (!input) return;
input.value = '';
input.dataset.clear = '1';
input.classList.add('border-red-400', 'dark:border-red-500');
const row = btn.closest('[data-cred-row]');
const stored = row?.querySelector('[data-cred-stored]');
if (stored) {
stored.textContent = '{% if lang == "fa" %}پس از Save پاک می‌شود{% else %}Will clear on Save{% endif %}';
stored.classList.remove('bg-green-100', 'dark:bg-green-900/40', 'text-green-700', 'dark:text-green-300');
stored.classList.add('bg-red-100', 'dark:bg-red-900/40', 'text-red-700', 'dark:text-red-300');
}
btn.disabled = true;
btn.classList.add('opacity-50');
});
});
document.getElementById('edit-site-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submit-btn');
@@ -313,13 +491,22 @@
const url = document.getElementById('url').value;
const creds = {};
// F.X.fix-pass5 — Empty input means "keep existing"; clearing
// a stored optional value requires an explicit Clear button
// click (which sets data-clear="1" on the input). Old
// behaviour silently wiped optional fields on every save.
if (pluginFields[pluginType]) {
pluginFields[pluginType].forEach(field => {
const input = document.querySelector(`[name="cred_${field.name}"]`);
if (!input) return;
const val = input.value.trim();
if (val) creds[field.name] = val;
else if (input.dataset.required === 'false') creds[field.name] = '';
if (val) {
creds[field.name] = val;
} else if (input.dataset.clear === '1') {
// Explicit clear: send empty string to wipe.
creds[field.name] = '';
}
// else: leave out → server keeps existing value.
});
}
@@ -391,6 +578,11 @@
} catch (_) {}
}
// F.X.fix-pass2 — exposed so the AI-keys set/delete handlers can
// trigger a live refresh of the gating UI after configuring a
// provider key (no full-page reload).
window.loadToolAccess = loadToolAccess;
function renderToolAccess() {
document.getElementById('tool-access-loading').classList.add('hidden');
document.getElementById('tool-access-content').classList.remove('hidden');
@@ -642,16 +834,35 @@
function renderToolRow(tool) {
const inScope = isToolInScope(tool);
// F.X.fix-pass3 — central prerequisite gating. Backend has
// already evaluated `available` + `unavailable_reason` so the
// UI just renders accordingly. Reasons we know how to handle:
// provider_key — needs an AI provider key on this site
// companion_route — needs the Airano MCP Bridge companion plugin
// feature — needs an SEO plugin (Rank Math / Yoast)
const reason = tool.unavailable_reason || null;
const missingProviderKey = reason === 'provider_key';
const missingCompanion = reason === 'companion_route';
const missingSeoPlugin = reason === 'feature';
const missingWpCreds = reason === 'wp_credentials';
const unavailable = !tool.available;
const dimmed = !inScope || unavailable;
const row = document.createElement('div');
row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30' + (inScope ? '' : ' opacity-40');
row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30' + (dimmed ? ' opacity-40' : '');
const left = document.createElement('div');
left.className = 'flex items-center gap-2 flex-1 min-w-0';
const nameEl = document.createElement('span');
nameEl.className = 'text-sm truncate font-mono ' + (inScope ? 'text-gray-800 dark:text-gray-200' : 'text-gray-400 dark:text-gray-500 line-through');
nameEl.className = 'text-sm truncate font-mono ' + (dimmed ? 'text-gray-400 dark:text-gray-500 line-through' : 'text-gray-800 dark:text-gray-200');
nameEl.textContent = tool.name;
nameEl.title = (tool.description || '') + (inScope ? '' : ' — {% if lang == "fa" %}خارج از سطح دسترسی{% else %}outside current scope{% endif %}');
const titleParts = [tool.description || ''];
if (!inScope) titleParts.push('— {% if lang == "fa" %}خارج از سطح دسترسی{% else %}outside current scope{% endif %}');
if (missingProviderKey) titleParts.push('— {% if lang == "fa" %}نیاز به تنظیم کلید AI Provider{% else %}needs an AI provider key{% endif %}');
if (missingCompanion) titleParts.push('— {% if lang == "fa" %}نیاز به افزونه Airano MCP Bridge{% else %}needs the Airano MCP Bridge companion plugin{% endif %}');
if (missingSeoPlugin) titleParts.push('— {% if lang == "fa" %}نیاز به افزونه‌ی SEO (Rank Math یا Yoast){% else %}needs an SEO plugin (Rank Math or Yoast){% endif %}');
if (missingWpCreds) titleParts.push('— {% if lang == "fa" %}نیاز به Application Password وردپرس{% else %}needs a WordPress Application Password{% endif %}');
nameEl.title = titleParts.join(' ');
left.appendChild(nameEl);
if (!inScope) {
@@ -668,29 +879,74 @@
left.appendChild(badge);
}
if (missingProviderKey) {
const configurePill = document.createElement('button');
configurePill.type = 'button';
configurePill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-pink-100 dark:bg-pink-500/20 text-pink-700 dark:text-pink-300 hover:bg-pink-200 dark:hover:bg-pink-500/30 transition-colors';
configurePill.textContent = '{% if lang == "fa" %}⚙ تنظیم کلید{% else %}⚙ Configure key{% endif %}';
configurePill.addEventListener('click', (ev) => {
ev.stopPropagation();
const section = document.getElementById('aikeys-section');
if (section && section.classList.contains('hidden')) {
toggleSection('aikeys');
}
const target = document.getElementById('aikeys-section');
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
left.appendChild(configurePill);
} else if (missingCompanion) {
const pill = document.createElement('span');
pill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-indigo-100 dark:bg-indigo-500/20 text-indigo-700 dark:text-indigo-300';
pill.textContent = '{% if lang == "fa" %}🧩 افزونه‌ی همراه نیاز است{% else %}🧩 needs companion plugin{% endif %}';
left.appendChild(pill);
} else if (missingSeoPlugin) {
const pill = document.createElement('span');
pill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300';
pill.textContent = '{% if lang == "fa" %}🔍 SEO plugin نیاز است{% else %}🔍 needs SEO plugin{% endif %}';
left.appendChild(pill);
} else if (missingWpCreds) {
const pill = document.createElement('button');
pill.type = 'button';
pill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-300 hover:bg-orange-200 dark:hover:bg-orange-500/30 transition-colors';
pill.textContent = '{% if lang == "fa" %}🔑 WP App Password نیاز{% else %}🔑 needs WP App Password{% endif %}';
pill.addEventListener('click', (ev) => {
ev.stopPropagation();
const link = document.createElement('a');
link.href = '/dashboard/sites/' + encodeURIComponent(siteId) + '/edit';
link.click();
});
left.appendChild(pill);
}
const label = document.createElement('label');
label.className = 'relative inline-flex items-center cursor-pointer flex-shrink-0';
label.className = 'relative inline-flex items-center flex-shrink-0 ' + (unavailable ? 'cursor-not-allowed' : 'cursor-pointer');
const input = document.createElement('input');
input.type = 'checkbox';
input.className = 'sr-only peer';
input.checked = tool.enabled !== false;
input.onchange = async () => {
const enabled = input.checked;
try {
const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
if (!r.ok) input.checked = !enabled;
else {
tool.enabled = enabled;
const en = allTools.filter(t => t.enabled !== false).length;
document.getElementById('tool-summary').textContent =
'{% if lang == "fa" %}' + en + ' از ' + allTools.length + ' ابزار فعال{% else %}' + en + ' of ' + allTools.length + ' tools enabled{% endif %}';
}
} catch (_) { input.checked = !enabled; }
};
// When prerequisites unmet, force toggle visually OFF regardless
// of the user's saved preference — backend filter already drops
// the tool from the live endpoint.
input.checked = tool.available !== false && tool.enabled !== false;
input.disabled = unavailable;
if (!unavailable) {
input.onchange = async () => {
const enabled = input.checked;
try {
const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
if (!r.ok) input.checked = !enabled;
else {
tool.enabled = enabled;
const en = allTools.filter(t => t.enabled !== false).length;
document.getElementById('tool-summary').textContent =
'{% if lang == "fa" %}' + en + ' از ' + allTools.length + ' ابزار فعال{% else %}' + en + ' of ' + allTools.length + ' tools enabled{% endif %}';
}
} catch (_) { input.checked = !enabled; }
};
}
const slider = document.createElement('div');
slider.className = 'w-9 h-5 bg-gray-300 dark:bg-gray-600 peer-checked:bg-blue-600 rounded-full peer peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 transition-colors after:content-[""] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4';
label.appendChild(input);
@@ -759,6 +1015,252 @@
btn.disabled = false;
}
// ── Per-site AI provider keys (F.5a.9.x) ────────────────────────
{% if provider_keys_supported %}
(function () {
const flash = document.getElementById('aikeys-flash');
function show(kind, msg) {
if (!flash) return;
flash.textContent = msg;
flash.className = 'rounded-lg p-3 text-sm ' + (
kind === 'ok'
? 'bg-green-50 dark:bg-green-500/10 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-500/30'
: 'bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-500/30'
);
flash.classList.remove('hidden');
}
// F.X.fix-pass2 — in-place DOM update after set/delete (no
// window.location.reload()), so the AI Image Generation panel
// stays expanded and the scroll position is preserved. Also
// triggers a Tool Access reload + a capability-badge re-check
// so the gating UI reflects the new provider state live.
function _updateRowStatus(provider, isSet) {
const row = document.querySelector('[data-site-ai-row="' + provider + '"]');
if (!row) return;
const badge = row.querySelector('span.inline-flex');
if (badge) {
if (isSet) {
badge.className = 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300';
badge.textContent = '{% if lang == "fa" %}تنظیم‌شده{% else %}Set{% endif %}';
} else {
badge.className = 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300';
badge.textContent = '{% if lang == "fa" %}تنظیم نشده{% else %}Unset{% endif %}';
}
}
const removeBtn = document.querySelector('[data-site-ai-remove="' + provider + '"]');
if (removeBtn) {
removeBtn.classList.toggle('hidden', !isSet);
}
const form = document.querySelector('[data-site-ai-set="' + provider + '"]');
if (form) {
const input = form.querySelector('input[name="api_key"]');
if (input) input.value = '';
}
}
function _refreshLinkedUi() {
// Re-fetch tools so the AI tool's gating updates inline.
if (typeof window.loadToolAccess === 'function') {
window.loadToolAccess();
}
// Trigger the HTMX re-check button so the capability badge
// re-renders with the new ai_providers_configured state.
const recheck = document.querySelector('#capability-badge button[hx-get]');
if (recheck && window.htmx) {
window.htmx.trigger(recheck, 'click');
}
}
async function setKey(ev, provider) {
ev.preventDefault();
const form = ev.target;
const fd = new FormData(form);
const apiKey = (fd.get('api_key') || '').toString();
if (!apiKey || apiKey.length < 8) {
show('err', '{% if lang == "fa" %}کلید باید حداقل ۸ کاراکتر باشد{% else %}Key must be at least 8 characters{% endif %}');
return false;
}
try {
const resp = await fetch('/api/sites/' + encodeURIComponent(siteId) + '/provider-keys/' + encodeURIComponent(provider), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: apiKey }),
credentials: 'same-origin',
});
const data = await resp.json();
if (!resp.ok || !data.ok) {
show('err', data.message || data.error || 'save failed');
return false;
}
_updateRowStatus(provider, true);
_refreshLinkedUi();
show('ok', '{% if lang == "fa" %}ذخیره شد{% else %}Saved{% endif %} — last4: ' + (data.secret_last4 || ''));
} catch (e) {
show('err', e.message || String(e));
}
return false;
}
async function deleteKey(provider) {
if (!window.confirm('{% if lang == "fa" %}آیا مطمئن هستید؟{% else %}Remove this provider key?{% endif %}')) {
return false;
}
try {
const resp = await fetch('/api/sites/' + encodeURIComponent(siteId) + '/provider-keys/' + encodeURIComponent(provider), {
method: 'DELETE',
credentials: 'same-origin',
});
const data = await resp.json();
if (!resp.ok || !data.ok) {
show('err', data.message || data.error || 'delete failed');
return false;
}
_updateRowStatus(provider, false);
_refreshLinkedUi();
show('ok', '{% if lang == "fa" %}حذف شد{% else %}Removed{% endif %}');
} catch (e) {
show('err', e.message || String(e));
}
return false;
}
// F.X.fix-pass2 — OpenRouter model discovery UI. Lazy-loaded on
// first <details> expand so the panel open doesn't pay the
// network cost unless the user asks. Falls back gracefully
// when no key is set (catalog is public but availability
// flags reflect the operator's account when we pass a key).
async function _loadOpenRouterModels(details) {
if (details.dataset.loaded === '1') return;
details.dataset.loaded = '1';
const placeholder = details.querySelector('[data-models-placeholder]');
const list = details.querySelector('[data-models-list]');
try {
const resp = await fetch(
'/api/providers/openrouter/models?site_id=' + encodeURIComponent(siteId),
{ credentials: 'same-origin' }
);
const data = await resp.json();
if (!resp.ok || !data.ok) {
placeholder.textContent = data.message || data.error || '{% if lang == "fa" %}خطا در دریافت لیست{% else %}Failed to load models{% endif %}';
return;
}
const models = Array.isArray(data.models) ? data.models : [];
if (!models.length) {
placeholder.textContent = '{% if lang == "fa" %}هیچ مدلی یافت نشد{% else %}No image models found{% endif %}';
return;
}
placeholder.classList.add('hidden');
list.classList.remove('hidden');
list.innerHTML = '';
const provider = details.dataset.siteAiModels || 'openrouter';
let currentDefault = details.dataset.currentDefault || '';
const renderRow = (m) => {
const li = document.createElement('li');
li.className = 'flex items-start justify-between gap-2 pb-1 border-b border-gray-200 dark:border-gray-700 last:border-0';
const info = document.createElement('div');
info.className = 'flex-1 min-w-0';
const id = document.createElement('code');
id.className = 'font-mono text-gray-800 dark:text-gray-200 break-all';
id.textContent = m.id;
const nameLine = document.createElement('div');
nameLine.className = 'text-[11px] text-gray-500 dark:text-gray-400 mt-0.5';
const nameBits = [];
if (m.name && m.name !== m.id) nameBits.push(m.name);
if (m.price_per_image_usd != null) {
nameBits.push('$' + Number(m.price_per_image_usd).toFixed(4) + '/img');
}
nameLine.textContent = nameBits.join(' · ');
info.appendChild(id);
if (nameBits.length) info.appendChild(nameLine);
const actions = document.createElement('div');
actions.className = 'flex-shrink-0 flex items-center gap-1';
const isDefault = currentDefault === m.id;
const defaultBtn = document.createElement('button');
defaultBtn.type = 'button';
defaultBtn.className = 'px-2 py-0.5 rounded border text-[11px] transition-colors ' + (
isDefault
? 'border-emerald-300 dark:border-emerald-600 bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300'
: 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
);
defaultBtn.textContent = isDefault
? '{% if lang == "fa" %}★ پیش‌فرض{% else %}★ Default{% endif %}'
: '{% if lang == "fa" %}پیش‌فرض{% else %}Set default{% endif %}';
defaultBtn.addEventListener('click', async () => {
const target = isDefault ? null : m.id;
defaultBtn.disabled = true;
const orig = defaultBtn.textContent;
defaultBtn.textContent = '…';
try {
const resp = await fetch(
'/api/sites/' + encodeURIComponent(siteId) +
'/provider-keys/' + encodeURIComponent(provider) +
'/default-model',
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: target }),
credentials: 'same-origin',
}
);
const data = await resp.json();
if (!resp.ok || !data.ok) {
defaultBtn.textContent = orig;
show('err', data.message || data.error || '{% if lang == "fa" %}خطا{% else %}Error{% endif %}');
return;
}
currentDefault = target || '';
details.dataset.currentDefault = currentDefault;
// Re-render the entire list so all rows reflect the new default.
list.innerHTML = '';
for (const x of models) list.appendChild(renderRow(x));
show('ok', '{% if lang == "fa" %}پیش‌فرض ذخیره شد{% else %}Default saved{% endif %}');
} catch (e) {
defaultBtn.textContent = orig;
show('err', e.message || String(e));
} finally {
defaultBtn.disabled = false;
}
});
const copy = document.createElement('button');
copy.type = 'button';
copy.className = 'px-2 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-[11px] text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700';
copy.textContent = '{% if lang == "fa" %}کپی{% else %}Copy{% endif %}';
copy.addEventListener('click', () => {
navigator.clipboard.writeText(m.id);
copy.textContent = '{% if lang == "fa" %}کپی شد{% else %}Copied{% endif %}';
setTimeout(() => { copy.textContent = '{% if lang == "fa" %}کپی{% else %}Copy{% endif %}'; }, 1500);
});
actions.appendChild(defaultBtn);
actions.appendChild(copy);
li.appendChild(info);
li.appendChild(actions);
return li;
};
for (const m of models) list.appendChild(renderRow(m));
} catch (e) {
placeholder.textContent = e.message || String(e);
}
}
document.querySelectorAll('details[data-site-ai-models]').forEach((d) => {
d.addEventListener('toggle', () => {
if (d.open) _loadOpenRouterModels(d);
});
});
window.__siteAiKeys = { setKey, deleteKey };
})();
{% endif %}
// F.X.fix #9: Re-check is now an HTMX partial swap — see the
// hx-get attribute on the button in _capability_badge.html. No JS
// shim needed here any more.
// ── Init ────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadToolAccess();