Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/alcafer2011_antigravity.json
T
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
203 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "alcafer2011/antigravity", "files": {"src/providerCatalog.js": {"b004d67566fd93489bf4cb3fbc1b4d93bfed8e95": {"t": 1785768876.9269176, "n": 32790, "h": "d1a42bf1831b3598", "c": "\"use strict\";\n\n/**\n * providerCatalog — CATALOGO PRECONFIGURATO di TUTTI i provider LLM noti\n * (OpenAI-compatibili) + delle piattaforme GPU dove ospitare i modelli tuoi.\n *\n * ★ 2026-07-30 — PERCHÉ ESISTE QUESTO FILE\n * Il cacciatore di taglie proponeva provider (letti dalle liste online) che NON\n * erano nel registro: creavi la chiave, la incollavi e il rilevamento diceva\n * \"provider non riconosciuto\", oppure il selettore manuale conteneva solo i 25\n * provider scritti a mano in providerRegistry.js. Qui c'è la lista LARGA, già\n * pronta con endpoint, nome della variabile .env, formato della chiave e alias\n * (i nomi con cui le liste online li chiamano), così:\n * 1) incolli la chiave → il provider si riconosce da solo (detect);\n * 2) se non si riconosce → il selettore manuale contiene TUTTI questi provider;\n * 3) il cacciatore, quando trova \"Nebius\"/\"Cohere\"/\"Scaleway\"/..., riesce ad\n * agganciarli a una voce vera (knownId) e mostra il bottone \"Incolla qui\".\n *\n * providerRegistry.js FONDE questo catalogo con le sue voci curate: le voci\n * curate VINCONO (hanno note/quote tarate a mano), da qui arriva tutto il resto.\n *\n * Campi (stessi di providerRegistry, più questi):\n * aliases[] nomi alternativi (come li scrivono le liste online / il cacciatore)\n * verified true = endpoint controllato · false = plausibile ma da confermare\n * col bottone \"Verifica\" del pannello chiavi. Le voci non verificate\n * restano selezionabili: al massimo il probe risponde 404 e lo vedi.\n * strong true = il prefisso della chiave è UNICO → riconoscimento diretto\n * freeModels[] modelli gratuiti noti (per la scheda del cacciatore)\n *\n * REGOLA: qui NON vanno chiavi/segreti. Solo endpoint pubblici e link ufficiali.\n */\n\nconst NV = \"⚠️ endpoint da confermare col bottone «Verifica» dopo aver salvato la chiave.\";\n\nconst CATALOG = [\n // ================= GRANDI CLOUD / OPENAI-COMPAT =========================\n {\n id: \"nebius\", label: \"Nebius AI Studio\", env: \"NEBIUS_API_KEY\",\n host: \"api.studio.nebius.com\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^eyJ[A-Za-z0-9_-]+\\./, strong: false,\n keyFmt: \"JWT (inizia con eyJ...)\", signup: \"https://studio.nebius.com/settings/api-keys\",\n free: true, billed: true, verified: true,\n aliases: [\"nebius\", \"nebius ai studio\", \"nebius token factory\"],\n freeModels: [\"meta-llama/Llama-3.3-70B-Instruct\", \"Qwen/Qwen2.5-Coder-32B-Instruct\", \"deepseek-ai/DeepSeek-V3\"],\n note: \"Catalogo open grosso (llama, qwen-coder, deepseek) con credito iniziale gratuito. Chiave JWT come Hyperbolic: se il rilevamento propone entrambi, scegli tu.\"\n },\n {\n id: \"moonshot\", label: \"Moonshot AI (Kimi)\", env: \"MOONSHOT_API_KEY\",\n host: \"api.moonshot.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^sk-/, keyFmt: \"Inizia con sk- (es. sk-xxxx)\",\n signup: \"https://platform.moonshot.ai/console/api-keys\",\n free: false, billed: true, verified: true,\n aliases: [\"moonshot\", \"moonshot ai\", \"kimi\", \"kimi k2\", \"moonshotai\"],\n freeModels: [],\n note: \"Kimi K2 (contesto enorme, forte nel coding agentico). A consumo, prezzi bassi.\"\n },\n {\n id: \"zai\", label: \"Z.ai (GLM / Zhipu)\", env: \"ZAI_API_KEY\",\n host: \"api.z.ai\", chatPath: \"/api/paas/v4/chat/completions\", modelsPath: null,\n detect: /^[0-9a-f]{32}\\.[A-Za-z0-9]{10,}$/, keyFmt: \"Forma id.segreto (32 esadecimali + punto + segreto)\",\n signup: \"https://z.ai/manage-apikey/apikey-list\",\n free: true, billed: true, verified: true,\n aliases: [\"z ai\", \"zai\", \"zhipu\", \"zhipu ai\", \"glm\", \"bigmodel\"],\n freeModels: [\"glm-4.5-flash\", \"glm-4.6-flash\"],\n note: \"GLM (glm-4.x/5): i modelli *-flash hanno un piano gratuito. Chiave in forma id.segreto come Ollama Cloud: se il rilevamento è incerto, scegli tu.\"\n },\n {\n id: \"bigmodel\", label: \"Zhipu BigModel (Cina)\", env: \"BIGMODEL_API_KEY\",\n host: \"open.bigmodel.cn\", chatPath: \"/api/paas/v4/chat/completions\", modelsPath: null,\n detect: /^[0-9a-f]{32}\\.[A-Za-z0-9]{10,}$/, keyFmt: \"Forma id.segreto (come Z.ai)\",\n signup: \"https://open.bigmodel.cn/usercenter/apikeys\",\n free: true, billed: true, verified: true,\n aliases: [\"bigmodel\", \"open bigmodel\", \"zhipu bigmodel\", \"chatglm\"],\n freeModels: [\"glm-4-flash\", \"glm-4.5-flash\"],\n note: \"Portale cinese di Zhipu: glm-4-flash gratis. Stesso endpoint di Z.ai ma dominio .cn (più lento dall'Italia).\"\n },\n {\n id: \"minimax\", label: \"MiniMax\", env: \"MINIMAX_API_KEY\",\n host: \"api.minimax.io\", chatPath: \"/v1/text/chatcompletion_v2\", modelsPath: null,\n detect: /^eyJ[A-Za-z0-9_-]+\\./, keyFmt: \"JWT (inizia con eyJ...)\",\n signup: \"https://www.minimax.io/platform/user-center/basic-information/interface-key\",\n free: false, billed: true, verified: false,\n aliases: [\"minimax\", \"minimax m2\", \"minimaxi\"],\n freeModels: [],\n note: \"MiniMax M2 (coding/agent, contesto grande). Endpoint OpenAI-compat su /v1/text/chatcompletion_v2. \" + NV\n },\n {\n id: \"siliconflow\", label: \"SiliconFlow\", env: \"SILICONFLOW_API_KEY\",\n host: \"api.siliconflow.com\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^sk-/, keyFmt: \"Inizia con sk- (es. sk-xxxx)\",\n signup: \"https://cloud.siliconflow.com/account/ak\",\n free: true, billed: true, verified: true,\n aliases: [\"siliconflow\", \"silicon flow\", \"siliconcloud\"],\n freeModels: [\"Qwen/Qwen2.5-7B-Instruct\", \"THUDM/glm-4-9b-chat\", \"deepseek-ai/DeepSeek-R1-Distill-Qwen-7B\"],\n note: \"Gateway cinese con molti modelli open; alcuni piccoli sono gratis a vita, gli altri a consumo economico.\"\n },\n {\n id: \"hunyuan\", label: \"Tencent Hunyuan\", env: \"HUNYUAN_API_KEY\",\n host: \"api.hunyuan.cloud.tencent.com\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^sk-/, keyFmt: \"Inizia con sk- (es. sk-xxxx)\",\n signup: \"https://console.cloud.tencent.com/hunyuan/api-key\",\n free: true, billed: true, verified: false,\n aliases: [\"hunyuan\", \"tencent\", \"tencent hunyuan\", \"tencent cloud\"],\n freeModels: [\"hunyuan-lite\"],\n note: \"hunyuan-lite è gratuito; i modelli grossi a consumo. \" + NV\n },\n {\n id: \"stepfun\", label: \"StepFun\", env: \"STEPFUN_API_KEY\",\n host: \"api.stepfun.com\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: null, keyFmt: \"Chiave alfanumerica lunga (senza prefisso fisso)\",\n signup: \"https://platform.stepfun.com/interface-key\",\n free: false, billed: true, verified: false,\n aliases: [\"stepfun\", \"step fun\", \"step-3\", \"step 3.5\"],\n freeModels: [],\n note: \"Modelli step-* (gli stessi :free che passano da Kilo). \" + NV\n },\n {\n id: \"qianfan\", label: \"Baidu Qianfan (ERNIE)\", env: \"QIANFAN_API_KEY\",\n host: \"qianfan.baidubce.com\", chatPath: \"/v2/chat/completions\", modelsPath: null,\n detect: /^bce-v3\\//, strong: true, keyFmt: \"Inizia con bce-v3/ (es. bce-v3/ALTAK-...)\",\n signup: \"https://console.bce.baidu.com/iam/#/iam/apikey/list\",\n free: true, billed: true, verified: false,\n aliases: [\"qianfan\", \"baidu\", \"baidu qianfan\", \"ernie\", \"wenxin\"],\n freeModels: [\"ernie-speed-128k\", \"ernie-lite-8k\"],\n note: \"ERNIE: le versioni speed/lite sono gratuite. \" + NV\n },\n {\n id: \"modelscope\", label: \"ModelScope (Alibaba, free)\", env: \"MODELSCOPE_API_KEY\",\n host: \"api-inference.modelscope.cn\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^ms-[0-9a-fA-F-]{20,}$/, strong: true, keyFmt: \"Inizia con ms- (es. ms-xxxxxxxx-....)\",\n signup: \"https://modelscope.cn/my/myaccesstoken\",\n free: true, billed: false, verified: true,\n aliases: [\"modelscope\", \"model scope\", \"modelscope inference\"],\n freeModels: [\"Qwen/Qwen3-Coder-480B-A35B-Instruct\", \"deepseek-ai/DeepSeek-V3\", \"Qwen/QwQ-32B\"],\n note: \"GRATIS ~2000 chiamate/giorno su modelli GROSSI (qwen3-coder-480b, deepseek). Serve solo un account ModelScope (Alibaba), niente carta.\"\n },\n {\n id: \"ppinfra\", label: \"PPIO / PPInfra (Novita Cina)\", env: \"PPINFRA_API_KEY\",\n host: \"api.ppinfra.com\", chatPath: \"/v3/openai/chat/completions\", modelsPath: \"/v3/openai/models\",\n detect: /^sk_/, keyFmt: \"Inizia con sk_ (come Novita)\",\n signup: \"https://ppinfra.com/settings/key-management\",\n free: true, billed: true, verified: false,\n aliases: [\"ppinfra\", \"ppio\", \"pp infra\"],\n freeModels: [],\n note: \"Gemello cinese di Novita (stessa API /v3/openai). Credito di benvenuto. \" + NV\n },\n\n // ================= GATEWAY / AGGREGATORI ================================\n {\n id: \"vercel\", label: \"Vercel AI Gateway\", env: \"VERCEL_AI_API_KEY\",\n host: \"ai-gateway.vercel.sh\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^vck_/, strong: true, keyFmt: \"Inizia con vck_ (es. vck_xxxx)\",\n signup: \"https://vercel.com/docs/ai-gateway\",\n free: true, billed: true, verified: true,\n aliases: [\"vercel\", \"vercel ai gateway\", \"ai gateway\"],\n freeModels: [\"(router a molti provider — credito mensile incluso nel piano Hobby)\"],\n note: \"Aggregatore: una chiave sola, tutti i provider dietro. Credito gratuito mensile col piano Hobby.\"\n },\n {\n id: \"requesty\", label: \"Requesty (router)\", env: \"REQUESTY_API_KEY\",\n host: \"router.requesty.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: null, keyFmt: \"Chiave alfanumerica lunga (senza prefisso fisso)\",\n signup: \"https://app.requesty.ai/api-keys\",\n free: true, billed: true, verified: false,\n aliases: [\"requesty\", \"requesty router\", \"requesty ai\"],\n freeModels: [],\n note: \"Router tipo OpenRouter con credito di prova. \" + NV\n },\n {\n id: \"aimlapi\", label: \"AI/ML API\", env: \"AIMLAPI_API_KEY\",\n host: \"api.aimlapi.com\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^[0-9a-fA-F]{32,80}$/, keyFmt: \"chiave esadecimale (32–80 caratteri)\",\n signup: \"https://aimlapi.com/app/keys\",\n free: true, billed: true, verified: false,\n aliases: [\"aimlapi\", \"ai ml api\", \"ai/ml api\"],\n freeModels: [\"(200+ modelli, piccolo credito free all'iscrizione)\"],\n note: \"Aggregatore con 200+ modelli e un piccolo credito gratuito. \" + NV\n },\n {\n id: \"aihubmix\", label: \"AiHubMix\", env: \"AIHUBMIX_API_KEY\",\n host: \"aihubmix.com\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^sk-/, keyFmt: \"Inizia con sk-\",\n signup: \"https://aihubmix.com/token\",\n free: false, billed: true, verified: false,\n aliases: [\"aihubmix\", \"ai hub mix\"],\n freeModels: [],\n note: \"Aggregatore (anche modelli cinesi) OpenAI-compat. \" + NV\n },\n {\n id: \"api302\", label: \"302.AI\", env: \"API302_API_KEY\",\n host: \"api.302.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^sk-/, keyFmt: \"Inizia con sk-\",\n signup: \"https://dash.302.ai/apis/list\",\n free: false, billed: true, verified: false,\n aliases: [\"302 ai\", \"302ai\", \"302.ai\"],\n freeModels: [],\n note: \"Aggregatore a consumo, credito di prova all'iscrizione. \" + NV\n },\n\n // ================= PROVIDER CON PIANO FREE / TRIAL ======================\n {\n id: \"cohere\", label: \"Cohere (trial gratis)\", env: \"COHERE_API_KEY\",\n host: \"api.cohere.ai\", chatPath: \"/compatibility/v1/chat/completions\", modelsPath: \"/compatibility/v1/models\",\n detect: /^[A-Za-z0-9]{40}$/, keyFmt: \"40 caratteri alfanumerici\",\n signup: \"https://dashboard.cohere.com/api-keys\",\n free: true, billed: false, verified: true,\n aliases: [\"cohere\", \"command a\", \"command r\", \"command r+\"],\n freeModels: [\"command-a-03-2025\", \"command-r-plus-08-2024\", \"command-r7b-12-2024\"],\n note: \"Chiave 'Trial' gratuita: 1000 chiamate/mese, nessuna carta. ★ Ora punta all'endpoint OpenAI-compat /compatibility/v1, quindi può entrare nel failover (prima era escluso).\"\n },\n {\n id: \"codestral\", label: \"Mistral Codestral (chiave free dedicata)\", env: \"CODESTRAL_API_KEY\",\n host: \"codestral.mistral.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^[A-Za-z0-9]{32}$/, keyFmt: \"32 caratteri alfanumerici (come Mistral)\",\n signup: \"https://console.mistral.ai/codestral\",\n free: true, billed: false, verified: true,\n aliases: [\"codestral\", \"mistral codestral\", \"codestral api\"],\n freeModels: [\"codestral-latest\", \"codestral-2501\"],\n note: \"Chiave Codestral SEPARATA e gratuita (endpoint dedicato codestral.mistral.ai): ottima per il completamento di codice.\"\n },\n {\n id: \"upstage\", label: \"Upstage Solar\", env: \"UPSTAGE_API_KEY\",\n host: \"api.upstage.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^up_/, strong: true, keyFmt: \"Inizia con up_ (es. up_xxxx)\",\n signup: \"https://console.upstage.ai/api-keys\",\n free: true, billed: true, verified: true,\n aliases: [\"upstage\", \"solar\", \"solar pro\", \"upstage solar\"],\n freeModels: [\"solar-pro2 (credito di benvenuto)\"],\n note: \"Solar Pro (coreano, veloce e capace). Credito gratuito all'iscrizione.\"\n },\n {\n id: \"friendli\", label: \"FriendliAI\", env: \"FRIENDLI_TOKEN\",\n host: \"api.friendli.ai\", chatPath: \"/serverless/v1/chat/completions\", modelsPath: \"/serverless/v1/models\",\n detect: /^flp_/, strong: true, keyFmt: \"Inizia con flp_ (es. flp_xxxx)\",\n signup: \"https://suite.friendli.ai/user-settings/tokens\",\n free: true, billed: true, verified: true,\n aliases: [\"friendli\", \"friendliai\", \"friendli ai\", \"periflow\"],\n freeModels: [\"meta-llama-3.3-70b-instruct\", \"deepseek-r1\", \"qwen2.5-coder-32b\"],\n note: \"Serverless veloce sui modelli open, con credito gratuito iniziale.\"\n },\n {\n id: \"inference-net\", label: \"Inference.net\", env: \"INFERENCE_API_KEY\",\n host: \"api.inference.net\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^inference-/, strong: true, keyFmt: \"Inizia con inference- (es. inference-xxxx)\",\n signup: \"https://inference.net/dashboard/api-keys\",\n free: true, billed: true, verified: true,\n aliases: [\"inference net\", \"inference.net\", \"inferencenet\"],\n freeModels: [\"meta-llama/llama-3.3-70b-instruct/fp-8\", \"deepseek/deepseek-r1-distill-llama-70b\"],\n note: \"Credito gratuito iniziale su modelli open. OpenAI-compat pulito.\"\n },\n {\n id: \"kluster\", label: \"Kluster.ai\", env: \"KLUSTER_API_KEY\",\n host: \"api.kluster.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: null, keyFmt: \"Chiave alfanumerica lunga (senza prefisso fisso)\",\n signup: \"https://platform.kluster.ai/apikeys\",\n free: true, billed: true, verified: true,\n aliases: [\"kluster\", \"kluster ai\", \"kluster.ai\"],\n freeModels: [\"klusterai/Meta-Llama-3.3-70B-Instruct-Turbo\", \"deepseek-ai/DeepSeek-V3\"],\n note: \"Credito di prova gratuito su modelli open grossi (e batch a metà prezzo).\"\n },\n {\n id: \"io-intelligence\", label: \"io.net Intelligence (free)\", env: \"IOINTELLIGENCE_API_KEY\",\n host: \"api.intelligence.io.solutions\", chatPath: \"/api/v1/chat/completions\", modelsPath: \"/api/v1/models\",\n detect: /^eyJ[A-Za-z0-9_-]+\\./, keyFmt: \"JWT (inizia con eyJ...)\",\n signup: \"https://ai.io.net/ai/api-keys\",\n free: true, billed: false, verified: true,\n aliases: [\"io net\", \"io.net\", \"io intelligence\", \"ionet\"],\n freeModels: [\"meta-llama/Llama-3.3-70B-Instruct\", \"deepseek-ai/DeepSeek-R1-0528\", \"Qwen/Qwen3-235B-A22B\"],\n note: \"Piano GRATUITO con quota giornaliera su modelli grossi (llama-70b, deepseek-r1, qwen-235b). Chiave JWT.\"\n },\n {\n id: \"scaleway\", label: \"Scaleway Generative APIs\", env: \"SCALEWAY_API_KEY\",\n host: \"api.scaleway.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, strong: true,\n keyFmt: \"UUID (8-4-4-4-12, es. 0a1b2c3d-....)\",\n signup: \"https://console.scaleway.com/iam/api-keys\",\n free: true, billed: true, verified: true,\n aliases: [\"scaleway\", \"scaleway generative apis\", \"scaleway ai\"],\n freeModels: [\"llama-3.3-70b-instruct\", \"qwen2.5-coder-32b-instruct\", \"deepseek-r1-distill-llama-70b\"],\n note: \"Datacenter europeo (Parigi): quota gratuita mensile, poi a consumo. Buono se ti interessa che i dati restino in UE.\"\n },\n {\n id: \"lambda\", label: \"Lambda Inference\", env: \"LAMBDA_API_KEY\",\n host: \"api.lambda.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^secret_/, strong: true, keyFmt: \"Inizia con secret_ (es. secret_xxxx)\",\n signup: \"https://cloud.lambda.ai/api-keys\",\n free: false, billed: true, verified: true,\n aliases: [\"lambda\", \"lambda labs\", \"lambda ai\", \"lambda inference\"],\n freeModels: [],\n note: \"Modelli open a prezzi bassi (llama, qwen, deepseek). A consumo.\"\n },\n {\n id: \"baseten\", label: \"Baseten\", env: \"BASETEN_API_KEY\",\n host: \"inference.baseten.co\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: null, keyFmt: \"Chiave alfanumerica lunga (senza prefisso fisso)\",\n signup: \"https://app.baseten.co/settings/api_keys\",\n free: true, billed: true, verified: false,\n aliases: [\"baseten\", \"base ten\"],\n freeModels: [\"(credito di prova su llama/qwen/deepseek)\"],\n note: \"Model APIs OpenAI-compat + possibilità di ospitare modelli tuoi. Credito di prova. \" + NV\n },\n {\n id: \"avian\", label: \"Avian.io\", env: \"AVIAN_API_KEY\",\n host: \"api.avian.io\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: null, keyFmt: \"Chiave alfanumerica lunga (senza prefisso fisso)\",\n signup: \"https://avian.io/dashboard\",\n free: false, billed: true, verified: false,\n aliases: [\"avian\", \"avian io\", \"avian.io\"],\n freeModels: [],\n note: \"Inference veloce su modelli open. \" + NV\n },\n {\n id: \"parasail\", label: \"Parasail\", env: \"PARASAIL_API_KEY\",\n host: \"api.parasail.io\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: null, keyFmt: \"Chiave alfanumerica lunga (senza prefisso fisso)\",\n signup: \"https://www.parasail.io/\",\n free: true, billed: true, verified: false,\n aliases: [\"parasail\", \"parasail io\"],\n freeModels: [\"(credito di prova, modelli open)\"],\n note: \"Serverless su GPU a basso costo, credito di prova. \" + NV\n },\n {\n id: \"ai21\", label: \"AI21 Jamba\", env: \"AI21_API_KEY\",\n host: \"api.ai21.com\", chatPath: \"/studio/v1/chat/completions\", modelsPath: null,\n detect: null, keyFmt: \"Chiave alfanumerica lunga (senza prefisso fisso)\",\n signup: \"https://studio.ai21.com/account/api-key\",\n free: true, billed: true, verified: false,\n aliases: [\"ai21\", \"ai21 labs\", \"jamba\"],\n freeModels: [\"jamba-mini (credito di prova)\"],\n note: \"Jamba (contesto lunghissimo). Credito di prova gratuito. \" + NV\n },\n {\n id: \"writer\", label: \"Writer Palmyra\", env: \"WRITER_API_KEY\",\n host: \"api.writer.com\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: null, keyFmt: \"Chiave alfanumerica lunga (senza prefisso fisso)\",\n signup: \"https://app.writer.com/aistudio/organization/api-keys\",\n free: true, billed: true, verified: false,\n aliases: [\"writer\", \"writer ai\", \"palmyra\"],\n freeModels: [\"palmyra-x5 (credito di prova)\"],\n note: \"Palmyra X (buono sui testi lunghi). Credito di prova. \" + NV\n },\n {\n id: \"reka\", label: \"Reka AI\", env: \"REKA_API_KEY\",\n host: \"api.reka.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: null, keyFmt: \"Chiave alfanumerica lunga (senza prefisso fisso)\",\n signup: \"https://platform.reka.ai/apikeys\",\n free: false, billed: true, verified: false,\n aliases: [\"reka\", \"reka ai\", \"reka core\", \"reka flash\"],\n freeModels: [],\n note: \"Reka Flash/Core (multimodale). \" + NV\n },\n\n // ================= SENZA FILTRI / ROLEPLAY (uncensored) ==================\n {\n id: \"nanogpt\", label: \"NanoGPT (senza filtri)\", env: \"NANOGPT_API_KEY\",\n host: \"nano-gpt.com\", chatPath: \"/api/v1/chat/completions\", modelsPath: \"/api/v1/models\",\n detect: null, keyFmt: \"Chiave alfanumerica lunga (senza prefisso fisso)\",\n signup: \"https://nano-gpt.com/api\",\n free: false, billed: true, uncensored: true, verified: true,\n aliases: [\"nanogpt\", \"nano gpt\", \"nano-gpt\"],\n freeModels: [\"(pay-per-prompt: anche modelli abliterated/uncensored)\"],\n note: \"Si paga a singolo prompt (pochi centesimi, carta o cripto) e include modelli SENZA FILTRI. Nessun abbonamento.\"\n },\n {\n id: \"infermatic\", label: \"Infermatic / TotalGPT (RP senza filtri)\", env: \"INFERMATIC_API_KEY\",\n host: \"api.totalgpt.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: null, keyFmt: \"Chiave alfanumerica lunga (senza prefisso fisso)\",\n signup: \"https://infermatic.ai/\",\n free: false, billed: true, uncensored: true, verified: false,\n aliases: [\"infermatic\", \"totalgpt\", \"total gpt\", \"infermatic ai\"],\n freeModels: [\"(abbonamento fisso: modelli RP/abliterated illimitati)\"],\n note: \"Abbonamento mensile fisso con modelli roleplay/abliterated (Midnight-Miqu, magnum...). \" + NV\n },\n {\n id: \"mancer\", label: \"Mancer (senza filtri)\", env: \"MANCER_API_KEY\",\n host: \"neuro.mancer.tech\", chatPath: \"/oai/v1/chat/completions\", modelsPath: \"/oai/v1/models\",\n detect: null, keyFmt: \"Chiave alfanumerica lunga (senza prefisso fisso)\",\n signup: \"https://mancer.tech/\",\n free: false, billed: true, uncensored: true, verified: false,\n aliases: [\"mancer\", \"mancer tech\", \"mancer ai\"],\n freeModels: [\"(a crediti: mytholite, weaver — nessun filtro)\"],\n note: \"Storico provider RP senza filtri, a crediti. Endpoint OpenAI-compat su /oai/v1. \" + NV\n },\n {\n id: \"targon\", label: \"Targon (Bittensor)\", env: \"TARGON_API_KEY\",\n host: \"api.targon.com\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^sn4_/, strong: true, keyFmt: \"Inizia con sn4_ (es. sn4_xxxx)\",\n signup: \"https://targon.com/\",\n free: true, billed: false, uncensored: true, verified: false,\n aliases: [\"targon\", \"bittensor\", \"sybil\", \"manifold\"],\n freeModels: [\"deepseek-ai/DeepSeek-V3\", \"(modelli open serviti dai miner Bittensor)\"],\n note: \"Rete decentralizzata Bittensor: quota gratuita, nessun filtro aggiunto dal gateway. \" + NV\n },\n {\n id: \"nineteen\", label: \"Nineteen.ai (Bittensor)\", env: \"NINETEEN_API_KEY\",\n host: \"api.nineteen.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: null, keyFmt: \"Chiave alfanumerica lunga (senza prefisso fisso)\",\n signup: \"https://nineteen.ai/app/api\",\n free: true, billed: false, uncensored: true, verified: false,\n aliases: [\"nineteen\", \"nineteen ai\", \"nineteen.ai\", \"sn19\"],\n freeModels: [\"unsloth/Llama-3.2-3B-Instruct\", \"(modelli open dai miner)\"],\n note: \"Gateway Bittensor con piano gratuito, modelli open senza filtri aggiuntivi. \" + NV\n },\n {\n id: \"redpill\", label: \"RedPill\", env: \"REDPILL_API_KEY\",\n host: \"api.redpill.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\",\n detect: /^sk-/, keyFmt: \"Inizia con sk-\",\n signup: \"https://red-pill.ai/\",\n free: false, billed: true, uncensored: true, verified: false,\n aliases: [\"redpill\", \"red pill\", \"red-pill\"],\n freeModels: [],\n note: \"Router (anche TEE/privacy) con modelli open non filtrati. \" + NV\n }\n];\n\n/**\n * PIATTAFORME GPU aggiuntive (dove PORTI i tuoi modelli abliterated).\n * Stessa forma di gpuPlatforms.PLATFORMS: gpuPlatforms le fonde con le sue e\n * le sue vincono (Kaggle è cablata e ha lo stato live).\n */\nconst PLATFORMS_EXTRA = [\n {\n id: \"beam\", url: \"https://www.beam.cloud/\", name: \"Beam Cloud (serverless GPU)\",\n kind: \"freemium\", freeGpu: true, alwaysOn: false, uncensoredOk: true, remote: false,\n costo: \"ore GPU gratis ogni mese sul free tier, poi a consumo al secondo\",\n pros: [\n \"Ore GPU gratis ogni mese senza carta\",\n \"Serverless: paghi solo i secondi di inferenza\",\n \"Container tuoi: nessun controllo sui modelli abliterated\",\n \"Deploy da CLI in un minuto\"\n ],\n cons: [\n \"Cold start di qualche decina di secondi\",\n \"Serve un token nel .env per accenderlo da remoto (non ancora cablato)\"\n ],\n howto: \"Da cablare: BEAM_TOKEN nel .env, deploy di un endpoint Ollama/vLLM e puntarlo al failover come Kaggle.\",\n note: \"Buon compromesso: gratis all'inizio e non si scollega come Colab.\"\n },\n {\n id: \"cerebrium\", url: \"https://www.cerebrium.ai/\", name: \"Cerebrium (serverless GPU)\",\n kind: \"freemium\", freeGpu: true, alwaysOn: false, uncensoredOk: true, remote: false,\n costo: \"credito gratuito iniziale, poi fatturazione al secondo\",\n pros: [\"Credito iniziale gratis\", \"Scale-to-zero: spento non costa\", \"Modelli tuoi senza restrizioni\"],\n cons: [\"Finito il credito si paga\", \"Cold start\", \"Non ancora cablato in Antigravity\"],\n howto: \"Da cablare: deploy di un runtime vLLM/Ollama e puntarlo al failover.\",\n note: \"Alternativa a Modal, molto simile nell'uso.\"\n },\n {\n id: \"koyeb\", url: \"https://www.koyeb.com/\", name: \"Koyeb (dominio stabile, GPU a consumo)\",\n kind: \"freemium\", freeGpu: false, alwaysOn: true, uncensoredOk: true, remote: false,\n costo: \"free tier solo CPU; GPU a consumo con scale-to-zero\",\n pros: [\"Servizi sempre accesi con dominio pubblico incluso (niente ngrok)\", \"Deploy da Git/Docker semplicissimo\"],\n cons: [\"Le GPU non sono gratis\", \"Sul free tier (CPU) girano solo modelli minuscoli\"],\n howto: \"Utile come PONTE stabile (dominio fisso) davanti a un backend GPU, al posto di ngrok.\",\n note: \"Da considerare per togliere di mezzo ngrok, più che per la GPU.\"\n },\n {\n id: \"flyio\", url: \"https://fly.io/gpu\", name: \"Fly.io (GPU a consumo)\",\n kind: \"paid\", freeGpu: false, alwaysOn: true, uncensoredOk: true, remote: false,\n costo: \"GPU a ore, con macchine che si sospendono da sole quando sono inattive\",\n pros: [\"Macchine sospendibili: paghi solo quando servono\", \"Rete globale e dominio stabile\", \"Nessun limite sui modelli\"],\n cons: [\"Nessuna GPU gratis\", \"Serve carta di credito\"],\n howto: \"Da cablare: FLY_API_TOKEN nel .env per accendere/spegnere la macchina come Kaggle.\",\n note: \"Buono se vuoi un endpoint tuo affidabile pagando a ore.\"\n },\n {\n id: \"tensordock\", url: \"https://tensordock.com/\", name: \"TensorDock (marketplace GPU)\",\n kind: \"paid\", freeGpu: false, alwaysOn: true, uncensoredOk: true, remote: false,\n costo: \"marketplace: prezzi molto bassi anche su 4090/A100\",\n pros: [\"Prezzi bassissimi\", \"VM complete: ci metti quello che vuoi\", \"Nessun limite sui modelli\"],\n cons: [\"A pagamento\", \"Affidabilità variabile (host indipendenti)\", \"Setup manuale\"],\n howto: \"Da cablare: API TensorDock per creare/distruggere la VM da remoto.\",\n note: \"Alternativa a Vast.ai, spesso con listino più chiaro.\"\n },\n {\n id: \"jarvislabs\", url: \"https://jarvislabs.ai/\", name: \"JarvisLabs (GPU a ore, disco che resta)\",\n kind: \"paid\", freeGpu: false, alwaysOn: false, uncensoredOk: true, remote: false,\n costo: \"a ore (A5000/A6000), con pausa/riprendi\",\n pros: [\"Pausa e riprendi tenendo il disco: i modelli restano scaricati\", \"Semplice, orientato ai notebook\", \"API per accendere/spegnere\"],\n cons: [\"A pagamento\", \"Niente free tier\"],\n howto: \"Da cablare: JARVISLABS_API_KEY per accendere/spegnere e puntare al failover.\",\n note: \"Comodo perché il disco resta: non riscarichi 20 GB di modello ogni volta.\"\n },\n {\n id: \"novita-gpu\", url: \"https://novita.ai/gpu-instance\", name: \"Novita GPU Instance\",\n kind: \"paid\", freeGpu: false, alwaysOn: true, uncensoredOk: true, remote: false,\n costo: \"a ore (4090/A100), fatturazione al minuto\",\n pros: [\"Stesso account che usi già per l'API Novita\", \"Template Ollama/vLLM pronti\", \"Modelli tuoi senza restrizioni\"],\n cons: [\"A pagamento\", \"Nessuna GPU gratis\"],\n howto: \"Da cablare: la chiave Novita che hai già serve anche per le istanze GPU.\",\n note: \"Se hai già la chiave Novita, è la strada più corta per una GPU 24/7.\"\n },\n {\n id: \"sagemaker-lab\", url: \"https://studiolab.sagemaker.aws/\", name: \"Amazon SageMaker Studio Lab\",\n kind: \"free\", freeGpu: true, alwaysOn: false, uncensoredOk: true, remote: false,\n costo: \"0€ — sessioni GPU a tempo, nessuna carta di credito\",\n pros: [\"GPU gratis senza carta\", \"Ambiente persistente (il disco resta)\", \"Più stabile di Colab\"],\n cons: [\"Sessioni a tempo, poi va riavviato a mano\", \"Approvazione dell'account non immediata\", \"Non gestibile da remoto\"],\n howto: \"Alternativa gratuita a Kaggle quando la quota settimanale è finita.\",\n note: \"Ore GPU gratis in più, utili come riserva.\"\n },\n {\n id: \"saturn\", url: \"https://saturncloud.io/\", name: \"Saturn Cloud (free GPU)\",\n kind: \"freemium\", freeGpu: true, alwaysOn: false, uncensoredOk: true, remote: false,\n costo: \"0€ — ore di calcolo gratis ogni mese (una parte su GPU)\",\n pros: [\"Ore GPU gratis al mese\", \"Ambienti Jupyter persistenti\", \"Niente carta per il free tier\"],\n cons: [\"Risorse gratis limitate e a volte in coda\", \"Non cablato in Antigravity\"],\n howto: \"Come Kaggle: notebook con Ollama + tunnel, da puntare al failover.\",\n note: \"Buona riserva quando Kaggle è a quota zero.\"\n },\n {\n id: \"thunder\", url: \"https://www.thundercompute.com/\", name: \"Thunder Compute (GPU low cost)\",\n kind: \"paid\", freeGpu: false, alwaysOn: true, uncensoredOk: true, remote: false,\n costo: \"A100 virtualizzata a poche decine di centesimi l'ora, al minuto\",\n pros: [\"Tra i prezzi più bassi per una A100\", \"CLI semplice, istanze in pochi secondi\", \"Nessun limite sui modelli\"],\n cons: [\"A pagamento\", \"GPU virtualizzata: qualche punto percentuale di prestazioni in meno\"],\n howto: \"Da cablare: token nel .env per accendere/spegnere l'istanza.\",\n note: \"Se serve un modello 70B senza spendere come su AWS.\"\n },\n {\n id: \"primeintellect\", url: \"https://www.primeintellect.ai/\", name: \"Prime Intellect (confronto prezzi GPU)\",\n kind: \"paid\", freeGpu: false, alwaysOn: true, uncensoredOk: true, remote: false,\n costo: \"aggregatore: prende il prezzo più basso tra molti fornitori\",\n pros: [\"Confronta i prezzi di molti fornitori in un unico posto\", \"API unica per accendere/spegnere\", \"Nessun limite sui modelli\"],\n cons: [\"A pagamento\", \"La qualità dipende dal fornitore che ti assegna\"],\n howto: \"Da cablare: API key per creare/distruggere l'istanza dal telefono.\",\n note: \"Comodo per trovare la GPU più economica disponibile al momento.\"\n },\n {\n id: \"hyperstack\", url: \"https://www.hyperstack.cloud/\", name: \"Hyperstack (GPU europee)\",\n kind: \"paid\", freeGpu: false, alwaysOn: true, uncensoredOk: true, remote: false,\n costo: \"a ore (A100/H100), datacenter in Europa\",\n pros: [\"Datacenter europei: latenza bassa dall'Italia e dati in UE\", \"API completa per accendere/spegnere\", \"Modelli tuoi senza restrizioni\"],\n cons: [\"A pagamento\", \"Niente free tier\"],\n howto: \"Da cablare: HYPERSTACK_API_KEY nel .env.\",\n note: \"La scelta 'europea' se ti interessa dove stanno i dati.\"\n }\n];\n\nmodule.exports = { CATALOG, PLATFORMS_EXTRA };\n"}}, "src/providerRegistry.js": {"b004d67566fd93489bf4cb3fbc1b4d93bfed8e95": {"t": 1786068492.7513866, "n": 35643, "h": "e346422b4a2d4928", "c": "\"use strict\";\n\n/**\n * providerRegistry — catalogo dei provider LLM (OpenAI-compatibili) con\n * RILEVAMENTO AUTOMATICO dalla forma della chiave. L'utente incolla SOLO la\n * chiave: endpoint (host/base_url), nome variabile .env e altro si auto-compilano.\n *\n * Ogni voce:\n * id identificatore interno (usato come \"id::modello\")\n * label nome leggibile\n * env nome variabile in .env\n * host host HTTPS dell'endpoint OpenAI-compat\n * chatPath path chat completions\n * modelsPath path per elencare i modelli (GET, OpenAI-compat) — opzionale\n * detect regex sul formato della chiave (per l'auto-rilevamento)\n * signup URL dove ottenere la chiave (reindirizzamento)\n * free true se ha un piano gratuito utile\n * keyFmt descrizione UMANA del formato della chiave (es. \"sk-or-v1-...\",\n * \"chiave senza prefisso fisso\", \"JWT (eyJ...)\") — la UI la mostra\n * così l'utente sa COSA incollare e il cacciatore può scriverlo in\n * automatico sotto il nome del provider.\n * note suggerimento breve (perché è utile / cosa offre)\n * billed true se a consumo (costi) — la barra uso lo considera\n *\n * NB: alcune chiavi hanno prefissi condivisi (`sk-...` = OpenAI/DeepSeek/Alibaba):\n * per quelle il rilevamento propone il candidato più probabile con ambiguous=true,\n * e la UI lascia scegliere/confermare.\n */\n\nconst PROVIDERS = [\n // ---- GRATIS, ottimi per coding/reverse engineering -------------------\n // ⚠️ 2026-07-24 — i :free grossi (llama-3.3-70b, hermes-405b) sono passati a\n // PAGAMENTO: rispondono 404 \"use the paid slug\". Tenuto nel registro (il giorno\n // che c'è budget si riaccende con OPENROUTER_ENABLE=1) ma fuori dal failover free.\n { keyFmt: \"Inizia con sk-or-v1- (es. «redacted:sk-or-v1-...»)\", id: \"openrouter\", label: \"OpenRouter (aggregatore)\", env: \"OPENROUTER_API_KEY\", host: \"openrouter.ai\", chatPath: \"/api/v1/chat/completions\", modelsPath: \"/api/v1/models\", detect: /^sk-or-v1-/, signup: \"https://openrouter.ai/keys\", free: false, note: \"⚠️ i modelli :free grossi ora sono a pagamento. Riattivabile con OPENROUTER_ENABLE=1 nel .env.\" },\n { keyFmt: \"Inizia con gsk_ (es. gsk_xxxx)\", id: \"groq\", label: \"Groq\", env: \"GROQ_API_KEY\", host: \"api.groq.com\", chatPath: \"/openai/v1/chat/completions\", modelsPath: \"/openai/v1/models\", detect: /^gsk_/, signup: \"https://console.groq.com/keys\", free: true, note: \"Velocissimo, gratis: llama-3.3-70b, qwen, gpt-oss-120b, deepseek-distill.\" },\n { keyFmt: \"Inizia con csk- (es. csk-xxxx)\", id: \"cerebras\", label: \"Cerebras\", env: \"CEREBRAS_API_KEY\", host: \"api.cerebras.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: /^csk-/, signup: \"https://cloud.cerebras.ai/\", free: true, note: \"Gratis e ULTRA-veloce: llama-3.3-70b, qwen-3-32b/coder. Ottimo per l'agente.\" },\n { keyFmt: \"Inizia con nvapi- (es. nvapi-xxxx)\", id: \"nvidia\", label: \"NVIDIA NIM\", env: \"NVIDIA_API_KEY\", host: \"integrate.api.nvidia.com\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: /^nvapi-/, signup: \"https://build.nvidia.com/\", free: true, note: \"Gratis: modelli GROSSI (nemotron, deepseek, qwen-coder, llama-405b). Ottimo per RE.\" },\n { keyFmt: \"Chiave senza prefisso fisso (50+ caratteri alfanumerici)\", id: \"sambanova\", label: \"SambaNova\", env: \"SAMBANOVA_API_KEY\", host: \"api.sambanova.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: null, signup: \"https://cloud.sambanova.ai/apis\", free: true, note: \"Gratis e veloce: llama-3.1-405B, qwen. Chiave senza prefisso fisso.\" },\n { keyFmt: \"Inizia con hf_ (es. hf_xxxx)\", id: \"hf\", label: \"HuggingFace\", env: \"HF_TOKEN\", envAliases: [\"HUGGINGFACE_API_KEY\"], host: \"router.huggingface.co\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: /^hf_/, signup: \"https://huggingface.co/settings/tokens\", free: true, note: \"Router su molti provider; modelli abliterated/uncensored. Crediti free mensili.\", billed: true },\n { keyFmt: \"Inizia con AIza o AQ. (chiave Google AI Studio)\", id: \"google\", label: \"Google Gemini\", env: \"GOOGLE_API_KEY\", envAliases: [\"GEMINI_API_KEY\"], host: \"generativelanguage.googleapis.com\", chatPath: \"/v1beta/openai/chat/completions\", modelsPath: \"/v1beta/openai/models\", detect: /^AIza/, signup: \"https://aistudio.google.com/app/apikey\", free: true, note: \"Gratis: gemini-2.x-flash (grande contesto). Buono anche per immagini.\" },\n { keyFmt: \"32 caratteri alfanumerici (es. abcdef0123456789abcdef0123456789)\", id: \"mistral\", label: \"Mistral\", env: \"MISTRAL_API_KEY\", host: \"api.mistral.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: /^[A-Za-z0-9]{32}$/, signup: \"https://console.mistral.ai/api-keys/\", free: true, note: \"Piano free: mistral-large, codestral (coding). Chiave 32 caratteri.\" },\n // ⚠️ 2026-07-24 — verificato con la chiave reale: 402 \"insufficient funds\".\n // Resta selezionabile a mano, ma è FUORI dal failover gratuito (creava buchi).\n { keyFmt: \"JWT (inizia con eyJ...)\", id: \"hyperbolic\", label: \"Hyperbolic\", env: \"HYPERBOLIC_API_KEY\", host: \"api.hyperbolic.xyz\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: /^eyJ/, signup: \"https://app.hyperbolic.xyz/settings\", free: false, note: \"⚠️ credito esaurito (402). Riattivabile con HYPERBOLIC_ENABLE=1 nel .env.\" },\n // ★ 2026-07-24 — NUOVA CORSIA GRATIS: GitHub Models. Basta un token GitHub\n // (ghp_/github_pat_), nessuna carta: gpt-4o-mini, llama, phi, qwen, deepseek.\n // ★ 2026-07-24 — CLOUDFLARE WORKERS AI. Caso particolare: l'endpoint contiene\n // l'ID dell'account, quindi oltre alla chiave (CLOUDFLARE_API_KEY, formato\n // cfat_...) serve CLOUDFLARE_ACCOUNT_ID nel .env. Il segnaposto {ACCOUNT} viene\n // sostituito da cloudEngine._buildProviders; senza ID il provider resta spento.\n { keyFmt: \"Inizia con cfat_ o cfut_ (es. cfat_xxxx)\", id: \"cloudflare\", label: \"Cloudflare Workers AI\", env: \"CLOUDFLARE_API_KEY\", host: \"api.cloudflare.com\", chatPath: \"/client/v4/accounts/{ACCOUNT}/ai/v1/chat/completions\", modelsPath: \"/client/v4/accounts/{ACCOUNT}/ai/models/search?task=Text+Generation&per_page=100\", needsEnv: \"CLOUDFLARE_ACCOUNT_ID\", detect: /^cf(at|ut)_/, signup: \"https://dash.cloudflare.com/profile/api-tokens\", free: true, note: \"Gratis ogni giorno sull'edge Cloudflare: gpt-oss-120b, kimi-k2, deepseek-r1-distill. Serve anche CLOUDFLARE_ACCOUNT_ID.\" },\n { keyFmt: \"Inizia con ghp_ o github_pat_\", id: \"github\", label: \"GitHub Models\", env: \"GITHUB_TOKEN\", host: \"models.github.ai\", chatPath: \"/inference/chat/completions\", modelsPath: \"/catalog/models\", models: [\"openai/gpt-4o-mini\"], detect: /^(ghp_|github_pat_)/, signup: \"https://github.com/settings/tokens\", free: true, note: \"Gratis col tuo account GitHub (token classic o fine-grained): gpt-4o-mini, llama, phi, qwen, deepseek.\" },\n { keyFmt: \"Inizia con sk_ (es. sk_xxxx)\", id: \"novita\", label: \"Novita AI\", env: \"NOVITA_API_KEY\", host: \"api.novita.ai\", chatPath: \"/v3/openai/chat/completions\", modelsPath: \"/v3/openai/models\", detect: /^sk_/, signup: \"https://novita.ai/settings/key-management\", free: true, note: \"Crediti gratis: molti modelli open (llama, qwen, deepseek).\" },\n { keyFmt: \"Inizia con cpk_ (es. cpk_xxxx)\", id: \"chutes\", label: \"Chutes\", env: \"CHUTES_API_KEY\", host: \"llm.chutes.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: /^cpk_/, signup: \"https://chutes.ai/\", free: true, note: \"Modelli open serverless, molti gratuiti (deepseek, qwen).\" },\n { keyFmt: \"Inizia con glhf_ (es. glhf_xxxx)\", id: \"glhf\", label: \"glhf.chat\", env: \"GLHF_API_KEY\", host: \"glhf.chat\", chatPath: \"/api/openai/v1/chat/completions\", modelsPath: \"/api/openai/v1/models\", detect: /^glhf_/, signup: \"https://glhf.chat/\", free: true, note: \"Esegue quasi ogni modello HuggingFace, gratis in beta.\" },\n { keyFmt: \"Forma id.segreto (es. 0123456789abcdef0123456789abcdef.secret)\", id: \"ollama-cloud\", label: \"Ollama Cloud\", env: \"OLLAMA_API_KEY\", host: \"ollama.com\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: /^[0-9a-f]{32}\\./, signup: \"https://ollama.com/settings/keys\", free: true, note: \"I tuoi modelli Ollama nel cloud (qwen, deepseek-coder). Chiave id.segreto.\" },\n // ★ CORSIA PRIORITARIA dell'utente: abliterated coder su GPU Kaggle (Ollama) via\n // tunnel ngrok con dominio FISSO. Attiva quando il notebook Kaggle gira. La chiave\n // è fittizia (Ollama la ignora): basta KAGGLE_KEY=ollama nel .env per attivarla.\n { keyFmt: \"Chiave fittizia: basta KAGGLE_KEY=ollama (il notebook non la usa)\", id: \"kaggle\", label: \"Kaggle GPU (abliterated coder)\", env: \"KAGGLE_KEY\", host: \"paving-preschool-facelift.ngrok-free.dev\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: null, signup: \"https://www.kaggle.com/code\", free: true, note: \"La TUA corsia abliterated coder (Qwen2.5-Coder 14B/32B) su GPU Kaggle via tunnel ngrok. Priorità nel failover quando il notebook è acceso.\" },\n // ★ 2026-07-30 — HOST CORRETTO. Puntava al workspace eu-central (Francoforte):\n // con una chiave creata sul portale CINESE (bailian.console.aliyun.com) quel\n // dominio risponde 401 \"Incorrect API key\", e il pannello dichiarava \"chiave\n // obsoleta\" una chiave in realtà PERFETTAMENTE VIVA. Verificato dal vivo:\n // dashscope.aliyuncs.com → 200 col tuo sk-ws-... · eu-central e -intl → 401.\n // Se un domani la chiave la fai sul portale internazionale, usa la voce\n // \"alibaba-intl\" qui sotto (DASHSCOPE_INTL_API_KEY).\n { keyFmt: \"Inizia con sk- (es. sk-ws-xxxx)\", id: \"alibaba\", label: \"Alibaba Qwen (DashScope Cina)\", env: \"DASHSCOPE_API_KEY\", host: \"dashscope.aliyuncs.com\", chatPath: \"/compatible-mode/v1/chat/completions\", modelsPath: \"/compatible-mode/v1/models\", detect: /^sk-/, signup: \"https://bailian.console.aliyun.com/\", free: true, note: \"Model Studio CINESE (qwen-coder/max/flash). La chiave del portale cinese funziona SOLO su dashscope.aliyuncs.com, non sui domini -intl/eu-central.\", ambiguousHint: true },\n { keyFmt: \"Inizia con sk- (es. sk-xxxx)\", id: \"alibaba-intl\", label: \"Alibaba Qwen (DashScope internazionale)\", env: \"DASHSCOPE_INTL_API_KEY\", host: \"dashscope-intl.aliyuncs.com\", chatPath: \"/compatible-mode/v1/chat/completions\", modelsPath: \"/compatible-mode/v1/models\", detect: /^sk-/, signup: \"https://bailian.console.alibabacloud.com/\", free: true, note: \"Model Studio INTERNAZIONALE (Singapore/Francoforte). Chiave separata da quella cinese: le due NON sono intercambiabili.\", ambiguousHint: true },\n\n // ★ 2026-07-27 — NUOVI GATEWAY GRATUITI (scoperti dal cacciatore online):\n // veri endpoint OpenAI-compat, piano free senza carta -> entrano nel failover.\n { keyFmt: \"Chiave fittizia: KILO_KEY=none (nessun account richiesto)\", id: \"kilo\", label: \"Kilo Gateway (free, no account)\", env: \"KILO_API_KEY\", host: \"api.kilo.ai\", chatPath: \"/api/gateway/v1/chat/completions\", modelsPath: \"/api/gateway/v1/models\", detect: null, signup: \"https://kilo.ai/docs/gateway\", free: true, note: \"Gateway OpenAI-compat: free model senza account (kilo-auto/free router, minimax-m2.5:free, step-3.5-flash:free, nemotron-3-super-120b:free). Limite ~200 req/h per IP. Chiave fittizia: KILO_KEY=none.\", ambiguousHint: false },\n { keyFmt: \"Chiave fittizia: OPENCODE_ZEN_KEY=none (nessun account richiesto)\", id: \"opencode\", label: \"OpenCode Zen (gateway free)\", env: \"OPENCODE_ZEN_KEY\", host: \"opencode.ai\", chatPath: \"/zen/v1/chat/completions\", modelsPath: \"/zen/v1/models\", detect: null, signup: \"https://opencode.ai/docs/zen/\", free: true, note: \"Gateway curato di modelli free (DeepSeek V4 Flash Free, Laguna S 2.1 Free, Ling-3.0-flash Free, North Mini Code Free). OpenAI-compat. Chiave fittizia: OPENCODE_ZEN_KEY=none.\" },\n\n // ★ AGGIUNTO — Venice AI: OpenAI-compat, modelli UNCENSORED (venice-uncensored,\n // zai-org-glm-5-2). Chiave + base URL nel .env (VENICE_API_KEY, VENICE_BASE_URL).\n { keyFmt: \"Inizia con VENICE_ (es. VENICE_xxxx)\", id: \"venice\", label: \"Venice AI (uncensored)\", env: \"VENICE_API_KEY\", host: \"api.venice.ai\", chatPath: \"/api/v1/chat/completions\", modelsPath: \"/api/v1/models\", detect: /^VENICE_/, signup: \"https://venice.ai/\", free: true, note: \"Uncensored: venice-uncensored, glm-5-2. OpenAI-compat su api.venice.ai.\" },\n\n // ★ 2026-07-27 — NUOVI PROVIDER (endpoint verificati live con curl). OpenAI-compat.\n // ArliAI: specializzato in modelli UNCENSORED/RP (senza filtri), piano free con\n // limite di richieste. Endpoint api.arliai.com/v1 (401 senza chiave = vivo).\n // Chiave: alphanumerica, 40+ caratteri, senza prefisso fisso.\n { keyFmt: \"Chiave alfanumerica lunga (40+ caratteri, es. abcdef123456...)\", id: \"arliai\", label: \"ArliAI (uncensored/RP)\", env: \"ARLIAI_API_KEY\", host: \"api.arliai.com\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: /^[A-Za-z0-9]{40,}$/, signup: \"https://www.arliai.com/\", free: true, note: \"Modelli UNCENSORED/roleplay illimitati sul piano (Mistral-Nemo, Llama-3.1-8B abliterated, Qwen2.5-Coder (varie)). Piano free disponibile.\", uncensored: true },\n // DeepInfra: catalogo enorme open-weight, credito iniziale gratuito, OpenAI-compat.\n // Chiave: alphanumerica, 40+ caratteri, senza prefisso fisso.\n { keyFmt: \"Chiave alfanumerica lunga (40+ caratteri, es. abcdef123456...)\", id: \"deepinfra\", label: \"DeepInfra\", env: \"DEEPINFRA_API_KEY\", host: \"api.deepinfra.com\", chatPath: \"/v1/openai/chat/completions\", modelsPath: \"/v1/openai/models\", detect: /^[A-Za-z0-9]{40,}$/, signup: \"https://deepinfra.com/dash/api_keys\", free: true, note: \"Tanti modelli open (llama, qwen, deepseek, mixtral). Credito iniziale gratuito, poi economico. Chiave alfanumerica lunga.\", billed: true },\n // Featherless: 'run any HuggingFace model', include molti abliterated/uncensored.\n { keyFmt: \"Inizia con rc_ (es. rc_xxxx)\", id: \"featherless\", label: \"Featherless (HF models)\", env: \"FEATHERLESS_API_KEY\", host: \"api.featherless.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: /^rc_/, signup: \"https://featherless.ai/\", free: false, note: \"Esegue quasi ogni modello HuggingFace, inclusi abliterated/uncensored. Abbonamento a prezzo fisso. Chiave rc_...\", uncensored: true, billed: true },\n // Cloudrift: gateway inference OpenAI-compat, credito di prova.\n // Chiave: alphanumerica, 40+ caratteri, senza prefisso fisso.\n { keyFmt: \"Chiave alfanumerica lunga (40+ caratteri, es. abcdef123456...)\", id: \"cloudrift\", label: \"Cloudrift\", env: \"CLOUDRIFT_API_KEY\", host: \"inference.cloudrift.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: /^[A-Za-z0-9]{40,}$/, signup: \"https://www.cloudrift.ai/\", free: true, note: \"Gateway inference OpenAI-compat (deepseek, qwen, llama). Credito di prova gratuito. Chiave alfanumerica lunga.\", uncensored: true },\n\n // ---- A PAGAMENTO / trial (utili ma con costi) ------------------------\n { keyFmt: \"Inizia con sk- (es. sk-proj-xxxx o sk-xxxx)\", id: \"openai\", label: \"OpenAI\", env: \"OPENAI_API_KEY\", host: \"api.openai.com\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: /^sk-(proj-|svcacct-)?[A-Za-z0-9_-]{20,}$/, signup: \"https://platform.openai.com/api-keys\", free: false, billed: true, note: \"GPT-4o/4.1/o-series. A pagamento. Chiave sk-... o sk-proj-...\", ambiguousHint: true },\n { keyFmt: \"Inizia con sk- (es. sk-xxxx)\", id: \"deepseek\", label: \"DeepSeek\", env: \"DEEPSEEK_API_KEY\", host: \"api.deepseek.com\", chatPath: \"/chat/completions\", modelsPath: \"/models\", detect: /^sk-/, signup: \"https://platform.deepseek.com/api_keys\", free: false, billed: true, note: \"deepseek-chat/reasoner, economici e forti nel coding. Chiave sk-... (ambigua).\", ambiguousHint: true },\n { keyFmt: \"Inizia con sk-ant- (es. sk-ant-xxxx)\", id: \"anthropic\", label: \"Anthropic Claude\", env: \"ANTHROPIC_API_KEY\", host: \"api.anthropic.com\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: /^sk-ant-/, signup: \"https://console.anthropic.com/settings/keys\", free: false, billed: true, note: \"Claude (Opus/Sonnet). Endpoint OpenAI-compat su /v1. A pagamento.\" },\n { keyFmt: \"Inizia con xai- (es. xai-xxxx)\", id: \"xai\", label: \"xAI Grok\", env: \"XAI_API_KEY\", host: \"api.x.ai\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: /^xai-/, signup: \"https://console.x.ai/\", free: false, billed: true, note: \"Grok. Trial iniziale, poi a pagamento.\" },\n { keyFmt: \"Inizia con pplx- (es. pplx-xxxx)\", id: \"perplexity\", label: \"Perplexity\", env: \"PERPLEXITY_API_KEY\", host: \"api.perplexity.ai\", chatPath: \"/chat/completions\", modelsPath: null, detect: /^pplx-/, signup: \"https://www.perplexity.ai/settings/api\", free: false, billed: true, note: \"Sonar con ricerca web integrata. A pagamento.\" },\n { keyFmt: \"Inizia con fw_ (es. fw_xxxx)\", id: \"fireworks\", label: \"Fireworks AI\", env: \"FIREWORKS_API_KEY\", host: \"api.fireworks.ai\", chatPath: \"/inference/v1/chat/completions\", modelsPath: \"/inference/v1/models\", detect: /^fw_/, signup: \"https://fireworks.ai/account/api-keys\", free: false, billed: true, note: \"Modelli open velocissimi (llama, qwen, deepseek). Credito di prova.\" },\n { keyFmt: \"64 caratteri esadecimali (es. a1b2c3...f0)\", id: \"together\", label: \"Together AI\", env: \"TOGETHER_API_KEY\", host: \"api.together.xyz\", chatPath: \"/v1/chat/completions\", modelsPath: \"/v1/models\", detect: /^[0-9a-f]{64}$/, signup: \"https://api.together.ai/settings/api-keys\", free: false, billed: true, note: \"Molti modelli open. Credito iniziale gratuito. Chiave 64 esadecimali.\" },\n];\n\n// ★ 2026-07-30 — FUSIONE COL CATALOGO PRECONFIGURATO (providerCatalog.js).\n// Il registro qui sopra è la lista CURATA (note e quote tarate a mano); il\n// catalogo è la lista LARGA di tutti i provider noti, con endpoint e formato\n// della chiave già pronti. Le voci curate VINCONO: dal catalogo prendono solo i\n// campi che non hanno (alias, freeModels, verified). Le voci nuove si aggiungono\n// in coda, così il selettore manuale e il rilevamento le vedono subito.\n// Se il file manca, il registro continua a funzionare com'era (nessun crash).\nlet CATALOG = [];\ntry { CATALOG = require(\"./providerCatalog\").CATALOG || []; } catch (_) { CATALOG = []; }\nfor (const c of CATALOG) {\n const cur = PROVIDERS.find(p => p.id === c.id);\n if (cur) {\n for (const k of [\"aliases\", \"verified\", \"freeModels\", \"keyFmt\", \"signup\", \"keyUrl\"]) {\n if (cur[k] === undefined && c[k] !== undefined) cur[k] = c[k];\n }\n continue;\n }\n PROVIDERS.push(Object.assign({}, c));\n}\n\n/** Quanto è \"probabile\" un candidato: gratis e verificati prima, a pagamento e\n * da confermare dopo. Serve a ordinare le proposte quando la chiave è ambigua. */\nfunction candRank(p) {\n return (p.free ? 0 : 3) + (p.verified === false ? 2 : 0) + (p.billed ? 1 : 0);\n}\n\n/**\n * Rileva il/i provider da una chiave incollata. Ritorna:\n * { best, candidates:[{id,label,...,ambiguous}], value }\n * best = miglior candidato (o null). candidates = tutti i plausibili (ordinati).\n */\nfunction detectProvider(rawKey) {\n const key = String(rawKey || \"\").trim();\n if (!key) return { best: null, candidates: [] };\n\n // 1) Prefissi FORTI (un solo provider possibile) — controllati in ordine.\n const STRONG = [\n [/^sk-or-v1-/, \"openrouter\"],\n [/^sk-ant-/, \"anthropic\"],\n [/^sk-(proj|svcacct)-/, \"openai\"],\n [/^gsk_/, \"groq\"],\n [/^hf_/, \"hf\"],\n [/^csk-/, \"cerebras\"],\n [/^nvapi-/, \"nvidia\"],\n [/^xai-/, \"xai\"],\n [/^pplx-/, \"perplexity\"],\n [/^fw_/, \"fireworks\"],\n [/^glhf_/, \"glhf\"],\n [/^cpk_/, \"chutes\"],\n [/^rc_/, \"featherless\"],\n // Cloudflare: `cfat_` = account token, `cfut_` = user token (quello che si\n // ottiene creando il token dal profilo). Vanno bene entrambi.\n [/^cf(at|ut)_/, \"cloudflare\"],\n [/^ghp_/, \"github\"],\n [/^github_pat_/, \"github\"],\n // ★ 2026-07-30 — JWT (eyJ...) e id.segreto NON sono più \"forti\": li usano\n // più provider (JWT: Hyperbolic, Nebius, io.net, MiniMax · id.segreto:\n // Ollama Cloud, Z.ai, BigModel). Prima vinceva sempre il primo della lista\n // — cioè Hyperbolic, che è pure a credito zero — e la chiave finiva salvata\n // sul provider sbagliato. Ora scendono fra i casi ambigui e scegli tu.\n [/^AIza[0-9A-Za-z_-]{10,}$/, \"google\"]\n ];\n for (const [rx, id] of STRONG) {\n if (rx.test(key)) return { best: publicView(byId(id)), candidates: [Object.assign(publicView(byId(id)), { ambiguous: false })] };\n }\n\n // 1-ter) ★ 2026-07-30 — TRAPPOLA GOOGLE. Un token che inizia con \"AQ.\" NON è\n // una chiave API di AI Studio: è un token OAuth TEMPORANEO (dura circa un'ora)\n // che si prende copiando dal posto sbagliato — di solito dalla sessione del\n // browser o dagli strumenti Gemini/Code Assist. Prima il registro lo trattava\n // come chiave Google valida: veniva salvato nel .env, funzionava per qualche\n // minuto e poi il pannello lo dichiarava \"chiave obsoleta\". Verificato dal vivo\n // il 2026-07-30: 401 \"Expected OAuth 2 access token\" su tutti e tre i modi\n // (Bearer, ?key=, x-goog-api-key). Ora lo diciamo PRIMA di salvarlo.\n if (/^AQ\\./.test(key)) {\n const g = byId(\"google\");\n return {\n best: null,\n candidates: g ? [Object.assign(publicView(g), { ambiguous: true })] : [],\n warn: \"Questo NON è una chiave API di Google: i token che iniziano con «AQ.» sono temporanei e scadono in circa un'ora. La chiave vera inizia con «AIza» e si prende su aistudio.google.com/app/apikey → «Create API key».\"\n };\n }\n\n // 1-bis) ★ 2026-07-30 — PREFISSI FORTI DEL CATALOGO (vck_, up_, flp_, sn4_,\n // secret_, inference-, ms-, bce-v3/, UUID Scaleway...): sono prefissi unici,\n // quindi valgono come la lista STRONG qui sopra. Prima questi provider non\n // venivano riconosciuti affatto e finivano in \"provider non riconosciuto\".\n const strongCat = PROVIDERS.filter(p => p.strong && p.detect && p.detect.test(key));\n if (strongCat.length === 1) {\n return { best: publicView(strongCat[0]), candidates: [Object.assign(publicView(strongCat[0]), { ambiguous: false })] };\n }\n\n // 2) Forme AMBIGUE (più provider plausibili) → proponi i candidati, UI conferma.\n let ids = [];\n if (/^sk-[A-Za-z0-9]/.test(key)) {\n // sk- generico: molti provider lo usano. Includi i principali + together\n // (alcune chiavi Together hanno prefisso sk-). La UI fa scegliere.\n ids = [\"deepseek\", \"openai\", \"alibaba\", \"together\", \"mistral\"];\n }\n else if (/^eyJ[A-Za-z0-9_-]+\\./.test(key)) ids = [\"io-intelligence\", \"nebius\", \"minimax\", \"hyperbolic\"];\n else if (/^[0-9a-f]{32}\\.[A-Za-z0-9_-]{10,}$/.test(key)) ids = [\"ollama-cloud\", \"zai\", \"bigmodel\"];\n else if (/^sk_/.test(key)) ids = [\"novita\", \"ppinfra\"];\n else if (/^[0-9a-f]{64}$/.test(key)) ids = [\"together\"];\n else if (/^[A-Za-z0-9]{32}$/.test(key)) ids = [\"deepinfra\", \"mistral\", \"together\"];\n\n // ★ 2026-07-30 — CANDIDATI DAL CATALOGO: qualunque altra voce la cui regex\n // riconosce questa chiave entra fra i candidati (in coda a quelli storici,\n // che restano i preferiti), ordinata per probabilità: gratis e verificati\n // prima. Così una chiave sk- propone anche moonshot/siliconflow/hunyuan/...\n // invece di lasciare l'utente davanti a un elenco vuoto.\n const extra = PROVIDERS\n .filter(p => p.detect && p.detect.test(key) && ids.indexOf(p.id) < 0)\n .sort((a, b) => candRank(a) - candRank(b))\n .map(p => p.id);\n ids = ids.concat(extra);\n\n const cands = ids.map(byId).filter(Boolean);\n return {\n best: cands[0] ? Object.assign(publicView(cands[0]), { ambiguous: cands.length > 1 }) : null,\n candidates: cands.map(p => Object.assign(publicView(p), { ambiguous: cands.length > 1 }))\n };\n}\n\n/** Vista \"pubblica\" (senza la regex) di una voce provider. */\nfunction publicView(p) {\n return {\n id: p.id, label: p.label, env: p.env, host: p.host,\n chatPath: p.chatPath, modelsPath: p.modelsPath || null,\n signup: p.signup, free: !!p.free, billed: !!p.billed, note: p.note || \"\",\n keyFmt: p.keyFmt || \"\", uncensored: !!p.uncensored,\n // ★ 2026-07-30 — verified:false = endpoint preso dal catalogo ma non ancora\n // confermato dal vivo. La UI lo mostra, così sai che dopo il salvataggio\n // conviene premere \"Verifica\".\n verified: p.verified !== false\n };\n}\n\nfunction byId(id) { return PROVIDERS.find(p => p.id === id) || null; }\nfunction byEnv(env) { return PROVIDERS.find(p => p.env === env) || null; }\nfunction all() { return PROVIDERS.map(publicView); }\n/** Solo i provider con piano gratuito, per i \"suggerimenti\". */\nfunction freeSuggestions() { return PROVIDERS.filter(p => p.free).map(publicView); }\n\n/**\n * FREE_PROVIDERS — \"Cacciatore di taglie\": lista CURATA (NON discovery automatica)\n * di provider/modelli gratuiti verificati. L'agente la mantiene a mano qui, così\n * la UI può proporli all'utente senza rischi di rate-limit/ban/falsi positivi.\n * Ogni voce:\n * id identificatore (coincide con PROVIDERS[].id quando esiste)\n * name nome leggibile\n * keyUrl dove prendere la chiave (apre il sito; MAI esponre chiavi altrui)\n * freeModels elenco (curato) di modelli :free noti\n * note perché vale la pena / cosa offre\n * uncensored true per provider/modelli senza filtri (dolphin/abliterated)\n */\nconst FREE_PROVIDERS = [\n { id: \"openrouter\", name: \"OpenRouter ⚠️ (ora a pagamento)\", keyUrl: \"https://openrouter.ai/keys\",\n freeModels: [],\n note: \"⚠️ Verificato 2026-07-24: i modelli :free grossi sono passati a pagamento (404 → 'use the paid slug'). Escluso dal failover gratuito per non creare buchi. Si riattiva con OPENROUTER_ENABLE=1 nel .env.\" },\n { id: \"github\", name: \"GitHub Models (gratis col tuo account)\", keyUrl: \"https://github.com/settings/tokens\",\n freeModels: [\"openai/gpt-4o-mini\", \"meta/llama-3.3-70b-instruct\", \"microsoft/phi-4\", \"deepseek/deepseek-v3\", \"mistral-ai/codestral-2501\"],\n note: \"Gratis con un token GitHub (nessuna carta). Metti il token come GITHUB_TOKEN: diventa subito una corsia del failover.\" },\n { id: \"mistral\", name: \"Mistral (piano free)\", keyUrl: \"https://console.mistral.ai/api-keys/\",\n freeModels: [\"codestral-latest\", \"devstral-medium-latest\", \"mistral-large-latest\", \"mistral-small-latest\"],\n note: \"Piano free con 50 richieste/minuto: codestral è ottimo per il codice. Chiave da 32 caratteri.\" },\n { id: \"groq\", name: \"Groq\", keyUrl: \"https://console.groq.com/keys\",\n freeModels: [\"llama-3.3-70b-versatile\", \"qwen-qwq-32b\", \"gpt-oss-120b\", \"deepseek-r1-distill-llama-70b\"],\n note: \"Velocissimo, gratis: llama-3.3-70b, qwen, gpt-oss-120b, deepseek-distill.\" },\n { id: \"google\", name: \"Google AI Studio (Gemini)\", keyUrl: \"https://aistudio.google.com/app/apikey\",\n freeModels: [\"gemini-2.0-flash\", \"gemini-2.5-flash\", \"gemini-2.5-pro (quota giornaliera)\"],\n note: \"Gratis: gemini-2.x-flash (grande contesto). Buono anche per immagini.\" },\n { id: \"hf\", name: \"HuggingFace Inference\", keyUrl: \"https://huggingface.co/settings/tokens\",\n freeModels: [\"meta-llama/Llama-3.3-70B-Instruct\", \"Qwen/Qwen2.5-Coder-32B-Instruct\", \"deepseek-ai/DeepSeek-V3-0324\"],\n note: \"Router su molti provider; crediti free mensili. Ospita anche modelli abliterated (vedi uncensored).\",\n uncensored: false },\n { id: \"cloudflare\", name: \"Cloudflare Workers AI\", keyUrl: \"https://dash.cloudflare.com/profile/api-tokens\",\n freeModels: [\"@cf/openai/gpt-oss-120b\", \"@cf/moonshotai/kimi-k2.7-code\", \"@cf/deepseek-ai/deepseek-r1-distill-qwen-32b\"],\n note: \"Gratis ogni giorno (quota neuroni). Oltre alla chiave cfat_... serve CLOUDFLARE_ACCOUNT_ID nel .env, perché l'indirizzo contiene l'ID account.\" },\n { id: \"cerebras\", name: \"Cerebras\", keyUrl: \"https://cloud.cerebras.ai/\",\n freeModels: [\"gpt-oss-120b\", \"zai-glm-4.7\", \"gemma-4-31b\"],\n note: \"Gratis e ULTRA-veloce. ATTENZIONE al limite vero: 5 richieste al MINUTO (2400 al giorno) — non è la quota che finisce, è la raffica. Il router ora lo rispetta da solo.\" },\n { id: \"nvidia\", name: \"NVIDIA NIM\", keyUrl: \"https://build.nvidia.com/\",\n freeModels: [\"nvidia/llama-3.1-nemotron-ultra-253b-v1\", \"deepseek-ai/deepseek-v3-0324\", \"qwen/qwen3-coder-480b-a35b\"],\n note: \"Gratis: modelli GROSSI (nemotron, deepseek, qwen-coder, llama-405b). Ottimo per RE.\",\n uncensored: true },\n { id: \"sambanova\", name: \"SambaNova\", keyUrl: \"https://cloud.sambanova.ai/apis\",\n freeModels: [\"Meta-Llama-3.3-70B-Instruct\", \"Qwen2.5-Coder-32B-Instruct\", \"DeepSeek-V3-0324\"],\n note: \"Gratis e veloce: llama-3.1-405B, qwen. Chiave senza prefisso fisso.\" },\n { id: \"hyperbolic\", name: \"Hyperbolic ⚠️ (credito finito)\", keyUrl: \"https://app.hyperbolic.xyz/settings\",\n freeModels: [],\n note: \"⚠️ Verificato 2026-07-24: 402 'insufficient funds'. Fuori dal failover finché non ha credito (HYPERBOLIC_ENABLE=1 per riaccenderlo).\" },\n { id: \"novita\", name: \"Novita AI\", keyUrl: \"https://novita.ai/settings/key-management\",\n freeModels: [\"meta-llama/llama-3.3-70b-instruct\", \"qwen/qwen2.5-coder-32b-instruct\", \"deepseek/deepseek-v3-turbo\"],\n note: \"Crediti gratis: molti modelli open (llama, qwen, deepseek).\" },\n { id: \"chutes\", name: \"Chutes\", keyUrl: \"https://chutes.ai/\",\n freeModels: [\"deepseek-ai/DeepSeek-V3\", \"Qwen/Qwen3-235B-A22B\", \"meta-llama/Llama-3.3-70B-Instruct\"],\n note: \"Modelli open serverless, molti gratuiti (deepseek, qwen).\" },\n { id: \"ollama-cloud\", name: \"Ollama Cloud\", keyUrl: \"https://ollama.com/settings/keys\",\n freeModels: [\"qwen2.5-coder:32b\", \"deepseek-r1:70b\", \"llama3.3:70b\"],\n note: \"I tuoi modelli Ollama nel cloud (qwen, deepseek-coder). Chiave id.segreto.\" },\n { id: \"kaggle\", name: \"Kaggle GPU (abliterated coder)\", keyUrl: \"https://www.kaggle.com/code\",\n freeModels: [\"Qwen2.5-Coder-14B-Instruct-abliterated (notebook 14B)\", \"Qwen3-Coder-30B-A3B MoE abliterated (notebook 30B)\"],\n note: \"La TUA corsia abliterated coder su GPU Kaggle via tunnel ngrok. Priorità nel failover quando il notebook è acceso.\" },\n\n // ★ 2026-07-27 — NUOVI GATEWAY FREE (scoperti dal cacciatore online). OpenAI-compat,\n // piano gratuito senza carta: entrano nel failover quando incolli la chiave fittizia.\n { id: \"kilo\", name: \"Kilo Gateway (free, no account)\", keyUrl: \"https://kilo.ai/docs/gateway\",\n freeModels: [\"kilo-auto/free (router)\", \"minimax/minimax-m2.5:free\", \"stepfun/step-3.5-flash:free\", \"nvidia/nemotron-3-super-120b-a12b:free\"],\n note: \"Gateway OpenAI-compat: i modelli free funzionano SENZA account (basta KILO_KEY=none nel .env). Limite ~200 req/h per IP condiviso. Buon rinforzo al failover.\" },\n { id: \"opencode\", name: \"OpenCode Zen (gateway free)\", keyUrl: \"https://opencode.ai/docs/zen/\",\n freeModels: [\"DeepSeek V4 Flash Free\", \"Laguna S 2.1 Free\", \"Ling-3.0-flash Free\", \"North Mini Code Free\"],\n note: \"Gateway curato di modelli free (OpenAI-compat su opencode.ai/zen/v1). Metti OPENCODE_ZEN_KEY=none nel .env per attivarlo.\" },\n\n // ★ 2026-07-27 — NUOVI PROVIDER (endpoint verificati live). Uncensored dove indicato.\n { id: \"arliai\", name: \"ArliAI (senza filtri / roleplay)\", keyUrl: \"https://www.arliai.com/\",\n freeModels: [\"Mistral-Nemo-12B-ArliAI-RPMax\", \"Llama-3.1-8B abliterated\", \"Qwen2.5-Coder (varie)\"],\n note: \"Specializzato in modelli SENZA FILTRI e roleplay: sul piano le richieste sono illimitate come modelli, con un limite di richieste in parallelo. Piano free disponibile.\",\n uncensored: true },\n { id: \"deepinfra\", name: \"DeepInfra (catalogo enorme open)\", keyUrl: \"https://deepinfra.com/dash/api_keys\",\n freeModels: [\"meta-llama/Llama-3.3-70B-Instruct\", \"Qwen/Qwen2.5-Coder-32B-Instruct\", \"deepseek-ai/DeepSeek-V3\", \"mistralai/Mixtral\"],\n note: \"Tantissimi modelli open a prezzo bassissimo, con credito iniziale gratuito. OpenAI-compat, ottimo come rincalzo.\",\n uncensored: false },\n { id: \"featherless\", name: \"Featherless (ogni modello HuggingFace)\", keyUrl: \"https://featherless.ai/\",\n freeModels: [\"qualsiasi modello HF, inclusi abliterated/uncensored\"],\n note: \"Esegue quasi OGNI modello di HuggingFace (compresi gli abliterated senza filtri) ad abbonamento fisso. Chiave rc_...\",\n uncensored: true },\n { id: \"cloudrift\", name: \"Cloudrift (gateway inference)\", keyUrl: \"https://www.cloudrift.ai/\",\n freeModels: [\"deepseek\", \"qwen\", \"llama (secondo catalogo live)\"],\n note: \"Gateway inference OpenAI-compat con credito di prova gratuito. Buon rincalzo al failover.\",\n uncensored: true },\n\n // ★ 2026-07-27 — SUGGERIMENTI (NON nel failover: endpoint non OpenAI-compat standard\n // o richiedono account). Il cacciatore li propone, ma Antigravity non li usa attivamente.\n { id: \"cohere\", name: \"Cohere (free trial, no carta)\", keyUrl: \"https://dashboard.cohere.com/api-keys\",\n freeModels: [\"command-a-03-2025\", \"command-r-plus-08-2024\", \"command-r7b-12-2024\"],\n note: \"Free 'Trial' 1000 chiamate/mese, no carta, ma endpoint /v2 NON OpenAI-compat -> non usato dal failover. Utile solo chiamandolo a mano.\" },\n { id: \"vercel\", name: \"Vercel AI Gateway (aggregatore)\", keyUrl: \"https://vercel.com/docs/ai-gateway\",\n freeModels: [\"(router a molti provider, $5/mese di credito)\"],\n note: \"Aggregatore che instrada a tanti provider. Richiede account Vercel + chiave -> non nel failover automatico, ma buono come fonte di modelli.\" },\n // ★ 2026-07-24 — RIMOSSE tre voci che facevano perdere tempo all'utente:\n // • \"nous\" → creare la chiave sul portale Nous CHIEDE SOLDI, non è gratis;\n // • \"hf-uncensored\" e \"ollama-uncensored\" → non sono provider con API gratuita:\n // scaricano modelli da far girare IN LOCALE, e su una Quadro T1000 (4 GB)\n // non gira niente di utile. Proporli era una presa in giro.\n // Il cacciatore deve proporre SOLO API gratuite raggiungibili dalla rete.\n];\n\n// ★ 2026-07-30 — Il cacciatore ora propone anche TUTTI i provider del catalogo\n// preconfigurato (scheda \"Provider\" e scheda \"Senza filtri\"). Ogni proposta ha\n// già id/endpoint/variabile .env nel registro: premendo \"Incolla qui\" e\n// incollando la chiave, il salvataggio funziona davvero e il provider entra nel\n// failover senza toccare il codice.\nfor (const c of CATALOG) {\n if (FREE_PROVIDERS.some(f => f.id === c.id)) continue;\n FREE_PROVIDERS.push({\n id: c.id,\n name: c.label + (c.verified === false ? \" (da verificare)\" : \"\"),\n keyUrl: c.signup || \"\",\n freeModels: c.freeModels || [],\n note: c.note || \"\",\n uncensored: !!c.uncensored,\n // niente piano gratuito vero: resta proposto ma marcato, così sai che è\n // credito di prova / a consumo prima di perderci tempo.\n paid: !c.free\n });\n}\n\n/** Vista pubblica della lista curata (niente chiavi/segreti).\n * type: undefined|\"all\" → tutto · \"uncensored\" → solo senza filtri. */\nfunction freeProviders(type) {\n let list = FREE_PROVIDERS;\n if (type === \"uncensored\") list = list.filter(p => p.uncensored);\n return list.map(p => Object.assign({}, p));\n}\n\nmodule.exports = { PROVIDERS, detectProvider, publicView, byId, byEnv, all, freeSuggestions, FREE_PROVIDERS, freeProviders };\n"}}, "src/mobile-page.html": {"b004d67566fd93489bf4cb3fbc1b4d93bfed8e95": {"t": 1786068563.5188546, "n": 128482, "h": "66697b5c4c5dd32d", "c": "<!doctype html>\n<html lang=\"it\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover\">\n<meta name=\"color-scheme\" content=\"light dark\">\n<title>Antigravity</title>\n<!-- Rileva l'ambiente PRIMA del CSS: iPhone → cornice-telefono confinata (430×932);\n tutto il resto (pannello VS Code, browser desktop) → layout FLUIDO che riempie\n lo spazio e si ri-adatta quando allarghi/stringi. Fallback: fluido. -->\n<script>(function(){try{var ua=navigator.userAgent||\"\";var isPhone=/iPhone|iPod/.test(ua)&&!/Electron/i.test(ua);document.documentElement.classList.add(isPhone?\"phone\":\"fluid\");}catch(e){document.documentElement.classList.add(\"fluid\");}})();</script>\n<script>\n/* ★ 2026-07-25 — Tema giorno/notte automatico (SOLO telefono).\n Default: scuro come prima. Segue il \"Tema chiaro/scuro\" impostato su iPhone.\n L'utente può forzarlo col pulsante 🌓 (scelta salvata nel telefono). */\n(function(){\n try{\n var ua=navigator.userAgent||\"\";\n if(!(/iPhone|iPod/.test(ua)&&!/Electron/i.test(ua))) return; // PC/VS Code: resta scuro\n var mq=window.matchMedia&&window.matchMedia(\"(prefers-color-scheme: light)\");\n function applyTheme(mode){\n var t=mode===\"auto\"?(mq&&mq.matches?\"light\":\"dark\"):mode;\n document.documentElement.setAttribute(\"data-theme\",t);\n }\n window.__agThemeMode=localStorage.getItem(\"ag-theme\")||\"auto\";\n applyTheme(window.__agThemeMode);\n window.__agApplyTheme=applyTheme;\n if(mq&&mq.addEventListener) mq.addEventListener(\"change\",function(){\n if((localStorage.getItem(\"ag-theme\")||\"auto\")===\"auto\") applyTheme(\"auto\");\n });\n }catch(e){}\n})();\n</script>\n<style>\n /* Tema scuro sempre, sia nel pannello VS Code sia sul telefono. */\n :root{\n color-scheme:dark;\n --bg:#262624; --bg2:#1f1e1d; --panel:#2a2927; --txt:#ececec; --dim:#a8a49c;\n --line:#3a3834; --line2:#4a473f; --bd:#3a3834; --accent:#d97757; --accent-ink:#ffffff;\n --user-bg:#34322e; --code-bg:#1c1b19; --code-ink:#ececec;\n --ok:#48bb78; --warn:#d69e2e; --err:#e05252;\n --radius:16px; --max:760px;\n --font:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif;\n --mono:ui-monospace,\"SF Mono\",Menlo,Consolas,\"Liberation Mono\",monospace;\n }\n *{box-sizing:border-box}\n /* ★ 2026-07-19 — DUE interfacce diverse (richiesta utente: \"pc una cosa,\n telefono un'altra\").\n • TELEFONO (schermo stretto): il \"blocco esterno\" è cappato a 430×932 (le\n misure logiche dell'iPhone 15 Pro Max), riempie lo schermo, cornice-telefono.\n • PC / VS Code largo (≥ 760px): si ALLARGA a un layout desktop comodo\n (fino a 1100px, tutta l'altezza), NON una cornice-telefono stretta.\n In entrambi i casi overflow:hidden sul body → scorre SOLO la chat (.scroll). */\n html{margin:0;height:100%;background:var(--bg2);display:flex;justify-content:center;align-items:center}\n /* DEFAULT = FLUIDO: riempie il contenitore (pannello VS Code o finestra browser) e\n si ri-adatta a QUALSIASI larghezza/altezza quando lo allarghi o lo stringi.\n Il testo resta leggibile perché la colonna .thread ha un suo max-width. */\n body{background:var(--bg);color:var(--txt);font:16px/1.6 var(--font);display:flex;flex-direction:column;\n width:100%;max-width:none;height:100dvh;max-height:none;overflow:hidden;margin:0;\n -webkit-font-smoothing:antialiased}\n /* TELEFONO (iPhone rilevato via UA): confinato alle misure logiche dell'iPhone 15\n Pro Max (430×932), cornice centrata. Sul dispositivo reale coincide con lo schermo. */\n html.phone{background:#131211}\n html.phone body{max-width:430px;max-height:932px;box-shadow:0 0 40px rgba(0,0,0,.4)}\n /* ★ 2026-07-25 — TEMA CHIARO (solo iPhone): si ribalta quando il telefono è in\n \"Tema chiaro\" o l'utente preme 🌓. Il PC/VS Code resta scuro (default :root). */\n html.phone[data-theme=\"light\"]{\n color-scheme:light;\n /* ★ 2026-07-25 — contrasto giorno alzato (richiesta utente): testo secondario piu'\n scuro, bordi e pannelli piu' distinti dal bianco. Arancio del brand invariato. */\n --bg:#ffffff; --bg2:#eae7e0; --panel:#ffffff; --txt:#14130f; --dim:#54514a;\n --line:#d5d2c9; --line2:#c1bdb2; --bd:#d5d2c9; --accent:#d97757; --accent-ink:#ffffff;\n --user-bg:#e7eaf0; --code-bg:#efeee7; --code-ink:#14130f;\n --ok:#2a8f4e; --warn:#a6790a; --err:#b53226;\n }\n html.phone[data-theme=\"light\"]{background:var(--bg2)}\n /* il pulsante tema si vede solo sul telefono */\n html:not(.phone) #themeBtn{display:none}\n\n /* Header */\n /* I bottoni dell'header non si comprimono mai: a restringersi è solo il titolo. */\n header{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--line);background:var(--panel);position:sticky;top:0;z-index:5}\n .brand{display:flex;align-items:center;gap:8px;font-weight:600;font-size:15px;min-width:0;overflow:hidden}\n .brand .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .logo{flex:0 0 auto;width:24px;height:24px;border-radius:7px;background:var(--accent);color:#fff;display:grid;place-items:center;font-size:13px;font-weight:700}\n .brand small{color:var(--dim);font-weight:400;font-size:12px}\n .hspace{flex:1 1 auto;min-width:0}\n .hstat{flex:0 0 auto;display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)}\n /* barra limiti Claude: verde → arancio oltre il 75% → rosso a limite raggiunto */\n .lim{padding:2px 8px;border-radius:999px;border:1px solid var(--bd);cursor:default}\n .lim.warn{color:#c47f00;border-color:#c47f0055;background:#c47f0012}\n .lim.bad{color:#c0392b;border-color:#c0392b55;background:#c0392b12}\n .dot{flex:0 0 auto;width:8px;height:8px;border-radius:50%;background:var(--err)}\n .dot.on{background:var(--ok)}\n .hbtn{flex:0 0 auto;background:transparent;border:1px solid var(--line2);color:var(--dim);width:34px;height:34px;border-radius:9px;font-size:16px;cursor:pointer;display:grid;place-items:center}\n .hbtn:hover{color:var(--txt);background:var(--bg2)}\n .hbtns{flex:0 0 auto;display:flex;align-items:center;gap:8px}\n /* Terza riga: comandi GPU Kaggle (telefono). Pulsanti in sequenza, comprensibili. */\n .kbar{display:flex;align-items:center;gap:6px;padding:6px 10px;border-bottom:1px solid var(--line);background:var(--bg);flex-wrap:wrap}\n .kbtn{flex:1 1 0;min-width:64px;border:1px solid var(--line2);background:var(--bg2);color:var(--txt);border-radius:9px;padding:8px 6px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}\n .kbtn:hover{filter:brightness(1.06)}\n .kbtn:active{transform:translateY(1px)}\n .kbtn.busy{opacity:.6;pointer-events:none}\n .kstat{flex:0 0 100%;font-size:11px;color:var(--dim);min-height:14px;padding-top:2px}\n @media (max-width:520px){ .kbtn{font-size:11px;padding:9px 4px} }\n /* versione della pagina: piccola, discreta, così si vede quando carica la nuova */\n .ver{flex:0 0 auto;font-size:10px;color:var(--dim);opacity:.65;font-family:var(--mono);white-space:nowrap}\n /* Pannello stretto: si sacrifica il testo, mai i comandi. */\n @media (max-width:520px){ .brand small{display:none} }\n @media (max-width:400px){ .hstat #stat{display:none} }\n @media (max-width:330px){ .brand .name{display:none} }\n /* ★ 2026-07-21 — HEADER CHE SCONFINA (foto utente): con ☰ + 5 comandi (🧅🔑🧹⤓✚)\n la barra usciva a destra sul pannello VS Code stretto e sul telefono. Invece di\n rimpicciolire le icone (diventavano illeggibili), quando lo spazio è poco la\n barra va a CAPO: riga 1 = ☰ + titolo + stato, riga 2 = i comandi allineati a\n destra. Così i bottoni restano grandi e non sconfinano MAI. */\n @media (max-width:560px){\n header{flex-wrap:wrap;gap:6px 8px;padding:8px 10px}\n .hbtns{flex:1 1 100%;justify-content:flex-end} /* i comandi su tutta la seconda riga */\n header .ver{display:none}\n }\n @media (max-width:400px){\n .brand small,.brand .name{display:none} /* riga 1 stretta: resta il logo ▲ + stato */\n }\n\n /* Striscia di stato SEMPRE visibile (sta FUORI dall'area che scrolla): a sinistra\n il modello che sta lavorando ORA (aggiornato dal routing), a destra le barre dei\n token cloud usati per provider. Su iPhone non serve scrollare per vederli. */\n #statusStrip{flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:5px 14px;border-bottom:1px solid var(--line);background:var(--bg2)}\n #curModel{flex:0 0 auto;max-width:46%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12.5px;font-weight:600;color:var(--txt)}\n #curModel.idle{color:var(--dim);font-weight:500}\n .provBars{flex:1 1 auto;display:flex;gap:9px;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none}\n .provBars::-webkit-scrollbar{display:none}\n .pbar{flex:0 0 auto;min-width:60px}\n .pbar .pl{display:flex;justify-content:space-between;gap:6px;font-size:10px;color:var(--dim);margin-bottom:2px;line-height:1.2}\n .pbar .pl b{color:var(--txt);font-weight:600}\n .pbar .pt{height:5px;border-radius:3px;background:var(--line);overflow:hidden}\n .pbar .pf{height:100%;width:0;background:var(--accent);border-radius:3px;transition:width .3s}\n .pbar.hot .pf{background:var(--warn)}\n\n /* Chat scroll area */\n .scroll{position:relative;flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;scroll-behavior:smooth}\n .thread{max-width:var(--max);margin:0 auto;padding:22px 16px 12px;display:flex;flex-direction:column;gap:22px}\n\n /* Empty state */\n .empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--dim);gap:14px;padding:30px 20px}\n .empty .big{width:52px;height:52px;border-radius:15px;background:var(--accent);color:#fff;display:grid;place-items:center;font-size:26px}\n .empty h2{color:var(--txt);font-size:19px;margin:0}\n .examples{display:flex;flex-direction:column;gap:8px;width:100%;max-width:380px;margin-top:6px}\n .ex{text-align:left;border:1px solid var(--line);border-radius:12px;padding:11px 13px;cursor:pointer;background:var(--panel);font-size:14px;color:var(--txt)}\n .ex:hover{border-color:var(--accent)}\n .ex b{font-weight:600}\n\n /* Messages */\n .row{display:flex;flex-direction:column;gap:6px}\n .row.user{align-items:flex-end}\n .role{font-size:12px;color:var(--dim);font-weight:500;padding:0 2px}\n .bubble{max-width:100%;word-wrap:break-word;overflow-wrap:anywhere}\n .row.user .bubble{background:var(--user-bg);padding:11px 15px;border-radius:18px;border-bottom-right-radius:5px;white-space:pre-wrap}\n .row.ai .bubble{width:100%}\n /* Markdown */\n .md>*:first-child{margin-top:0}\n .md>*:last-child{margin-bottom:0}\n .md p{margin:0 0 12px}\n .md h1,.md h2,.md h3,.md h4{margin:18px 0 8px;line-height:1.3;font-weight:650}\n .md h1{font-size:22px}.md h2{font-size:19px}.md h3{font-size:17px}.md h4{font-size:15px}\n .md ul,.md ol{margin:0 0 12px;padding-left:22px}\n .md li{margin:4px 0}\n .md a{color:var(--accent);text-decoration:underline;text-underline-offset:2px}\n .md img.genimg{max-width:100%;height:auto;border-radius:12px;border:1px solid var(--line);margin:6px 0;cursor:zoom-in;display:block}\n .md strong{font-weight:650}\n .md blockquote{margin:0 0 12px;padding:2px 14px;border-left:3px solid var(--line2);color:var(--dim)}\n .md hr{border:none;border-top:1px solid var(--line);margin:16px 0}\n .md code.in{font-family:var(--mono);font-size:.88em;background:var(--code-bg);padding:2px 6px;border-radius:6px}\n .md table{border-collapse:collapse;margin:0 0 12px;display:block;overflow-x:auto;max-width:100%}\n .md th,.md td{border:1px solid var(--line2);padding:6px 10px;text-align:left;font-size:14px}\n .md th{background:var(--bg2)}\n /* Code block */\n .cb{margin:12px 0;border:1px solid var(--line);border-radius:12px;overflow:hidden;background:var(--code-bg)}\n .cb .cbh{display:flex;justify-content:space-between;align-items:center;padding:6px 12px;background:var(--bg2);border-bottom:1px solid var(--line);font-size:12px;color:var(--dim)}\n .cb .cbcopy{background:transparent;border:0;color:var(--dim);cursor:pointer;font-size:12px;padding:3px 8px;border-radius:6px}\n .cb .cbcopy:hover{color:var(--txt);background:var(--line)}\n .cb pre{margin:0;padding:12px 14px;overflow-x:auto}\n .cb code{font-family:var(--mono);font-size:13px;line-height:1.5;color:var(--code-ink);white-space:pre}\n .caret::after{content:\"\";display:inline-block;width:8px;height:1.05em;vertical-align:-2px;margin-left:2px;background:var(--accent);border-radius:1px;animation:blink 1s steps(2) infinite}\n @keyframes blink{50%{opacity:0}}\n /* Indicatore \"sto pensando\" con parola animata (tipo Claude) */\n .thinking{display:flex;align-items:center;gap:9px;font-size:15px;padding:2px 0}\n .thinking .sw{background:linear-gradient(90deg,var(--dim) 20%,var(--txt) 50%,var(--dim) 80%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:shimmer 1.8s linear infinite;font-weight:500}\n .thinking .el{color:var(--dim);font-size:13px}\n @keyframes shimmer{to{background-position:-200% 0}}\n\n /* Reasoning block (ragionamento) */\n .think{border:1px solid var(--line);border-radius:11px;background:var(--bg2);margin:0 0 10px;overflow:hidden}\n .think .th{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px;color:var(--dim)}\n .think .th:hover{color:var(--txt)}\n .think .tg{margin-left:auto;font-size:12px;opacity:.8}\n .think .tc{border-top:1px solid var(--line);padding:10px 12px;font-size:13.5px;line-height:1.55;color:var(--dim);white-space:pre-wrap;max-height:280px;overflow:auto;display:none}\n .think.open .tc{display:block}\n\n /* Message actions */\n .acts{display:flex;gap:2px;padding-top:2px}\n .act{background:transparent;border:0;color:var(--dim);cursor:pointer;font-size:12px;padding:5px 8px;border-radius:7px;display:inline-flex;align-items:center;gap:5px}\n .act:hover{color:var(--txt);background:var(--bg2)}\n\n /* Pannello Piano (todo) */\n .plan{border:1px solid var(--line);border-radius:11px;background:var(--panel);padding:11px 13px;margin:2px 0}\n .plan .ph{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.6px;margin-bottom:8px;font-weight:600}\n .plan .pi{display:flex;gap:9px;align-items:flex-start;padding:3px 0;font-size:14px}\n .plan .pi .pk{width:16px;flex:0 0 auto}\n .plan .pi.done .pt{color:var(--dim);text-decoration:line-through}\n .plan .pi.cur .pt{font-weight:600}\n\n /* Tool card */\n .tool{border:1px solid var(--line);border-radius:11px;background:var(--panel);overflow:hidden;margin:2px 0}\n .tool .th{display:flex;align-items:center;gap:9px;padding:9px 12px;cursor:pointer;font-size:13px}\n .tool .th:hover{background:var(--bg2)}\n .tool .tic{width:18px;text-align:center}\n .tool .tt{flex:1;font-family:var(--mono);font-size:12px;color:var(--txt);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .tool .ts{font-size:11px;color:var(--dim)}\n .tool .tb{border-top:1px solid var(--line);padding:10px 12px;font-family:var(--mono);font-size:12px;white-space:pre-wrap;max-height:240px;overflow:auto;color:var(--dim);display:none}\n .tool.open .tb{display:block}\n .spin{display:inline-block;width:11px;height:11px;border:2px solid var(--line2);border-top-color:var(--accent);border-radius:50%;animation:rot .7s linear infinite}\n @keyframes rot{to{transform:rotate(360deg)}}\n\n /* Approval */\n .appr{border:1px solid var(--warn);border-radius:12px;background:color-mix(in srgb,var(--warn) 10%,transparent);padding:12px 14px;margin:2px 0}\n .appr .at{font-weight:600;color:var(--warn);margin-bottom:5px}\n .appr .aexpl{font-size:14px;line-height:1.5;color:var(--txt);white-space:pre-wrap;margin-bottom:9px}\n .appr .ad{font-family:var(--mono);font-size:12px;white-space:pre-wrap;background:var(--code-bg);border-radius:8px;padding:9px;margin-bottom:10px;max-height:160px;overflow:auto}\n .appr .arow{display:flex;gap:9px}\n\n /* Buttons */\n .btn{border:1px solid var(--line2);background:var(--panel);color:var(--txt);padding:8px 16px;border-radius:9px;cursor:pointer;font-size:14px;font-weight:500}\n .btn.pri{background:var(--accent);border-color:var(--accent);color:var(--accent-ink);font-weight:600}\n .btn:hover{filter:brightness(1.05)}\n\n /* Scroll-to-bottom FAB */\n .fab{position:absolute;left:50%;transform:translateX(-50%);bottom:8px;background:var(--panel);border:1px solid var(--line2);color:var(--txt);width:38px;height:38px;border-radius:50%;box-shadow:0 4px 14px rgba(0,0,0,.14);cursor:pointer;font-size:17px;display:none;place-items:center;z-index:4}\n .fab.show{display:grid}\n\n /* Composer */\n .composer{border-top:1px solid var(--line);background:var(--panel);padding:10px 14px calc(10px + env(safe-area-inset-bottom))}\n .cwrap{max-width:var(--max);margin:0 auto}\n /* Va a capo invece di scorrere fuori vista: nessun comando sparisce quando il\n pannello si stringe (prima era overflow-x:auto con la barra nascosta). */\n .opts{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:9px}\n .chip{flex:0 0 auto;border:1px solid var(--line2);background:var(--bg2);color:var(--dim);border-radius:20px;padding:6px 12px;font-size:13px;cursor:pointer}\n .chip.on{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}\n select.sel{flex:1 1 auto;min-width:104px;max-width:190px;background:var(--bg2);color:var(--txt);border:1px solid var(--line2);border-radius:20px;padding:6px 10px;font-size:13px}\n .inputbar{display:flex;align-items:flex-end;gap:9px;background:var(--bg2);border:1px solid var(--line2);border-radius:24px;padding:7px 8px 7px 16px}\n .inputbar:focus-within{border-color:var(--accent)}\n textarea{flex:1;background:transparent;border:0;color:var(--txt);font:16px/1.5 var(--font);resize:none;max-height:180px;outline:none;padding:6px 0}\n .send{width:40px;height:40px;flex:0 0 auto;border:0;border-radius:50%;background:var(--accent);color:#fff;font-size:17px;cursor:pointer;display:grid;place-items:center}\n .send:disabled{opacity:.4}\n .send.stop{background:var(--err)}\n .mic{width:40px;height:40px;flex:0 0 auto;border:1px solid var(--line2);border-radius:50%;background:var(--bg2);color:var(--dim);font-size:16px;cursor:pointer;display:grid;place-items:center}\n .mic.on{background:var(--err);border-color:var(--err);color:#fff;animation:micpulse 1.2s infinite}\n @keyframes micpulse{50%{opacity:.55}}\n .mic.hidden{display:none}\n .hint{text-align:center;color:var(--dim);font-size:11px;margin-top:7px}\n\n /* Drawer chat salvate */\n .drawer-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);opacity:0;pointer-events:none;transition:opacity .2s;z-index:20}\n .drawer-overlay.open{opacity:1;pointer-events:auto}\n .drawer{position:fixed;top:0;left:0;bottom:0;width:82%;max-width:320px;background:var(--panel);border-right:1px solid var(--line);transform:translateX(-100%);transition:transform .22s ease;z-index:21;display:flex;flex-direction:column}\n .drawer.open{transform:none}\n /* Cassetto sul lato DESTRO (Chiavi API): la classe .open deve poter vincere,\n perciò lo stato chiuso sta qui in CSS e NON come stile inline (che batterebbe .open). */\n .drawer.right{left:auto;right:0;border-right:none;border-left:1px solid var(--line);transform:translateX(100%)}\n .drawer.right.open{transform:none}\n .drawer-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:14px 14px 10px;border-bottom:1px solid var(--line);font-weight:600}\n .drawer-head .btn{padding:7px 12px;font-size:13px}\n .drawer-list{flex:1;overflow-y:auto;padding:8px}\n /* FEATURE 1 — riga provider col suo indicatore vivo/morto/quota + VERIFICA */\n .kp-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:6px 4px;font-size:13px;border:1px solid var(--line);border-radius:10px;margin:6px 0}\n .kp-dot{font-size:15px;line-height:1}\n .kp-name{flex:1;min-width:90px}\n .kp-check,.kp-paste{flex:0 0 auto}\n .kp-free{border:1px solid var(--line);border-radius:10px;padding:9px 11px;margin:6px 0}\n .btn.sm{padding:4px 8px;font-size:12px}\n .conv{display:flex;align-items:center;gap:8px;padding:10px 11px;border-radius:10px;cursor:pointer;color:var(--txt)}\n .conv:hover{background:var(--bg2)}\n .conv.active{background:var(--bg2);border:1px solid var(--line2)}\n .conv .ct{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px}\n .conv .cd{opacity:0;border:0;background:transparent;color:var(--dim);cursor:pointer;font-size:15px;padding:2px 6px;border-radius:6px}\n .conv:hover .cd{opacity:1}\n .conv .cd:hover{color:var(--err);background:var(--line)}\n</style>\n</head>\n<body>\n<header>\n <button class=\"hbtn\" id=\"menuBtn\" title=\"Chat salvate\">☰</button>\n <span class=\"brand\"><span class=\"logo\">▲</span><span class=\"name\">Antigravity</span></span>\n <span class=\"hspace\"></span>\n <span class=\"hstat\"><span class=\"dot\" id=\"dot\"></span><span id=\"stat\">connessione…</span></span>\n <span class=\"hstat\" id=\"usage\" style=\"display:none\" title=\"Uso cloud del mese\"></span>\n <span class=\"hstat lim\" id=\"climit\" style=\"display:none\" title=\"Limiti di utilizzo di Claude\"></span>\n <span class=\"hbtns\">\n <button class=\"hbtn\" id=\"torBtn\" title=\"Navigatore Tor (rete .onion)\">🧅</button>\n <button class=\"hbtn\" id=\"musicBtn\" title=\"Musica (stile Spotify): cerca brano, testo, anteprima\" style=\"display:inline-flex;align-items:center;justify-content:center\"><svg width=\"20\" height=\"20\" viewBox=\"0 0 168 168\" aria-hidden=\"true\"><path fill=\"#1ED760\" d=\"M83.996.277C37.747.277.253 37.77.253 84.019c0 46.251 37.494 83.742 83.743 83.742 46.254 0 83.744-37.491 83.744-83.742 0-46.246-37.49-83.738-83.745-83.738l.001-.004zm38.404 120.78a5.217 5.217 0 01-7.18 1.73c-19.662-12.01-44.414-14.73-73.564-8.07a5.222 5.222 0 01-6.249-3.93 5.213 5.213 0 013.926-6.25c31.9-7.291 59.263-4.15 81.337 9.34 2.46 1.51 3.24 4.72 1.73 7.18zm10.25-22.805c-1.89 3.075-5.91 4.045-8.98 2.155-22.51-13.839-56.823-17.846-83.448-9.764-3.453 1.043-7.1-.903-8.148-4.35a6.538 6.538 0 014.354-8.143c30.413-9.228 68.222-4.758 94.072 11.127 3.07 1.89 4.04 5.91 2.15 8.98v-.005zm.88-23.744c-26.99-16.031-71.52-17.505-97.289-9.684-4.138 1.255-8.514-1.081-9.768-5.219a7.835 7.835 0 015.221-9.771c29.581-8.98 78.756-7.245 109.83 11.202a7.823 7.823 0 012.74 10.733c-2.2 3.722-7.02 4.949-10.73 2.739z\"/></svg></button>\n <button class=\"hbtn\" id=\"keysBtn\" title=\"Chiavi API dei provider\">🔑</button>\n <button class=\"hbtn\" id=\"clearBtn\" title=\"Pulisci lo schermo (la memoria resta)\">🧹</button>\n <button class=\"hbtn\" id=\"exportBtn\" title=\"Esporta la chat in Markdown\">⤓</button>\n <button class=\"hbtn\" id=\"newChat\" title=\"Nuova chat\">✚</button>\n <button class=\"hbtn\" id=\"themeBtn\" title=\"Tema: automatico (segui il telefono)\">🌓</button>\n </span>\n</header>\n<!-- Striscia di stato sempre visibile su iPhone: modello attivo (routing) + token per provider -->\n<div id=\"statusStrip\">\n <span id=\"curModel\" class=\"idle\" title=\"Modello che sta lavorando ora (routing automatico)\">🤖 —</span>\n <div class=\"provBars\" id=\"provBars\" title=\"Token cloud usati questo mese, per provider\"></div>\n <!-- VERSIONE della pagina: si aggiorna da sola quando il file cambia, così\n vedi subito se hai caricato la versione nuova. -->\n <span class=\"ver\" id=\"pageVer\" title=\"Versione della pagina caricata\">v{{VER}}</span>\n</div>\n<!-- Terza riga: comandi GPU Kaggle (accendi/spegni i modelli abliterated su cloud) -->\n<div id=\"kaggleBar\" class=\"kbar\">\n <button class=\"kbtn\" id=\"kgUp14\" title=\"Accendi il notebook Kaggle 14B (Qwen2.5-Coder) su GPU\">🟢 Kaggle 14B</button>\n <button class=\"kbtn\" id=\"kgUp30\" title=\"Accendi il notebook Kaggle 30B-MoE (Qwen3-Coder) su GPU\">🟣 Kaggle 30B</button>\n <button class=\"kbtn\" id=\"kgStatus\" title=\"Controlla se il notebook Kaggle è acceso\">🔵 Stato</button>\n <button class=\"kbtn\" id=\"kgOff\" title=\"Spegni il notebook Kaggle (Kaggle non ha API di stop: apre la pagina)\">🔴 Spegni</button>\n <span class=\"kstat\" id=\"kgQuota\" title=\"Ore GPU Kaggle usate/totali della settimana e quando si azzerano\">⏱️ …</span>\n <span class=\"kstat\" id=\"kgStat\"></span>\n</div>\n\n<div class=\"drawer-overlay\" id=\"overlay\"></div>\n<aside class=\"drawer\" id=\"drawer\">\n <div class=\"drawer-head\">\n <span>Chat</span>\n <button class=\"btn pri\" id=\"drawerNew\">+ Nuova</button>\n </div>\n <div class=\"drawer-list\" id=\"convList\"></div>\n</aside>\n\n<div class=\"scroll\" id=\"scroll\">\n <div class=\"thread\" id=\"thread\">\n <div class=\"empty\" id=\"empty\">\n <div class=\"big\">▲</div>\n <div><h2>Come posso aiutarti?</h2><div>Apro, leggo e analizzo qualsiasi file, codice o binario — senza filtri.</div></div>\n <div class=\"examples\">\n <div class=\"ex\" data-ex=\"Analizza il file in C:/Users/infoa/Downloads/… e dimmi cos'è e cosa fa\">💿 <b>Analizza un binario</b></div>\n <div class=\"ex\" data-ex=\"Studia il progetto in questa cartella e spiegami come funziona\">📂 <b>Studia un progetto</b></div>\n <div class=\"ex\" data-ex=\"Trova i bug in questo file e proponimi le correzioni\">🐛 <b>Trova i bug</b></div>\n </div>\n </div>\n </div>\n <button class=\"fab\" id=\"fab\" title=\"Vai in fondo\">↓</button>\n</div>\n\n<div class=\"composer\">\n <div class=\"cwrap\">\n <div class=\"opts\">\n <button class=\"chip on\" data-mode=\"auto\">Auto</button>\n <button class=\"chip\" data-mode=\"agent\">Agente</button>\n <button class=\"chip\" data-mode=\"reverse\">Reverse</button>\n <button class=\"chip\" data-mode=\"coding\">Codice</button>\n <select class=\"sel\" id=\"provider\"><option value=\"local\">💻 Locale</option><option value=\"cloud\">☁️ Cloud</option><option value=\"comfy\">🎨 ComfyUI</option><option value=\"ghidra\">🐉 Ghidra RE</option><option value=\"zw3d\">🔧 ZW3D</option><option value=\"maintenance\">🛠️ Manutenzione</option></select>\n <select class=\"sel\" id=\"llmProvider\" title=\"Provider: chi ospita il modello\"><option value=\"auto\">Provider: tutti</option></select>\n <select class=\"sel\" id=\"model\"><option value=\"auto\">Modello: Auto</option></select>\n <select class=\"sel\" id=\"policy\"><option value=\"ask-writes\">Chiedi</option><option value=\"read-only\">Sola lettura</option><option value=\"auto-allow\">Autonomo</option></select>\n </div>\n <div id=\"attachRow\" style=\"display:none;flex-wrap:wrap;gap:6px;padding:4px 2px\"></div>\n <input type=\"file\" id=\"fileInput\" style=\"display:none\">\n <div class=\"inputbar\">\n <button class=\"mic\" id=\"attachBtn\" title=\"Allega un file (qualsiasi tipo)\">📎</button>\n <textarea id=\"input\" rows=\"1\" placeholder=\"Scrivi un messaggio…\"></textarea>\n <button class=\"mic\" id=\"mic\" title=\"Detta col microfono\">🎤</button>\n <button class=\"send\" id=\"send\" title=\"Invia\">➤</button>\n </div>\n <div class=\"hint\">Invio per inviare · Maiusc+Invio per andare a capo</div>\n </div>\n</div>\n\n<div class=\"drawer-overlay\" id=\"keysOverlay\"></div>\n<aside class=\"drawer right\" id=\"keysDrawer\">\n <div class=\"drawer-head\"><span>🔑 Chiavi API</span><button class=\"btn\" id=\"keysClose\">Chiudi</button></div>\n <div class=\"drawer-list\">\n <div style=\"font-size:13px;color:var(--dim);margin:2px 4px 10px\">Incolla una chiave: riconosco il provider e compilo tutto da solo.</div>\n <textarea id=\"keyInput\" rows=\"2\" placeholder=\"Incolla qui la chiave API…\" style=\"width:100%;box-sizing:border-box;background:var(--bg2);color:var(--txt);border:1px solid var(--line2);border-radius:9px;padding:9px;font-size:13px;font-family:monospace\"></textarea>\n <div id=\"keyDetect\" style=\"font-size:13px;margin:8px 4px;min-height:18px;color:var(--dim)\"></div>\n <select class=\"sel\" id=\"keyProvider\" style=\"width:100%;margin:4px 0;display:none\"></select>\n <button class=\"btn pri\" id=\"keySave\" style=\"width:100%;margin:6px 0 14px\" disabled>Salva chiave</button>\n <div style=\"font-weight:600;font-size:13px;margin:6px 4px\">Configurati</div>\n <div id=\"keysActive\" style=\"margin-bottom:12px\"></div>\n <button class=\"btn\" id=\"statusBtn\" style=\"width:100%;margin:2px 0 10px\">📊 Stato sistema</button>\n <div id=\"statusOut\" style=\"font-size:12px;color:var(--dim);margin:0 4px 12px;white-space:pre-wrap;display:none\"></div>\n <!-- ★ 2026-07-30 — CANTIERE. Sta PRIMA dei pulsanti di manutenzione apposta:\n sono loro (ripara / torna indietro / delega a Hermes) che ti cancellavano il\n lavoro mentre lo stavi costruendo dal telefono. Con il cantiere aperto quei\n file diventano intoccabili, e \"Riprendi\" ricostruisce da solo il punto in cui\n eri quando l'app si è interrotta (niente più «riprendi da dove ti eri fermato»\n raccontato a voce). -->\n <div style=\"font-weight:600;font-size:13px;margin:10px 4px 4px\">🔒 Cantiere (proteggi quello che stai costruendo)</div>\n <div id=\"cantiereStato\" style=\"font-size:12px;color:var(--dim);margin:0 4px 6px;white-space:pre-wrap\">…</div>\n <div id=\"cantiereChiuso\">\n <input class=\"inp\" id=\"cantTitolo\" placeholder=\"Cosa stai costruendo (titolo)\" style=\"width:100%;margin:2px 0\">\n <input class=\"inp\" id=\"cantFiles\" placeholder=\"File da proteggere, separati da virgola\" style=\"width:100%;margin:2px 0\">\n <button class=\"btn pri\" id=\"cantApri\" style=\"width:100%;margin:4px 0 10px\">🔒 Apri cantiere</button>\n </div>\n <div id=\"cantiereAperto\" style=\"display:none\">\n <button class=\"btn pri\" id=\"cantRiprendi\" style=\"width:100%;margin:2px 0\">▶️ Riprendi da dove ti sei interrotto</button>\n <input class=\"inp\" id=\"cantPasso\" placeholder=\"Passo appena fatto…\" style=\"width:100%;margin:4px 0 2px\">\n <input class=\"inp\" id=\"cantProssimo\" placeholder=\"Prossimo passo (è ciò che permette di riprendere)\" style=\"width:100%;margin:2px 0\">\n <button class=\"btn sm\" id=\"cantSegna\" style=\"width:100%;margin:2px 0\">📌 Segna passo</button>\n <button class=\"btn sm\" id=\"cantChiudi\" style=\"width:100%;margin:2px 0 10px;background:var(--danger);color:#fff\">🔓 Chiudi cantiere</button>\n </div>\n\n <!-- ★ 2026-07-30 — PIATTAFORME GPU: gli stessi pulsanti di Kaggle, per tutte\n quelle cablate. Il modello non lo scegli tu: esce dalla VRAM della GPU. -->\n <div style=\"font-weight:600;font-size:13px;margin:10px 4px 4px\">🖥️ Piattaforme GPU</div>\n <div id=\"piattElenco\" style=\"font-size:12px;color:var(--dim);margin:0 4px 6px\">…</div>\n <button class=\"btn sm\" id=\"piattAggiorna\" style=\"width:100%;margin:2px 0 10px\">🔄 Aggiorna piattaforme</button>\n\n <!-- ★ 2026-07-30 — Qui c'erano DUE bottoni, \"Cacciatore di bug (auto-ripara)\" e\n \"Ripara ora\": chiamavano lo stesso identico endpoint con gli stessi parametri\n (/bugHunter/scan?fix=1), cambiavano solo le emoji del testo di risposta. Uno\n solo, che dice quello che fa davvero. -->\n <button class=\"btn\" id=\"bugHunterBtn\" style=\"width:100%;margin:2px 0 6px\">🐞 Controlla e ripara ora</button>\n <button class=\"btn sm\" id=\"rollbackBtn\" style=\"width:100%;margin:2px 0 6px;background:var(--danger);color:#fff\">↩️ Torna indietro (ripristina ultimo backup sano)</button>\n <div id=\"bugHunterOut\" style=\"font-size:12px;color:var(--dim);margin:0 4px 12px;white-space:pre-wrap;display:none\"></div>\n <!-- Coda dei problemi che il cacciatore NON sa riparare da solo: da qui si\n delegano a Hermes, uno alla volta e solo se lo chiedi tu. -->\n <div id=\"ticketBox\" style=\"margin:0 4px 12px;display:none\">\n <div style=\"font-weight:600;font-size:13px;margin:6px 0\">🎫 Da sistemare a mano <span id=\"ticketCount\" style=\"color:var(--dim);font-weight:400\"></span></div>\n <div id=\"ticketList\"></div>\n </div>\n <div style=\"font-weight:600;font-size:13px;margin:6px 4px\">🏹 Cacciatore di taglie</div>\n <div style=\"display:flex;gap:4px;margin:2px 4px 8px\">\n <button class=\"btn sm tab active\" data-tab=\"prov\" style=\"flex:1\">⚡ Provider</button>\n <button class=\"btn sm tab\" data-tab=\"unc\" style=\"flex:1\">🔓 Senza filtri</button>\n <button class=\"btn sm tab\" data-tab=\"gpu\" style=\"flex:1\">🖥️ Piattaforme</button>\n </div>\n\n <!-- SCHEDA 1: Provider gratuiti (curati + caccia live) -->\n <div class=\"huntPanel\" data-panel=\"prov\">\n <div id=\"keysSuggest\"></div>\n <button class=\"btn pri\" id=\"huntBtn\" style=\"width:100%;margin:10px 0 6px\">🔎 Cerca altri provider gratuiti online</button>\n <div id=\"huntOut\" style=\"font-size:12px;color:var(--dim);margin:0 4px 12px\"></div>\n </div>\n\n <!-- SCHEDA 2: Solo provider SENZA FILTRI (abliterated / dolphin / roleplay) -->\n <div class=\"huntPanel\" data-panel=\"unc\" style=\"display:none\">\n <div style=\"font-size:12px;color:var(--dim);margin:0 4px 8px\">Solo provider e modelli SENZA filtri (abliterated, dolphin, roleplay). Caccia tra le fonti aggiornate.</div>\n <button class=\"btn pri\" id=\"huntUncBtn\" style=\"width:100%;margin:4px 0 6px\">🔎 Cerca provider senza filtri</button>\n <div id=\"uncOut\" style=\"font-size:12px;color:var(--dim);margin:0 4px 12px\"></div>\n </div>\n\n <!-- SCHEDA 3: Piattaforme GPU (tipo Kaggle) per ospitare i TUOI modelli -->\n <div class=\"huntPanel\" data-panel=\"gpu\" style=\"display:none\">\n <div style=\"font-size:12px;color:var(--dim);margin:0 4px 8px\">Dove ospitare i TUOI modelli senza filtri (GPU gratis o economiche). Non sono provider API: qui PORTI il modello tuo.</div>\n <button class=\"btn pri\" id=\"gpuBtn\" style=\"width:100%;margin:4px 0 6px\">🚀 Confronta piattaforme GPU</button>\n <div id=\"gpuOut\" style=\"font-size:12px;color:var(--dim);margin:0 4px 12px\"></div>\n </div>\n </div>\n</aside>\n\n<!-- ===== NAVIGATORE TOR (lettore .onion, a schermo intero) ===== -->\n<div id=\"torPanel\" style=\"display:none;position:fixed;inset:0;z-index:50;background:var(--bg);flex-direction:column\">\n <div style=\"display:flex;align-items:center;gap:8px;padding:10px;border-bottom:1px solid var(--line);background:var(--panel)\">\n <button class=\"hbtn\" id=\"torBack\" title=\"Indietro\">‹</button>\n <input id=\"torUrl\" placeholder=\"cerca qualcosa, o incolla un indirizzo .onion…\" style=\"flex:1;background:var(--bg2);color:var(--txt);border:1px solid var(--line2);border-radius:9px;padding:9px 11px;font-size:14px\" autocapitalize=\"off\" autocorrect=\"off\" spellcheck=\"false\">\n <button class=\"btn pri\" id=\"torGo\">Vai</button>\n <button class=\"hbtn\" id=\"torClose\" title=\"Chiudi Tor\">✕</button>\n </div>\n <div id=\"torStatus\" style=\"font-size:12px;color:var(--dim);padding:6px 12px;border-bottom:1px solid var(--line);min-height:16px\"></div>\n <div id=\"torContent\" style=\"flex:1;overflow-y:auto;padding:16px;font-size:15px;line-height:1.5;color:var(--txt);word-break:break-word\"></div>\n</div>\n\n<!-- ★ 2026-07-30 — 🎵 Pannello MUSICA in stile Spotify (nero/verde) -->\n<div id=\"musicPanel\" style=\"display:none;position:fixed;inset:0;z-index:50;background:#121212;color:#fff;flex-direction:column;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif\">\n <style>\n #musicPanel .mrow{transition:background .15s}\n #musicPanel .mrow:hover{background:#1a1a1a}\n #musicPanel .mplay:hover{transform:scale(1.06)}\n #musicPanel ::placeholder{color:#8b8b8b}\n #musicPanel #musicQuery:focus{outline:none}\n </style>\n <div style=\"display:flex;align-items:center;gap:11px;padding:13px 15px;background:linear-gradient(#282828,#121212)\">\n <svg width=\"26\" height=\"26\" viewBox=\"0 0 168 168\" aria-hidden=\"true\"><path fill=\"#1ED760\" d=\"M83.996.277C37.747.277.253 37.77.253 84.019c0 46.251 37.494 83.742 83.743 83.742 46.254 0 83.744-37.491 83.744-83.742 0-46.246-37.49-83.738-83.745-83.738l.001-.004zm38.404 120.78a5.217 5.217 0 01-7.18 1.73c-19.662-12.01-44.414-14.73-73.564-8.07a5.222 5.222 0 01-6.249-3.93 5.213 5.213 0 013.926-6.25c31.9-7.291 59.263-4.15 81.337 9.34 2.46 1.51 3.24 4.72 1.73 7.18zm10.25-22.805c-1.89 3.075-5.91 4.045-8.98 2.155-22.51-13.839-56.823-17.846-83.448-9.764-3.453 1.043-7.1-.903-8.148-4.35a6.538 6.538 0 014.354-8.143c30.413-9.228 68.222-4.758 94.072 11.127 3.07 1.89 4.04 5.91 2.15 8.98v-.005zm.88-23.744c-26.99-16.031-71.52-17.505-97.289-9.684-4.138 1.255-8.514-1.081-9.768-5.219a7.835 7.835 0 015.221-9.771c29.581-8.98 78.756-7.245 109.83 11.202a7.823 7.823 0 012.74 10.733c-2.2 3.722-7.02 4.949-10.73 2.739z\"/></svg>\n <span style=\"font-weight:700;font-size:18px;letter-spacing:-.3px\">Musica</span>\n <button id=\"musicClose\" title=\"Chiudi\" style=\"margin-left:auto;background:none;border:none;color:#b3b3b3;font-size:20px;cursor:pointer;width:36px;height:36px;border-radius:50%\">✕</button>\n </div>\n <div style=\"padding:6px 15px 10px\">\n <div style=\"display:flex;align-items:center;gap:9px;background:#242424;border-radius:500px;padding:10px 15px\">\n <svg width=\"19\" height=\"19\" viewBox=\"0 0 24 24\" fill=\"#b3b3b3\" aria-hidden=\"true\"><path d=\"M10 2a8 8 0 105.293 14.293l4.853 4.854 1.415-1.415-4.854-4.853A8 8 0 0010 2zm0 2a6 6 0 110 12 6 6 0 010-12z\"/></svg>\n <input id=\"musicQuery\" placeholder=\"Cosa vuoi ascoltare?\" style=\"flex:1;background:none;border:none;color:#fff;font-size:15px\" autocapitalize=\"off\" autocorrect=\"off\" spellcheck=\"false\">\n <button id=\"musicGo\" style=\"background:#1ED760;color:#000;border:none;border-radius:500px;padding:8px 18px;font-weight:700;font-size:13px;cursor:pointer\">Cerca</button>\n </div>\n </div>\n <div id=\"musicStatus\" style=\"font-size:12px;color:#b3b3b3;padding:0 16px 6px;min-height:14px\"></div>\n <div id=\"musicContent\" style=\"flex:1;overflow-y:auto;padding:2px 8px 24px\"></div>\n <audio id=\"musicAudio\" style=\"display:none\"></audio>\n</div>\n\n<script>\nconst T = new URLSearchParams(location.search).get(\"t\") || \"\";\n\n/* ---- DIAGNOSTICA: manda gli errori JS al server ----------------------------\n Safari sull'iPhone non si puo' ispezionare dal PC: senza questo, davanti a\n \"si è verificato ripetutamente un errore\" (= il processo della pagina va in\n crash) si puo' solo tirare a indovinare. Qui ogni errore e ogni promise\n rifiutata finiscono nel log del server.\n Volutamente minimale e a prova di errore: se fallisce l'invio, si tace —\n la diagnostica non deve MAI diventare essa stessa la causa del guasto. */\n(function(){\n var inviati = 0; // tetto: niente valanghe se l'errore si ripete\n function segnala(tipo, dati){\n if (inviati++ > 20) return;\n try{\n fetch(\"/clientError?t=\"+encodeURIComponent(T), {\n method:\"POST\",\n headers:{\"Content-Type\":\"application/json\"},\n body: JSON.stringify(Object.assign({\n tipo: tipo,\n quando: new Date().toISOString(),\n ua: navigator.userAgent,\n memoria: (performance && performance.memory) ? performance.memory.usedJSHeapSize : null\n }, dati))\n }).catch(function(){});\n }catch(_){}\n }\n window.addEventListener(\"error\", function(e){\n segnala(\"errore\", { messaggio: String(e.message||\"\"), file: String(e.filename||\"\"),\n riga: e.lineno||0, colonna: e.colno||0,\n stack: e.error && e.error.stack ? String(e.error.stack).slice(0,1500) : \"\" });\n });\n window.addEventListener(\"unhandledrejection\", function(e){\n var r = e.reason;\n segnala(\"promise\", { messaggio: r && r.message ? String(r.message) : String(r),\n stack: r && r.stack ? String(r.stack).slice(0,1500) : \"\" });\n });\n // Traccia anche l'apertura: cosi' so se la pagina e' arrivata a eseguire il JS\n // (pagina bianca CON questa riga = si e' rotta dopo; SENZA = non e' mai partita).\n segnala(\"apertura\", { schermo: (screen.width+\"x\"+screen.height) });\n})();\nconst $ = s => document.querySelector(s);\nconst scrollEl=$(\"#scroll\"), thread=$(\"#thread\"), empty=$(\"#empty\"), input=$(\"#input\"), sendBtn=$(\"#send\"), fab=$(\"#fab\");\nlet mode=\"auto\", running=false, streamEl=null, streamRaw=\"\", curAssist=null, lastUser=null, stick=true;\nconst toolCards={};\n\nfunction api(p,b){ return fetch(p+\"?t=\"+encodeURIComponent(T),{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:JSON.stringify(b||{})}); }\nfunction apiUrl(p){ return p+\"?t=\"+encodeURIComponent(T); }\n\n/* ---- autoscroll intelligente ---- */\nfunction nearBottom(){ return scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 80; }\nfunction scrollDown(force){ if(force||stick){ scrollEl.scrollTop=scrollEl.scrollHeight; } }\nscrollEl.addEventListener(\"scroll\",()=>{ stick=nearBottom(); fab.classList.toggle(\"show\",!stick); });\nfab.addEventListener(\"click\",()=>{ stick=true; scrollDown(true); fab.classList.remove(\"show\"); });\n\n/* ---- markdown ---- */\nfunction esc(s){ return String(s).replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\"); }\nfunction inline(s){\n return esc(s)\n .replace(/`([^`]+)`/g,'<code class=\"in\">$1</code>')\n .replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>')\n .replace(/(^|[^*])\\*([^*]+)\\*/g,'$1<em>$2</em>')\n // Immagini ![alt](url): PRIMA dei link, così \"![…]\" non diventa un link.\n .replace(/!\\[([^\\]]*)\\]\\(([^)\\s]+)\\)/g,'<img class=\"genimg\" src=\"$2\" alt=\"$1\" loading=\"lazy\">')\n .replace(/\\[([^\\]]+)\\]\\(([^)\\s]+)\\)/g,'<a href=\"$2\" target=\"_blank\" rel=\"noreferrer\">$1</a>');\n}\nfunction mdTable(rows){\n const cells=r=>r.replace(/^\\||\\|$/g,\"\").split(\"|\").map(c=>c.trim());\n let h=\"<table><thead><tr>\"+cells(rows[0]).map(c=>\"<th>\"+inline(c)+\"</th>\").join(\"\")+\"</tr></thead><tbody>\";\n for(let i=2;i<rows.length;i++){ h+=\"<tr>\"+cells(rows[i]).map(c=>\"<td>\"+inline(c)+\"</td>\").join(\"\")+\"</tr>\"; }\n return h+\"</tbody></table>\";\n}\nfunction renderMd(src){\n // ★ 2026-07-19 — NORMALIZZA le fine-riga PRIMA di tutto. BUG CRITICO risolto:\n // una riga tipo \"### Titolo\\r\" (fine-riga Windows CRLF, prodotta dai modelli\n // locali) NON veniva riconosciuta come titolo (`.` e `$` non gestiscono il\n // \\r finale) MA il controllo di fine-paragrafo la considerava \"speciale\":\n // l'indice del ciclo non avanzava mai → LOOP INFINITO → 4GB di RAM → Safari\n // uccideva la scheda (pagina bianca \"errore ripetuto\"), pulsanti inclusi. Un\n // solo messaggio così (la risposta sul Tor del 18/07) bastava a bloccare tutta\n // la chat a ogni ricaricamento. Convertendo \\r\\n e \\r in \\n il problema sparisce.\n src=String(src||\"\").replace(/\\r\\n?/g,\"\\n\");\n const parts=src.split(/(```[\\s\\S]*?```)/g);\n let out=\"\";\n for(const part of parts){\n if(part.startsWith(\"```\")){\n const m=part.match(/^```([a-zA-Z0-9+#-]*)\\n?([\\s\\S]*?)```$/);\n const lang=(m&&m[1])||\"codice\", code=(m&&m[2]!=null)?m[2].replace(/\\n$/,\"\"):part.slice(3,-3);\n out+='<div class=\"cb\"><div class=\"cbh\"><span>'+esc(lang)+'</span><button class=\"cbcopy\" data-code=\"'+encodeURIComponent(code)+'\">Copia</button></div><pre><code>'+esc(code)+'</code></pre></div>';\n continue;\n }\n const lines=part.split(\"\\n\"); let i=0;\n while(i<lines.length){\n let ln=lines[i];\n if(/^\\s*$/.test(ln)){ i++; continue; }\n let mm;\n if(mm=ln.match(/^(#{1,4})\\s+(.*)$/)){ const n=mm[1].length; out+=\"<h\"+n+\">\"+inline(mm[2])+\"</h\"+n+\">\"; i++; continue; }\n if(/^\\s*([-*_])\\s*\\1\\s*\\1[\\s*_]*$/.test(ln)){ out+=\"<hr>\"; i++; continue; }\n if(/^\\s*>\\s?/.test(ln)){ let b=[]; while(i<lines.length&&/^\\s*>\\s?/.test(lines[i])){ b.push(lines[i].replace(/^\\s*>\\s?/,\"\")); i++; } out+=\"<blockquote>\"+b.map(inline).join(\"<br>\")+\"</blockquote>\"; continue; }\n if(/^\\s*\\|.*\\|\\s*$/.test(ln)&&i+1<lines.length&&/^\\s*\\|?[\\s:-]+\\|[\\s:|-]*$/.test(lines[i+1])){ let t=[]; while(i<lines.length&&/^\\s*\\|.*\\|\\s*$/.test(lines[i])){ t.push(lines[i].trim()); i++; } out+=mdTable(t); continue; }\n if(/^\\s*[-*+]\\s+/.test(ln)){ let items=[]; while(i<lines.length&&/^\\s*[-*+]\\s+/.test(lines[i])){ items.push(lines[i].replace(/^\\s*[-*+]\\s+/,\"\")); i++; } out+=\"<ul>\"+items.map(x=>\"<li>\"+inline(x)+\"</li>\").join(\"\")+\"</ul>\"; continue; }\n if(/^\\s*\\d+[.)]\\s+/.test(ln)){ let items=[]; while(i<lines.length&&/^\\s*\\d+[.)]\\s+/.test(lines[i])){ items.push(lines[i].replace(/^\\s*\\d+[.)]\\s+/,\"\")); i++; } out+=\"<ol>\"+items.map(x=>\"<li>\"+inline(x)+\"</li>\").join(\"\")+\"</ol>\"; continue; }\n let para=[]; while(i<lines.length&&!/^\\s*$/.test(lines[i])&&!/^\\s*(#{1,4}\\s|>|[-*+]\\s|\\d+[.)]\\s|\\|)/.test(lines[i])){ para.push(lines[i]); i++; }\n out+=\"<p>\"+para.map(inline).join(\"<br>\")+\"</p>\";\n }\n }\n return out;\n}\n\n/* ---- costruzione messaggi ---- */\nfunction hideEmpty(){ if(empty) empty.style.display=\"none\"; }\nfunction addUser(text){\n hideEmpty();\n const r=document.createElement(\"div\"); r.className=\"row user\";\n r.innerHTML='<div class=\"role\">Tu</div><div class=\"bubble\"></div>';\n r.querySelector(\".bubble\").textContent=text;\n thread.appendChild(r); scrollDown(); return r;\n}\nfunction newAssist(){\n hideEmpty();\n const r=document.createElement(\"div\"); r.className=\"row ai\";\n r.innerHTML='<div class=\"role\">Antigravity</div><div class=\"bubble\"><div class=\"md\"></div></div>';\n thread.appendChild(r); scrollDown();\n return { row:r, bubble:r.querySelector(\".bubble\"), md:r.querySelector(\".md\"), thinkEl:null };\n}\n\n// Separa il RAGIONAMENTO (<think>…</think>, emesso da modelli tipo deepseek-r1)\n// dalla risposta vera. Gestisce anche il tag ancora aperto durante lo streaming.\nfunction splitThink(raw){\n let think=\"\", answer=raw||\"\";\n const o=answer.indexOf(\"<think>\");\n if(o>=0){\n const c=answer.indexOf(\"</think>\");\n if(c>=0){ think=answer.slice(o+7,c); answer=answer.slice(0,o)+answer.slice(c+8); }\n else { think=answer.slice(o+7); answer=answer.slice(0,o); }\n }\n return { think:think.trim(), answer:answer.trim() };\n}\n// Disegna un messaggio assistente: blocco \"Ragionamento\" (se presente) + risposta.\nfunction paintAssist(a, raw){\n const s=splitThink(raw);\n if(!a) return s.answer; // ★ lo stream può essere già finito (curAssist=null): non crashare\n if(s.think){\n if(!a.thinkEl){\n a.thinkEl=document.createElement(\"div\"); a.thinkEl.className=\"think open\";\n a.thinkEl.innerHTML='<div class=\"th\">💭 Ragionamento<span class=\"tg\">nascondi</span></div><div class=\"tc\"></div>';\n a.thinkEl.querySelector(\".th\").onclick=()=>{ const op=a.thinkEl.classList.toggle(\"open\"); a.thinkEl.querySelector(\".tg\").textContent=op?\"nascondi\":\"mostra\"; };\n a.bubble.insertBefore(a.thinkEl, a.md);\n }\n a.thinkEl.querySelector(\".tc\").textContent=s.think;\n }\n a.md.innerHTML=renderMd(s.answer);\n return s.answer;\n}\nfunction actionsFor(row, text){\n const bar=document.createElement(\"div\"); bar.className=\"acts\";\n const copy=document.createElement(\"button\"); copy.className=\"act\"; copy.innerHTML=\"⧉ Copia\";\n copy.onclick=()=>{ navigator.clipboard&&navigator.clipboard.writeText(text); copy.innerHTML=\"✓ Copiato\"; setTimeout(()=>copy.innerHTML=\"⧉ Copia\",1400); };\n bar.appendChild(copy);\n const regen=document.createElement(\"button\"); regen.className=\"act\"; regen.innerHTML=\"↻ Rigenera\";\n regen.onclick=()=>regenerate();\n bar.appendChild(regen);\n row.appendChild(bar);\n}\n\n/* ---- indicatore \"sto pensando\" (parole animate come Claude) ---- */\nconst THINK=[\"Rifletto\",\"Rimugino\",\"Elaboro\",\"Medito\",\"Ragiono\",\"Ponzo\",\"Congetturo\",\"Almanacco\",\"Considero\",\"Analizzo\",\"Scandaglio\",\"Vaglio\"];\nlet thinkEl=null,thinkIv=null,thinkSec=null,thinkT0=0;\nfunction showThinking(){\n if(thinkEl) return; hideEmpty();\n thinkEl=document.createElement(\"div\"); thinkEl.className=\"row ai\";\n thinkEl.innerHTML='<div class=\"thinking\"><span class=\"sw\"></span><span class=\"el\"></span></div>';\n thread.appendChild(thinkEl); thinkT0=Date.now();\n let i=Math.floor(Math.random()*THINK.length);\n const word=()=>{ if(thinkEl) thinkEl.querySelector(\".sw\").textContent=THINK[i%THINK.length]+\"…\"; };\n const elapsed=()=>{ if(thinkEl) thinkEl.querySelector(\".el\").textContent=\"(\"+Math.round((Date.now()-thinkT0)/1000)+\"s · esc per fermare)\"; };\n word(); elapsed();\n thinkIv=setInterval(()=>{ i++; word(); },2000);\n thinkSec=setInterval(elapsed,1000);\n scrollDown();\n}\nfunction hideThinking(){ if(thinkIv){clearInterval(thinkIv);thinkIv=null;} if(thinkSec){clearInterval(thinkSec);thinkSec=null;} if(thinkEl){thinkEl.remove();thinkEl=null;} }\n\n/* ---- streaming ---- */\nlet _paintPending=false, _lastPaint=0, _paintTimer=null;\nfunction startStream(){\n hideThinking();\n streamRaw=\"\"; _lastPaint=0; _paintPending=false;\n if(_paintTimer){ clearTimeout(_paintTimer); _paintTimer=null; }\n const a=newAssist(); curAssist=a; streamEl=a.md; streamEl.classList.add(\"caret\");\n}\n// ★ 2026-07-19 — FRENO AL RIDISEGNO IN STREAMING.\n// Prima paintAssist() (parse markdown dell'INTERO testo accumulato) girava a OGNI\n// token: su una risposta lunga (i modelli di ragionamento sputano <think> da\n// migliaia di token) diventa lavoro al quadrato, il thread principale si inchioda\n// e la pagina si \"blocca\" — pulsanti inclusi, incliccabili. Ora si ridisegna al\n// massimo ogni 120ms (~8 volte/sec): scorre fluido e l'interfaccia resta viva.\n// Il disegno FINALE completo lo fa endStream, quindi nessuna perdita di qualità.\nfunction _flushPaint(){ _paintPending=false; _paintTimer=null; if(!curAssist) return; _lastPaint=Date.now(); paintAssist(curAssist, streamRaw); scrollDown(); }\nfunction appendStream(t){\n if(!streamEl) startStream();\n streamRaw+=t; streamEl.classList.add(\"caret\");\n const now=Date.now();\n if(now-_lastPaint>120){ _flushPaint(); }\n else if(!_paintPending){ _paintPending=true; _paintTimer=setTimeout(_flushPaint, 120); }\n}\nfunction endStream(full){\n if(_paintTimer){ clearTimeout(_paintTimer); _paintTimer=null; } // ★ annulla il ridisegno in coda: senza questo scattava con curAssist=null → crash \"reading 'md'\"\n _paintPending=false;\n const text=(full&&full.length>=streamRaw.length)?full:streamRaw;\n if(!streamEl&&text) startStream();\n let answer=text;\n if(curAssist){ answer=paintAssist(curAssist, text); curAssist.md.classList.remove(\"caret\"); }\n if(curAssist&&text){ document.querySelectorAll(\".row.ai .acts\").forEach(e=>e.remove()); actionsFor(curAssist.row, answer); }\n streamEl=null; curAssist=null; streamRaw=\"\";\n setRunning(false); setStatus(\"pronto\",true);\n}\n\n/* ---- pannello piano (todo) ---- */\nlet planEl=null;\nfunction renderPlan(entries){\n hideThinking();\n if(!planEl){ planEl=document.createElement(\"div\"); planEl.className=\"plan\"; (curAssist?curAssist.md.parentElement:thread).appendChild(planEl); }\n planEl.innerHTML='<div class=\"ph\">Piano</div>'+(entries||[]).map(e=>{\n const ic=e.status===\"completed\"?\"✅\":(e.status===\"in_progress\"?\"▶️\":\"⬜\");\n const cl=e.status===\"completed\"?\"done\":(e.status===\"in_progress\"?\"cur\":\"\");\n return '<div class=\"pi '+cl+'\"><span class=\"pk\">'+ic+'</span><span class=\"pt\">'+esc(e.content||\"\")+'</span></div>';\n }).join(\"\");\n scrollDown();\n}\n\n/* ---- tool cards ---- */\nfunction toolCard(d){\n hideThinking();\n let c=toolCards[d.id];\n if(!c){\n c=document.createElement(\"div\"); c.className=\"tool\";\n c.innerHTML='<div class=\"th\"><span class=\"tic\"></span><span class=\"tt\"></span><span class=\"ts\"><span class=\"spin\"></span></span></div><div class=\"tb\"></div>';\n c.querySelector(\".th\").onclick=()=>c.classList.toggle(\"open\");\n (curAssist?curAssist.md.parentElement:thread).appendChild(c); toolCards[d.id]=c;\n }\n const icon=d.kind===\"execute\"?\"⚡\":d.kind===\"edit\"?\"✎\":\"🔍\";\n c.querySelector(\".tic\").textContent=(d.status===\"completed\")?\"✓\":(d.status===\"error\"?\"✗\":icon);\n c.querySelector(\".tt\").textContent=d.title||\"strumento\";\n const ts=c.querySelector(\".ts\"); if(d.status===\"completed\"){ ts.textContent=\"fatto\"; }\n if(d.content) c.querySelector(\".tb\").textContent=String(d.content).slice(0,4000);\n scrollDown();\n}\n\n/* ---- approvazioni ---- */\nfunction approval(m){\n // Evita doppioni se l'evento arriva due volte (riconnessione).\n const dup=thread.querySelector('.appr[data-aid=\"'+m.id+'\"]'); if(dup) return;\n const box=document.createElement(\"div\"); box.className=\"appr\"; box.dataset.aid=m.id;\n // Separo la SPIEGAZIONE in parole semplici (prosa leggibile) dal DETTAGLIO tecnico\n // (comando/codice, in monospace), così capisci cosa approvi senza leggere il codice.\n let expl=\"\", tech=String(m.detail||\"\"); const marker=\"\\n\\n👉 Nel dettaglio (tecnico):\\n\";\n const ix=tech.indexOf(marker); if(ix>=0){ expl=tech.slice(0,ix); tech=tech.slice(ix+marker.length); }\n box.innerHTML='<div class=\"at\">🔐 '+esc(m.title||\"Confermi?\")+'</div>'\n +(expl?'<div class=\"aexpl\">'+esc(expl)+'</div>':'')\n +(tech?'<div class=\"ad\">'+esc(tech)+'</div>':'')\n +'<div class=\"arow\"><button class=\"btn pri\">✓ Approva</button><button class=\"btn\">✗ Nega</button></div>';\n const bs=box.querySelectorAll(\"button\");\n // Non rimuoviamo subito: aspettiamo l'eco \"approvalResolved\" dal server, così\n // il box sparisce su ENTRAMBI i device (telefono + VS Code) insieme.\n bs[0].onclick=()=>{ bs[0].disabled=bs[1].disabled=true; api(\"/approve\",{id:m.id,approved:true}); };\n bs[1].onclick=()=>{ bs[0].disabled=bs[1].disabled=true; api(\"/approve\",{id:m.id,approved:false}); };\n (curAssist?curAssist.md.parentElement:thread).appendChild(box); scrollDown();\n}\nfunction approvalResolved(m){\n const box=thread.querySelector('.appr[data-aid=\"'+m.id+'\"]');\n if(box) box.remove();\n}\n\n/* ---- render conversazione condivisa ---- */\nfunction clearThread(){ hideThinking(); thread.querySelectorAll(\".row,.tool,.appr,.plan\").forEach(e=>e.remove()); for(const k in toolCards)delete toolCards[k]; curAssist=null; streamEl=null; streamRaw=\"\"; planEl=null; }\nfunction renderHistory(msgs){\n clearThread();\n if(!msgs||!msgs.length){ if(empty) empty.style.display=\"\"; return; }\n hideEmpty();\n for(const m of msgs){\n if(m.role===\"user\"){ addUser(m.content); lastUser=m.content; }\n else if(m.role===\"assistant\"){ const a=newAssist(); const ans=paintAssist(a, m.content||\"\"); actionsFor(a.row, ans); }\n }\n scrollDown(true);\n}\n\n/* ---- eventi dal server ---- */\nfunction onMsg(m){\n switch(m.type){\n case \"history\": renderHistory(m.messages); break;\n case \"conversations\": renderConvList(m.list); break;\n case \"userMessage\": stick=true; planEl=null; addUser(m.value); lastUser=m.value; showThinking(); break;\n case \"cleared\": clearThread(); if(empty) empty.style.display=\"\"; setStatus(\"pronto\",true); break;\n case \"updateStatus\": setStatus(m.value,true); break;\n case \"addResponse\": { const a=newAssist(); const ans=paintAssist(a, m.value||\"\"); actionsFor(a.row, ans); setRunning(false); break; }\n case \"streamStart\": startStream(); setStatus(\"sta scrivendo…\",true); break;\n case \"streamToken\": appendStream(m.value||\"\"); break;\n case \"streamEnd\": endStream(m.value||\"\"); break;\n case \"agentEvent\": if(m.kind===\"tool\") toolCard(m.data||{}); else if(m.kind===\"plan\") renderPlan((m.data||{}).entries||[]); break;\n case \"requestApproval\": approval(m); break;\n case \"approvalResolved\": approvalResolved(m); break;\n case \"clearView\": clearThread(); if(empty) empty.style.display=\"\"; setStatus(\"schermo pulito · memoria intatta\",true); break;\n case \"cloudUsage\": renderUsage(m.usage); break;\n case \"claudeLimit\": renderLimit(m.limit); break;\n case \"model\": { const el=$(\"#curModel\"); const t=(m.label||m.model||\"\").trim(); if(t){ el.classList.remove(\"idle\"); el.textContent=\"🤖 \"+t; el.title=\"Sta lavorando ora: \"+t; } break; }\n }\n}\n\n/* ---- status / running ---- */\nfunction setStatus(t,on){ $(\"#stat\").textContent=t; if(on!==undefined)$(\"#dot\").classList.toggle(\"on\",!!on); }\nfunction setRunning(r){ running=r; sendBtn.textContent=r?\"■\":\"➤\"; sendBtn.classList.toggle(\"stop\",r); if(r)showThinking(); else hideThinking(); }\n\n/* ---- invio ---- */\nlet attachments=[]; // {path,name} dei file allegati\nfunction send(){\n if(running){ api(\"/stop\",{}); setRunning(false); return; }\n let text=input.value.trim();\n if(!text && !attachments.length) return;\n // Allega i percorsi dei file al prompt, così l'agente li apre/analizza.\n if(attachments.length){\n const list=attachments.map(a=>a.path).join(\"\\n\");\n text=(text||\"Analizza il/i file allegato/i e dimmi cos'è e cosa fa.\")+\"\\n\\n[FILE ALLEGATI - percorsi reali su disco]:\\n\"+list;\n attachments=[]; renderAttach();\n }\n input.value=\"\"; input.style.height=\"auto\";\n stick=true; setRunning(true); setStatus(\"invio…\",true);\n api(\"/send\",{ prompt:text, mode, model:$(\"#model\").value, provider:$(\"#provider\").value, channel:\"normal\" });\n}\n/* ---- allega file ---- */\nfunction renderAttach(){\n const row=$(\"#attachRow\");\n if(!attachments.length){ row.style.display=\"none\"; row.innerHTML=\"\"; return; }\n row.style.display=\"flex\";\n row.innerHTML=attachments.map((a,i)=>`<span style=\"display:inline-flex;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line2);border-radius:14px;padding:3px 10px;font-size:12px\">📎 ${a.name.slice(0,28)} <b data-rm=\"${i}\" style=\"cursor:pointer;color:var(--dim)\">✕</b></span>`).join(\"\");\n}\n$(\"#attachRow\").addEventListener(\"click\",e=>{ const rm=e.target.getAttribute(\"data-rm\"); if(rm!==null){ attachments.splice(+rm,1); renderAttach(); } });\n$(\"#attachBtn\").addEventListener(\"click\",()=>$(\"#fileInput\").click());\n$(\"#fileInput\").addEventListener(\"change\",async e=>{\n const files=Array.from(e.target.files||[]); e.target.value=\"\";\n for(const f of files){\n setStatus(\"carico \"+f.name+\"…\",true);\n try{\n const buf=await f.arrayBuffer();\n const r=await fetch(\"/upload?t=\"+encodeURIComponent(T),{method:\"POST\",headers:{\"X-Filename\":encodeURIComponent(f.name),\"Content-Type\":\"application/octet-stream\"},body:buf});\n const j=await r.json();\n if(j.ok){ attachments.push({path:j.path,name:j.name}); renderAttach(); }\n else setStatus(\"errore upload: \"+(j.error||\"\"),false);\n }catch(err){ setStatus(\"errore upload\",false); }\n }\n setStatus(\"pronto\",true);\n});\nfunction regenerate(){ if(running||!lastUser) return; stick=true; setRunning(true); setStatus(\"rigenero…\",true); api(\"/send\",{ prompt:lastUser, mode, model:$(\"#model\").value, provider:$(\"#provider\").value, channel:\"normal\" }); }\nfunction newChat(){ api(\"/newChat\",{}); closeDrawer(); }\n\n/* ---- cassetto chat salvate ---- */\nfunction openDrawer(){ $(\"#drawer\").classList.add(\"open\"); $(\"#overlay\").classList.add(\"open\"); }\nfunction closeDrawer(){ $(\"#drawer\").classList.remove(\"open\"); $(\"#overlay\").classList.remove(\"open\"); }\nfunction renderConvList(list){\n const box=$(\"#convList\"); box.innerHTML=\"\";\n (list||[]).forEach(c=>{\n const el=document.createElement(\"div\"); el.className=\"conv\"+(c.active?\" active\":\"\");\n el.innerHTML='<span class=\"ct\"></span><button class=\"cd\" title=\"Elimina\">🗑</button>';\n el.querySelector(\".ct\").textContent=c.title||\"Nuova chat\";\n el.querySelector(\".ct\").onclick=()=>{ api(\"/switch\",{id:c.id}); closeDrawer(); };\n el.querySelector(\".cd\").onclick=(e)=>{ e.stopPropagation(); if(confirm(\"Eliminare questa chat?\")) api(\"/delete\",{id:c.id}); };\n box.appendChild(el);\n });\n}\n\n/* ---- SSE ---- */\n// Su telefono, quando cambi pagina/app la connessione SSE viene sospesa e spesso\n// NON si riaggancia da sola: al ritorno restavi con una chat vecchia. Ora, appena\n// la pagina torna visibile (o riprende il focus), se il canale è chiuso lo\n// riapriamo: il server rimanda subito history + elenco chat → contesto ripristinato.\nlet ev=null, reconnTimer=null;\nfunction connect(){\n if(ev){ try{ ev.close(); }catch(_){} }\n ev=new EventSource(\"/events?t=\"+encodeURIComponent(T));\n ev.onopen=()=>setStatus(\"pronto\",true);\n ev.onmessage=e=>{ try{ onMsg(JSON.parse(e.data)); }catch(_){} };\n ev.onerror=()=>{\n setStatus(\"riconnessione…\",false);\n // EventSource riprova da solo sugli errori transitori, ma se il canale è\n // rimasto CHIUSO (es. dopo lo sblocco del telefono) lo riapriamo noi.\n clearTimeout(reconnTimer);\n reconnTimer=setTimeout(()=>{ if(!ev || ev.readyState===2) connect(); }, 1500);\n };\n}\nfunction ensureConnected(){ if(!ev || ev.readyState===2) connect(); }\ndocument.addEventListener(\"visibilitychange\",()=>{ if(document.visibilityState===\"visible\") ensureConnected(); });\nwindow.addEventListener(\"focus\",ensureConnected);\nwindow.addEventListener(\"pageshow\",ensureConnected);\nlet CATALOG={local:[],claude:[],hermes:[],nous:[],cloud:[],cloudUnc:[],cloudNorm:[],provMeta:{}};\nasync function loadModels(){\n try{\n const r=await fetch(\"/models?t=\"+encodeURIComponent(T)); const j=await r.json();\n CATALOG.local=j.models||[];\n CATALOG.claude=(j.claude&&j.claude.models)||[];\n // ★ 2026-07-19 — modelli CLOUD tenuti SEPARATI per gruppo: prima i SENZA\n // CENSURA, poi i normali. Sono quelli VERI trovati dalla discovery live sui\n // provider dove hai la chiave — non inventati.\n {\n const ch=(j.cloud&&j.cloud.channels)||{normal:[],uncensored:[]};\n CATALOG.provMeta=ch.providers||{}; // nome + gratuità di ogni provider (dal motore)\n const seen=new Set(); CATALOG.cloudUnc=[]; CATALOG.cloudNorm=[];\n for(const m of (ch.uncensored||[])){ if(!m||seen.has(m.value))continue; seen.add(m.value);\n CATALOG.cloudUnc.push({value:m.value,label:m.label||m.value,provider:m.provider,free:!!m.free}); }\n for(const m of (ch.normal||[])){ if(!m||seen.has(m.value))continue; seen.add(m.value);\n // molti \"normali\" sono in realtà abliterated/uncensored: si capisce dal nome\n const unc=/abliterat|uncensored|dolphin|hermes|venice/i.test(m.value||\"\");\n (unc?CATALOG.cloudUnc:CATALOG.cloudNorm).push({value:m.value,label:m.label||m.value,provider:m.provider,free:!!m.free}); }\n CATALOG.cloud=CATALOG.cloudUnc.concat(CATALOG.cloudNorm);\n }\n CATALOG.hermes=(j.hermes&&j.hermes.models)||[];\n CATALOG.nous=(j.nous&&j.nous.models)||[];\n // Modelli NOUS (free) fusi dentro Hermes: niente provider a parte. Li mostriamo\n // anche nella sezione Manutenzione (branch generico sotto). Il value resta\n // \"nous::...\" così l'orchestratore li instrada al motore Nous diretto.\n if(CATALOG.nous.length){\n /* Senza doppioni: da quando anche hermesClient espone SOLO i 4 modelli :free,\n le due liste si sovrappongono (stesso value \"nous::org/nome:free\") e il menu\n mostrava ogni modello due volte. */\n const gia=new Set(CATALOG.hermes.map(m=>m.value));\n for(const m of CATALOG.nous){ if(!gia.has(m.value)){ gia.add(m.value); CATALOG.hermes.push({value:m.value, label:\"🜂 \"+m.label}); } }\n }\n // Il provider Claude compare solo se la CLI è davvero installata sul PC.\n if(j.claude&&j.claude.available&&!$(\"#provider\").querySelector('option[value=\"claude\"]')){\n const o=document.createElement(\"option\"); o.value=\"claude\"; o.textContent=\"🤖 Claude Code\";\n $(\"#provider\").appendChild(o);\n }\n // Provider Hermes: compare solo se Hermes è installato sul PC.\n if(j.hermes&&j.hermes.available&&!$(\"#provider\").querySelector('option[value=\"hermes\"]')){\n const o=document.createElement(\"option\"); o.value=\"hermes\"; o.textContent=\"🜂 Hermes\";\n $(\"#provider\").appendChild(o);\n }\n if(j.claude&&j.claude.limit) renderLimit(j.claude.limit);\n fillModels();\n if(j.policy) $(\"#policy\").value=j.policy;\n }catch(_){}\n}\n/* I modelli mostrati dipendono dal provider scelto (locali, Claude o Hermes).\n Hermes porta i SUOI modelli (Ollama + i provider cloud che ha configurato) e la\n sua lista contiene già le voci \"Auto\" e \"Auto — senza censura\": la usiamo intera. */\nfunction fillModels(){\n const p=$(\"#provider\").value, sel=$(\"#model\");\n if(p===\"hermes\"){\n sel.innerHTML=\"\"; fillProviders([]); // Hermes porta la sua lista già pronta\n const list = CATALOG.hermes.length ? CATALOG.hermes : [{value:\"auto\",label:\"Modello: Auto\"}];\n list.forEach(m=>{ const o=document.createElement(\"option\"); o.value=m.value; o.textContent=m.label; sel.appendChild(o); });\n return;\n }\n // ★ 2026-07-24 — DUE SELETTORI SEPARATI, uguali per TUTTE le funzioni:\n // #llmProvider → CHI ospita il modello (alfabetico, con 🆓 o 💳 reale)\n // #model → i modelli DI QUEL provider (alfabetico, sempre dal catalogo\n // aggiornato da solo), ciascuno con la sua etichetta.\n // Gratis/a pagamento NON è più deciso qui: arriva dal motore (campo `free`),\n // che è l'unico a saperlo davvero. Prima un elenco scritto a mano nella pagina\n // marcava \"a pagamento\" ogni provider nuovo (GitHub e Cloudflare, che sono gratis).\n sel.innerHTML=\"\";\n const addOpt=(par,v,t)=>{ const o=document.createElement(\"option\"); o.value=v; o.textContent=t; par.appendChild(o); };\n const provOf=(v)=>{ const s=String(v||\"\"); const i=s.indexOf(\"::\"); return i>=0?s.slice(0,i):\"\"; };\n const byLabel=(x,y)=>(x.label||x.value).localeCompare(y.label||y.value);\n\n if(p===\"claude\"){ fillProviders([]); addOpt(sel,\"auto\",\"Modello: Auto\"); addGrp(sel,\"🤖 Claude\",CATALOG.claude); return; }\n\n // Elenco unico di TUTTI i modelli disponibili, con provider e gratuità.\n const all=[];\n (CATALOG.cloudUnc||[]).concat(CATALOG.cloudNorm||[]).forEach(m=>{ if(m&&m.value!==\"auto\") all.push({value:m.value,label:m.label||m.value,prov:m.provider||provOf(m.value)||\"altro\",free:!!m.free}); });\n (CATALOG.local||[]).forEach(m=>{ if(m&&m.value!==\"auto\") all.push({value:m.value,label:m.label||m.value,prov:\"ollama\",free:true}); });\n (CATALOG.nous||[]).forEach(m=>{ if(m&&m.value!==\"auto\") all.push({value:m.value,label:m.label||m.value,prov:\"nous\",free:true}); });\n\n fillProviders(all);\n const scelto=$(\"#llmProvider\").value||\"auto\";\n const lista=(scelto===\"auto\")?all:all.filter(m=>m.prov===scelto);\n\n addOpt(sel,\"auto\",\"⚙️ Auto — sceglie il migliore (consigliato)\");\n if(scelto===\"auto\"){\n // \"tutti\": resta il raggruppamento per provider, alfabetico, con 🆓/💳\n const byProv={}; lista.forEach(m=>{ (byProv[m.prov]=byProv[m.prov]||[]).push(m); });\n Object.keys(byProv).sort((a,b)=>provName(a).localeCompare(provName(b))).forEach(pr=>{\n const g=document.createElement(\"optgroup\");\n const nFree=byProv[pr].filter(m=>m.free).length;\n g.label=(nFree===byProv[pr].length?\"🆓 \":(nFree?\"🆓/💳 \":\"💳 \"))+provName(pr)+\" (\"+byProv[pr].length+\")\";\n byProv[pr].sort(byLabel).forEach(m=>addOpt(g,m.value,(m.free?\"🆓 \":\"💳 \")+(m.label||m.value)));\n sel.appendChild(g);\n });\n } else {\n lista.sort(byLabel).forEach(m=>addOpt(sel,m.value,(m.free?\"🆓 \":\"💳 \")+(m.label||m.value)));\n if(!lista.length) addOpt(sel,\"auto\",\"(nessun modello per questo provider)\");\n }\n}\n/* Nomi leggibili dei provider (per ordinare e mostrare). Il resto arriva dal server. */\nconst PROV_NAMES={openrouter:\"OpenRouter\",groq:\"Groq\",cerebras:\"Cerebras\",nvidia:\"NVIDIA\",sambanova:\"SambaNova\",hf:\"HuggingFace\",google:\"Gemini (Google)\",mistral:\"Mistral\",hyperbolic:\"Hyperbolic\",kaggle:\"Kaggle (tuo)\",alibaba:\"Alibaba Qwen\",openai:\"OpenAI\",deepseek:\"DeepSeek\",nous:\"Nous\",novita:\"Novita\",chutes:\"Chutes\",cloudflare:\"Cloudflare\",github:\"GitHub Models\",ollama:\"Ollama (locale)\",\"ollama-cloud\":\"Ollama Cloud\"};\nfunction provName(p){ const m=(CATALOG.provMeta||{})[p]; if(m&&m.name) return m.name; return PROV_NAMES[p]||(p?p.charAt(0).toUpperCase()+p.slice(1):\"Altro\"); }\nfunction addGrp(sel,nome,items){ if(!items||!items.length) return;\n const g=document.createElement(\"optgroup\"); g.label=nome+\" (\"+items.length+\")\";\n items.slice().sort((a,b)=>(a.label||a.value).localeCompare(b.label||b.value)).forEach(m=>{ if(m.value!==\"auto\"){ const o=document.createElement(\"option\"); o.value=m.value; o.textContent=m.label||m.value; g.appendChild(o);} });\n sel.appendChild(g); }\n/* Riempie il selettore PROVIDER: ordine alfabetico, con quanti modelli ha e se è\n gratis. Mantiene la scelta corrente se il provider esiste ancora. */\nfunction fillProviders(all){\n const ps=$(\"#llmProvider\"); if(!ps) return;\n const prima=ps.value||\"auto\";\n const conta={}, free={};\n (all||[]).forEach(m=>{ conta[m.prov]=(conta[m.prov]||0)+1; if(m.free) free[m.prov]=(free[m.prov]||0)+1; });\n ps.innerHTML=\"\";\n const o0=document.createElement(\"option\"); o0.value=\"auto\"; o0.textContent=\"Provider: tutti (\"+(all||[]).length+\")\"; ps.appendChild(o0);\n Object.keys(conta).sort((a,b)=>provName(a).localeCompare(provName(b))).forEach(pr=>{\n const tot=conta[pr], nf=free[pr]||0;\n const bollo = nf===tot ? \"🆓\" : (nf===0 ? \"💳\" : \"🆓/💳\");\n const o=document.createElement(\"option\"); o.value=pr;\n o.textContent=bollo+\" \"+provName(pr)+\" (\"+tot+\")\";\n ps.appendChild(o);\n });\n if([...ps.options].some(o=>o.value===prima)) ps.value=prima;\n}\n/* ---- limiti di utilizzo dell'abbonamento Claude (come li mostra Claude Code) ---- */\nfunction renderLimit(l){\n if(!l) return;\n const el=$(\"#climit\");\n const pct=l.percent||0;\n el.textContent=\"🤖 \"+pct+\"% \"+(l.label||\"\")+(l.overage?\" · extra\":\"\");\n el.title=\"Limite \"+(l.label||\"\")+\" al \"+pct+\"%\"+(l.resetsHuman?\" · si azzera \"+l.resetsHuman:\"\")+(l.status===\"rejected\"?\" · LIMITE RAGGIUNTO\":\"\");\n el.className=\"hstat lim\"+(l.status===\"rejected\"||pct>=100?\" bad\":(pct>=75?\" warn\":\"\"));\n el.style.display=\"\";\n}\n\n/* ---- UI wiring ---- */\nsendBtn.onclick=send;\ninput.addEventListener(\"keydown\",e=>{ if(e.key===\"Enter\"&&!e.shiftKey){ e.preventDefault(); send(); } });\ninput.addEventListener(\"input\",()=>{ input.style.height=\"auto\"; input.style.height=Math.min(input.scrollHeight,180)+\"px\"; });\n/* ---- dettatura vocale (Web Speech API) ---- */\n// Parli e scrive nel campo. Su iPhone può non essere supportata: in quel caso il\n// pulsante si nasconde e resta la dettatura del microfono della tastiera di iOS.\n(function(){\n const micBtn=$(\"#mic\");\n const SR=window.SpeechRecognition||window.webkitSpeechRecognition;\n if(!SR){ micBtn.classList.add(\"hidden\"); return; }\n let rec=null, listening=false, base=\"\";\n micBtn.onclick=()=>{\n if(listening){ rec&&rec.stop(); return; }\n rec=new SR(); rec.lang=\"it-IT\"; rec.interimResults=true; rec.continuous=true;\n base=input.value?input.value.replace(/\\s*$/,\"\")+\" \":\"\";\n rec.onresult=e=>{ let t=\"\"; for(let i=e.resultIndex;i<e.results.length;i++) t+=e.results[i][0].transcript; input.value=base+t; input.dispatchEvent(new Event(\"input\")); };\n rec.onend=()=>{ listening=false; micBtn.classList.remove(\"on\"); };\n rec.onerror=()=>{ listening=false; micBtn.classList.remove(\"on\"); };\n try{ rec.start(); listening=true; micBtn.classList.add(\"on\"); input.focus(); }catch(_){}\n };\n})();\ndocument.querySelectorAll(\".chip[data-mode]\").forEach(ch=>ch.onclick=()=>{ document.querySelectorAll(\".chip[data-mode]\").forEach(x=>x.classList.remove(\"on\")); ch.classList.add(\"on\"); mode=ch.dataset.mode; });\n$(\"#policy\").addEventListener(\"change\",e=>api(\"/policy\",{policy:e.target.value}));\n$(\"#provider\").addEventListener(\"change\",fillModels);\n// Il selettore MODELLO si aggancia sempre al PROVIDER scelto: cambiando provider\n// si ricostruisce la lista dei suoi modelli (fresca, dal catalogo auto-aggiornato).\n$(\"#llmProvider\").addEventListener(\"change\",fillModels);\n$(\"#newChat\").addEventListener(\"click\",newChat);\n/* ---- contatore uso cloud + barre token per provider ---- */\nlet _usageMonth=null;\nconst PROV_SHORT={openrouter:\"OpenRouter\",groq:\"Groq\",google:\"Gemini\",nvidia:\"NVIDIA\",sambanova:\"SambaNova\",hyperbolic:\"Hyperbolic\",cerebras:\"Cerebras\",mistral:\"Mistral\",alibaba:\"Qwen\",hf:\"HF\",deepseek:\"DeepSeek\",openai:\"OpenAI\",anthropic:\"Claude\"};\nfunction renderUsage(u){\n if(!u) return;\n const el=$(\"#usage\");\n const req=u.requests||0, tok=u.totalTokens||0, cost=u.estCostUsd||0;\n el.textContent=\"☁ \"+req+\" · \"+(tok/1000).toFixed(1)+\"k\"+(cost>0?\" · $\"+cost.toFixed(3):\"\");\n el.style.display=req?\"\":\"none\";\n // \"Comunicazione di quando si azzera\": al cambio mese avvisa in barra di stato.\n if(_usageMonth && u.month && u.month!==_usageMonth) setStatus(\"🔄 Uso mensile dei token azzerato — riparti da zero\",true);\n _usageMonth=u.month||_usageMonth;\n renderProvBars(u);\n}\n// Barre token per provider: si riempiono col REALE consumo del mese (relativo al\n// provider più usato), con l'etichetta in token e quando si azzerano.\nfunction renderProvBars(u){\n const box=$(\"#provBars\"); if(!box) return;\n const pp=u.perProvider||{};\n const entries=Object.entries(pp).filter(e=>(e[1].totalTokens||0)>0).sort((a,b)=>(b[1].totalTokens||0)-(a[1].totalTokens||0));\n if(!entries.length){ box.innerHTML='<span style=\"font-size:11px;color:var(--dim)\">token cloud: 0 · si azzera '+esc(u.resetsHuman||\"a inizio mese\")+'</span>'; return; }\n const max=entries[0][1].totalTokens||1;\n box.innerHTML=entries.map(e=>{\n const prov=e[0], t=e[1].totalTokens||0, pct=Math.max(5,Math.round(t/max*100));\n const name=PROV_SHORT[prov]||prov, lbl=t>=1000?(t/1000).toFixed(1)+\"k\":String(t);\n return '<div class=\"pbar'+(pct>=80?' hot':'')+'\" title=\"'+esc(name)+': '+t+' token questo mese · si azzera '+esc(u.resetsHuman||\"\")+'\">'\n +'<div class=\"pl\"><b>'+esc(name)+'</b><span>'+lbl+'</span></div>'\n +'<div class=\"pt\"><div class=\"pf\" style=\"width:'+pct+'%\"></div></div></div>';\n }).join(\"\");\n}\n/* ---- esporta la conversazione in Markdown ---- */\nfunction exportChat(){\n const rows=[...thread.querySelectorAll(\".row\")];\n let md=\"# Conversazione Antigravity\\n\\n\";\n for(const r of rows){\n const isUser=r.classList.contains(\"user\");\n const b=r.querySelector(isUser?\".bubble\":\".md\");\n if(!b) continue;\n md+=(isUser?\"**Tu:** \":\"**Antigravity:** \")+(b.innerText||b.textContent||\"\").trim()+\"\\n\\n\";\n }\n const blob=new Blob([md],{type:\"text/markdown\"});\n const a=document.createElement(\"a\");\n a.href=URL.createObjectURL(blob); a.download=\"antigravity-chat.md\";\n document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href);\n}\n$(\"#exportBtn\").addEventListener(\"click\",exportChat);\n// 🧹 Pulisci lo schermo su TUTTI i device SENZA cancellare la memoria: i messaggi\n// restano sul server (il modello continua a ricordarli), spariscono solo dalla vista.\n$(\"#clearBtn\").addEventListener(\"click\",()=>{ api(\"/clearView\",{}); setStatus(\"schermo pulito · memoria intatta\",true); });\n// ---- Comandi GPU Kaggle (terza riga) ----\nfunction kgSetBusy(busy){ for(const id of [\"kgUp14\",\"kgUp30\",\"kgStatus\",\"kgOff\"]) { const el=document.getElementById(id); if(el) el.classList.toggle(\"busy\",!!busy); } }\nfunction kgMsg(t){ const e=document.getElementById(\"kgStat\"); if(e){ e.textContent=t; } }\n$(\"#kgUp14\").addEventListener(\"click\",async()=>{ kgSetBusy(true); kgMsg(\"🟡 Accendo Kaggle 14B…\"); try{ const r=await fetch(apiUrl(\"/kaggle/up?size=14\")); const j=await r.json(); kgMsg(j.ok?\"✅ Kaggle 14B acceso.\":(j.error||\"⚠️ avvio 14B fallito\")); }catch(e){ kgMsg(\"⚠️ \"+e.message); } finally{ kgSetBusy(false); } });\n$(\"#kgUp30\").addEventListener(\"click\",async()=>{ kgSetBusy(true); kgMsg(\"🟡 Accendo Kaggle 30B…\"); try{ const r=await fetch(apiUrl(\"/kaggle/up?size=30\")); const j=await r.json(); kgMsg(j.ok?\"✅ Kaggle 30B acceso.\":(j.error||\"⚠️ avvio 30B fallito\")); }catch(e){ kgMsg(\"⚠️ \"+e.message); } finally{ kgSetBusy(false); } });\n$(\"#kgStatus\").addEventListener(\"click\",async()=>{ kgMsg(\"🔵 controllo…\"); loadKaggleQuota(); try{ const r=await fetch(apiUrl(\"/kaggle/status\")); const j=await r.json(); kgMsg(j.up?\"🟢 Kaggle ACCESO (\"+(j.host||\"\")+\")\":\"🔴 Kaggle spento\"); }catch(e){ kgMsg(\"⚠️ \"+e.message); } });\n// Ore quota GPU Kaggle (usate/totali della settimana + quando si azzerano).\nasync function loadKaggleQuota(){\n const el=document.getElementById(\"kgQuota\"); if(!el) return;\n try{ const r=await fetch(apiUrl(\"/kaggle/quota\")); const q=await r.json();\n // ★ 2026-07-30 — Sul telefono il tooltip non si vede (non c'è il mouse): se il\n // token è scaduto va scritto NELLA pagina, altrimenti i tasti sembrano \"rotti\"\n // senza motivo. Il perché e il rimedio finiscono nella riga di stato.\n if(q&&q.authFailed){ el.textContent=\"⛔ token Kaggle scaduto\"; el.title=q.hint||q.error; kgMsg(q.hint||q.error); return; }\n if(!q||q.error){ el.textContent=\"⏱️ ore n/d\"; if(q&&q.error) el.title=q.error; return; }\n el.textContent=\"⏱️ \"+q.usedH+\"/\"+q.totalH+\"h · reset \"+(q.reset||\"?\");\n el.title=\"Ore GPU Kaggle: usate \"+q.usedH+\"h su \"+q.totalH+\"h (\"+q.remainingH+\"h rimaste). Si azzerano il \"+(q.reset||\"?\")+\".\";\n }catch(e){ el.textContent=\"⏱️ ore n/d\"; }\n}\nsetTimeout(loadKaggleQuota, 1500); // all'avvio, senza rallentare il caricamento\n$(\"#kgOff\").addEventListener(\"click\",async()=>{ kgMsg(\"🔴 spegnimento…\"); try{ const r=await fetch(apiUrl(\"/kaggle/shutdown\"),{method:\"POST\"}); const j=await r.json(); kgMsg(j.message||(j.ok?\"✅ spento\":\"⚠️ impossibile spegnere\")); if(j.url){ window.open(j.url,\"_blank\"); } }catch(e){ kgMsg(\"⚠️ \"+e.message); } });\n$(\"#menuBtn\").addEventListener(\"click\",openDrawer);\n$(\"#overlay\").addEventListener(\"click\",closeDrawer);\n$(\"#drawerNew\").addEventListener(\"click\",newChat);\ndocument.addEventListener(\"click\",e=>{\n const ex=e.target.closest(\".ex\"); if(ex){ input.value=ex.dataset.ex; input.dispatchEvent(new Event(\"input\")); input.focus(); return; }\n const cp=e.target.closest(\".cbcopy\"); if(cp){ const c=decodeURIComponent(cp.dataset.code||\"\"); navigator.clipboard&&navigator.clipboard.writeText(c); cp.textContent=\"Copiato ✓\"; setTimeout(()=>cp.textContent=\"Copia\",1400); }\n});\n\n/* ---- 🔑 Pannello Chiavi API (auto-rilevamento provider) ---- */\nlet keyPick=null; // provider scelto/rilevato\nfunction openKeys(){ $(\"#keysDrawer\").classList.add(\"open\"); $(\"#keysOverlay\").classList.add(\"open\"); loadKeys(); }\nfunction closeKeys(){ $(\"#keysDrawer\").classList.remove(\"open\"); $(\"#keysOverlay\").classList.remove(\"open\"); }\n$(\"#keysBtn\").addEventListener(\"click\",openKeys);\n$(\"#keysClose\").addEventListener(\"click\",closeKeys);\n$(\"#keysOverlay\").addEventListener(\"click\",closeKeys);\n\n// ★ FEATURE C: dashboard stato — chiama /status e mostra cosa è vivo.\n$(\"#statusBtn\").addEventListener(\"click\", async () => {\n const out = $(\"#statusOut\");\n out.style.display = \"block\";\n out.textContent = \"Controllo…\";\n try {\n const r = await fetch(\"/status?t=\" + encodeURIComponent(T));\n const s = await r.json();\n const e = s.engines || {};\n const lines = [];\n lines.push(\"🖥️ Server: \" + (s.server ? \"ON\" : \"OFF\"));\n lines.push(\"💻 Ollama locale: \" + (e.local ? \"ON\" : \"off\") + \" (\" + (s.models_local || 0) + \" modelli)\");\n lines.push(\"☁️ Cloud HF: \" + (e.cloud ? \"ON\" : \"off\"));\n lines.push(\"🜂 Nous :free: \" + (s.nous ? \"ON\" : \"off\"));\n if (s.kaggle) lines.push(\"🧨 Kaggle: \" + (s.kaggle.up ? \"ON (\" + (s.kaggle.slug || \"\") + \")\" : \"spento\"));\n lines.push(\"🧅 Tor: \" + (s.tor || \"n/a\"));\n lines.push(\"🔑 Chiavi configurate: \" + (s.keys_configured || 0));\n // GPU attiva (failover) + piattaforme cablate\n try {\n const g = await (await fetch(\"/gpu/failover?t=\" + encodeURIComponent(T))).json();\n if (g.ok) {\n lines.push(\"🖥️ GPU attiva: \" + (g.active && g.active.active ? g.active.active : \"?\"));\n const cfg = (g.platforms || []).filter(p => p.configured).map(p => p.id);\n lines.push(\" piattaforme cablate: \" + (cfg.length ? cfg.join(\", \") : \"solo Kaggle\"));\n }\n } catch (_) {}\n // Sotto-agenti attivi\n lines.push(\"🤖 Sotto-agenti: Ghidra / ZW3D / Immagini / Web (attivi)\");\n // ★ 2026-07-30 — Qui c'era scritto \"ON (auto-ripara + Hermes ogni 30min)\":\n // una stringa fissa che non leggeva niente. Il timer da 30 minuti non è mai\n // esistito (l'unico setInterval del server è il keep-alive dell'SSE) e Hermes\n // non veniva mai chiamato. Ora la riga dice lo stato vero, letto dalla coda.\n try {\n const q = await (await fetch(\"/bugHunter/queue?t=\" + encodeURIComponent(T))).json();\n const aperti = (q.tickets || []).filter(t => t.status !== \"resolved\").length;\n lines.push(\"🐞 Cacciatore bug: scansione all'avvio del server e col bottone\");\n lines.push(\" da sistemare a mano: \" + (aperti ? aperti + \" (delegabili a Hermes)\" : \"nessuno\"));\n } catch (_) { lines.push(\"🐞 Cacciatore bug: stato coda non leggibile\"); }\n out.textContent = lines.join(\"\\n\");\n } catch (err) {\n out.textContent = \"Errore stato: \" + err.message;\n }\n});\n\n// ★ 2026-07-27 — CACCIATORE DI BUG INTERNO: bottone visibile. Lancia una scansione\n// + auto-riparazione (whitelist chiusa) e mostra il report. Se trova e sistema\n// problemi, lo dice; se tutto ok, conferma \"sano\".\n$(\"#bugHunterBtn\").addEventListener(\"click\", async () => {\n const out = $(\"#bugHunterOut\");\n out.style.display = \"block\";\n out.textContent = \"🐞 Ispeziono l'applicazione e riparo se necessario…\";\n try {\n const r = await fetch(\"/bugHunter/scan?fix=1&t=\" + encodeURIComponent(T));\n const j = await r.json();\n if (!j.ok) { out.textContent = \"⚠️ Cacciatore non disponibile: \" + (j.error || \"\"); return; }\n const lines = [];\n lines.push(\"🐞 Scansione: \" + (j.issues.length ? j.issues.length + \" anomalie\" : \"nessuna anomalia\"));\n if (j.fixed > 0) lines.push(\"✅ Corretto/i da solo: \" + j.fixed);\n (j.fixes || []).forEach(f => lines.push(\" ✓ \" + f));\n if (j.issues.length) j.issues.forEach(i => lines.push(\" \" + (i.severity === \"high\" ? \"🔴\" : i.severity === \"medium\" ? \"🟠\" : \"🟡\") + \" [\" + i.id + \"] \" + i.detail));\n lines.push(j.issues.length === 0 ? \"💚 App sana.\" : \"⚠️ Alcune cose richiedono attenzione manuale.\");\n out.textContent = lines.join(\"\\n\");\n } catch (err) { out.textContent = \"Errore cacciatore: \" + err.message; }\n loadTickets();\n});\n\n// ★ 2026-07-30 — CODA TICKET, adesso collegata davvero. I problemi che il\n// cacciatore non sa riparare da solo finiscono qui, e da qui si mandano a Hermes\n// UNO ALLA VOLTA, premendo. Hermes ci mette minuti: il server risponde subito e\n// scrive la risoluzione nel ticket quando ha finito, per questo si ricarica.\nasync function loadTickets() {\n const box = $(\"#ticketBox\"), list = $(\"#ticketList\"), cnt = $(\"#ticketCount\");\n try {\n const j = await (await fetch(\"/bugHunter/queue?t=\" + encodeURIComponent(T))).json();\n const aperti = (j.tickets || []).filter(t => t.status !== \"resolved\");\n if (!aperti.length) { box.style.display = \"none\"; return; }\n box.style.display = \"block\";\n cnt.textContent = \"(\" + aperti.length + \")\";\n list.innerHTML = \"\";\n for (const t of aperti) {\n const occupato = t.status === \"analyzing\" || t.status === \"applying\";\n const daApprovare = t.status === \"awaiting-approval\";\n const card = document.createElement(\"div\");\n card.style.cssText = \"background:var(--bg2);border:1px solid \" + (daApprovare ? \"var(--warn)\" : \"var(--line2)\") + \";border-radius:9px;padding:8px;margin:6px 0;font-size:12px\";\n const testa = document.createElement(\"div\");\n testa.style.cssText = \"color:var(--txt);margin-bottom:6px\";\n testa.textContent = (t.severity === \"high\" ? \"🔴\" : \"🟠\") + \" [\" + t.id + \"] \" + t.detail;\n card.appendChild(testa);\n\n // ★ 2026-07-30 — IL PUNTO DI TUTTO: qui leggi cosa stai per approvare.\n // Hermes ha già analizzato e scritto causa / file / modifica / rischi.\n if (t.plan) {\n const et = document.createElement(\"div\");\n et.style.cssText = \"color:var(--warn);font-weight:600;margin:6px 0 3px\";\n et.textContent = \"Hermes propone di fare questo:\";\n card.appendChild(et);\n const piano = document.createElement(\"div\");\n piano.style.cssText = \"color:var(--txt);white-space:pre-wrap;background:var(--bg);border-radius:7px;padding:7px;margin-bottom:6px;max-height:320px;overflow:auto;font-family:monospace;font-size:11px\";\n piano.textContent = t.plan;\n card.appendChild(piano);\n }\n // Se durante l'ANALISI ha toccato dei file (non doveva), lo vedi prima di approvare.\n if (t.warnings && t.warnings.length) {\n const w = document.createElement(\"div\");\n w.style.cssText = \"color:var(--danger);white-space:pre-wrap;margin-bottom:6px\";\n w.textContent = \"⚠️ In fase di analisi ha modificato dei file (non avrebbe dovuto):\\n \" + t.warnings.join(\"\\n \");\n card.appendChild(w);\n }\n if (t.resolution) {\n const esito = document.createElement(\"div\");\n esito.style.cssText = \"color:var(--dim);white-space:pre-wrap;margin-bottom:6px\";\n esito.textContent = t.resolution;\n card.appendChild(esito);\n }\n\n const barra = document.createElement(\"div\");\n barra.style.cssText = \"display:flex;gap:6px\";\n const bH = document.createElement(\"button\");\n bH.className = \"btn sm\"; bH.style.flex = \"1\";\n if (occupato) {\n bH.textContent = t.status === \"analyzing\" ? \"🜂 Hermes sta analizzando…\" : \"🛠️ Hermes sta applicando…\";\n bH.disabled = true;\n } else if (daApprovare) {\n bH.textContent = \"✅ Approva ed esegui\";\n bH.style.background = \"var(--warn)\"; bH.style.color = \"#1a1d21\";\n } else {\n bH.textContent = \"🔍 Fai analizzare a Hermes\";\n }\n bH.addEventListener(\"click\", async () => {\n if (occupato) return;\n const rotta = daApprovare ? \"/bugHunter/apply\" : \"/bugHunter/delegate\";\n bH.disabled = true; bH.textContent = daApprovare ? \"🛠️ Avvio…\" : \"🔍 Invio…\";\n try {\n const r = await (await fetch(rotta + \"?t=\" + encodeURIComponent(T), {\n method: \"POST\", headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ file: t.file })\n })).json();\n if (!r.ok) { bH.textContent = \"⚠️ \" + (r.error || \"invio fallito\"); bH.disabled = false; return; }\n loadTickets();\n } catch (e) { bH.textContent = \"⚠️ \" + e.message; bH.disabled = false; }\n });\n const bC = document.createElement(\"button\");\n bC.className = \"btn sm\";\n bC.textContent = daApprovare ? \"✕ Rifiuta\" : \"✕ Chiudi\";\n bC.addEventListener(\"click\", async () => {\n if (daApprovare && !confirm(\"Rifiuti la proposta di Hermes e chiudi il ticket?\")) return;\n bC.disabled = true;\n try {\n await fetch(\"/bugHunter/close?t=\" + encodeURIComponent(T), {\n method: \"POST\", headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ file: t.file, resolution: daApprovare ? \"Proposta rifiutata dall'utente.\" : \"Chiuso a mano dall'utente.\" })\n });\n loadTickets();\n } catch (e) { bC.disabled = false; }\n });\n barra.appendChild(bH); barra.appendChild(bC);\n card.appendChild(barra);\n list.appendChild(card);\n }\n } catch (_) { box.style.display = \"none\"; }\n}\n// Ricarica periodica finché c'è un ticket in lavorazione da Hermes.\nsetInterval(() => { if ($(\"#ticketBox\") && $(\"#ticketBox\").style.display === \"block\") loadTickets(); }, 20000);\nloadTickets();\n\n// ★ 2026-07-27 — TASTO \"TORNA INDIETRO\": ripristina l'ultimo backup sano di un file.\n// Chiede quale file (default mobileServer.js, il cuore). Salva la versione attuale\n// prima di sovrascrivere (rollback.js non cancella mai senza salvataggio).\n$(\"#rollbackBtn\").addEventListener(\"click\", async () => {\n const out = $(\"#bugHunterOut\");\n out.style.display = \"block\";\n const name = prompt(\"Quale file vuoi ripristinare all'ultimo backup sano?\\n(lascia vuoto per mobileServer.js)\", \"mobileServer.js\");\n if (!name) { out.textContent = \"Annullato.\"; return; }\n out.textContent = \"↩️ Ripristino \" + name + \" dall'ultimo backup…\";\n try {\n const r = await fetch(\"/rollback?t=\" + encodeURIComponent(T), {\n method: \"POST\", headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ name })\n });\n const j = await r.json();\n if (!j.ok) { out.textContent = \"⚠️ Rollback non riuscito: \" + (j.error || JSON.stringify(j.rollback)); return; }\n const rb = j.rollback || {};\n const lines = [\"↩️ Ripristinato da: \" + (rb.restoredFrom || \"?\")];\n if (rb.backedUpCurrent) lines.push(\"📦 Versione attuale salvata in: \" + rb.backedUpCurrent);\n lines.push(\"🔄 Server in riavvio…\");\n out.textContent = lines.join(\"\\n\");\n } catch (err) { out.textContent = \"Errore rollback: \" + err.message; }\n});\n\nasync function loadKeys(){\n try{\n const r=await fetch(\"/keys?t=\"+encodeURIComponent(T)); const j=await r.json();\n const act=$(\"#keysActive\"), sug=$(\"#keysSuggest\");\n const configured=(j.providers||[]).filter(p=>p.configured);\n // ★ 2026-07-27 — TUTTI gli id provider noti al registro (server): li usiamo per\n // decidere quali proposte possono avere il bottone \"Incolla qui\" (= hanno una\n // chiave da salvare). Prima era una whitelist scritta a mano che dimenticava i\n // provider nuovi: ora è automatica dal registro, così ogni provider aggiunto\n // lato server ottiene subito il tasto chiave senza toccare la pagina.\n const knownIds=new Set((j.providers||[]).map(p=>p.id));\n // ★ 2026-07-30 — formato della chiave per provider: la scheda del cacciatore\n // dice COSA incollare (es. \"Inizia con vck_\") prima ancora che tu apra il sito.\n const fmtById={}; (j.providers||[]).forEach(p=>{ if(p.keyFmt) fmtById[p.id]=p.keyFmt; });\n // Lista provider gratuiti dal server: ora arriva LIVE (modelli realmente\n // disponibili + budget residuo letto dagli header di rate-limit).\n let fps=[];\n try{ const rf=await fetch(\"/freeProviders?t=\"+encodeURIComponent(T)); const jf=await rf.json(); fps=jf.providers||[]; }catch(_){}\n const liveById={}; (fps||[]).forEach(p=>{ liveById[p.id]=p; });\n // ★ FEATURE 1: per OGNI provider configurato mostro indicatore vivo/morto/quota\n // e un bottone VERIFICA che fa un probe live via POST /keys/check?provider=xxx.\n act.innerHTML = configured.length ? configured.map(p=>{\n const id=escapeAttr(p.id), lbl=escapeHtml(p.label);\n // ★ 2026-07-24 — riga BUDGET: il limite vero dei piani free è al MINUTO\n // (Cerebras 5/min con 2400/giorno). Mostrarlo evita di scambiare una raffica\n // per \"chiave esaurita\".\n const lv=liveById[p.id]||{}; const b=lv.budget||{};\n const bits=[];\n if(b.remainingDay!=null) bits.push(\"oggi restano \"+b.remainingDay);\n if(b.perMinute!=null) bits.push(b.perMinute+\"/min\");\n if(b.busyForSec>0) bits.push(\"⏳ libero fra \"+b.busyForSec+\"s\");\n if(lv.health&&lv.health!==\"ok\"&&lv.health!==\"no-key\") bits.push(lv.health);\n const budget=bits.length?`<div style=\"font-size:11px;color:var(--dim);flex-basis:100%\">${escapeHtml(bits.join(\" · \"))}</div>`:\"\";\n return `<div class=\"kp-row\" data-pid=\"${id}\">\n <span class=\"kp-dot\" id=\"dot-${id}\" title=\"stato non verificato\">⚪</span>\n <span class=\"kp-name\">${lbl} ${p.free?'<small style=\"color:var(--dim)\">gratis</small>':''}</span>\n <button class=\"btn sm kp-check\" data-pid=\"${id}\">Verifica</button>\n <div class=\"kp-detail\" id=\"det-${id}\" style=\"font-size:12px;color:var(--dim);flex-basis:100%\"></div>\n ${budget}\n </div>`;\n }).join(\"\") : '<div style=\"font-size:13px;color:var(--dim);padding:4px\">Nessuna ancora. Usa il Cacciatore di taglie qui sotto.</div>';\n // Wiring dei bottoni VERIFICA (delegation sulla lista).\n act.querySelectorAll(\".kp-check\").forEach(b=>{\n b.onclick=()=>checkProviderKey(b.dataset.pid);\n });\n // FEATURE 2 — \"Cacciatore di taglie\": provider gratuiti che NON hai (fps sopra).\n const cfgIds=new Set(configured.map(p=>p.id));\n // ★ 2026-07-23 — Il cacciatore di taglie propone SOLO i provider gratuiti che NON\n // hai ancora (quelli già configurati sono sopra in \"Configurati\"): niente doppioni.\n const newFps=(fps||[]).filter(p=>!cfgIds.has(p.id));\n if(!newFps.length){ sug.innerHTML='<div style=\"font-size:13px;color:var(--dim);padding:4px\">🎉 Hai già tutti i provider gratuiti che conosco. Ottimo!</div>'; }\n else sug.innerHTML=newFps.map(p=>{\n const id=escapeAttr(p.id), name=escapeHtml(p.name), url=escapeAttr(p.keyUrl||\"#\");\n const unc=p.uncensored?' <span style=\"color:var(--ok)\">· senza filtri</span>':'';\n const models=(p.freeModels||[]).map(m=>escapeHtml(m)).join(\", \");\n // \"Incolla qui\" pre-imposta keyProvider SOLO se il provider esiste nel registro\n // (ha una chiave da salvare); per i provider locali/uncensored non ha senso.\n // ★ 2026-07-30 — la whitelist scritta a mano è sparita: knownIds (calcolato\n // sopra dal registro) contiene TUTTI i provider che hanno una voce vera con\n // endpoint e variabile .env, catalogo preconfigurato compreso. Ogni provider\n // proposto dal cacciatore ottiene così il bottone \"Incolla qui\".\n const canPaste=cfgIds.has(p.id)||knownIds.has(p.id);\n const pasteBtn=canPaste?`<button class=\"btn sm kp-paste\" data-pid=\"${id}\">Incolla qui</button>`:\"\";\n return `<div class=\"kp-free\">\n <div style=\"font-weight:600;font-size:13px\">${name}${unc}</div>\n <div style=\"font-size:12px;color:var(--dim);margin:3px 0\">${escapeHtml(p.note||\"\")}</div>\n ${models?`<div style=\"font-size:11px;color:var(--dim)\">Free: ${models}</div>`:\"\"}\n ${fmtById[p.id]?`<div style=\"font-size:11px;color:var(--dim);margin-top:3px\">🔑 Chiave: ${escapeHtml(fmtById[p.id])}</div>`:\"\"}\n <div style=\"margin-top:5px;display:flex;gap:6px;flex-wrap:wrap\">\n <a class=\"btn sm kp-link\" href=\"#\" data-url=\"${url}\" data-pid=\"${id}\" onclick=\"event.preventDefault(); if(this.dataset.pid) window.keyPick=this.dataset.pid; openTranslated(this.dataset.url);\" style=\"color:var(--acc,#c96442)\">Ottieni chiave ↗</a>\n ${pasteBtn}\n </div>\n </div>`;\n }).join(\"\");\n sug.querySelectorAll(\".kp-paste\").forEach(b=>{\n b.onclick=()=>pasteHere(b.dataset.pid);\n });\n }catch(e){}\n}\n// ★ 2026-07-24 — CACCIA VERA: il cacciatore va ONLINE a cercare provider gratuiti\n// nuovi (fonte pubblica aggiornata di continuo) e propone solo quelli che non hai.\n// Non crea account: trova, mostra i limiti reali e ti dà il link per la chiave.\nconst huntBtn=$(\"#huntBtn\");\nif(huntBtn) huntBtn.addEventListener(\"click\",async()=>{\n const out=$(\"#huntOut\"); if(!out) return;\n huntBtn.disabled=true; out.innerHTML=\"🔎 caccia in corso…\";\n try{\n const r=await fetch(\"/freeProviders/hunt?force=1&t=\"+encodeURIComponent(T));\n const j=await r.json();\n if(j.error){ out.innerHTML=\"⚠️ \"+escapeHtml(j.error); huntBtn.disabled=false; return; }\n const free=(j.nuovi||[]);\n const card=p=>{\n const models=(p.models||[]).slice(0,3).map(m=>escapeHtml(m)).join(\"<br>\");\n // ★ = citato come gratuito da PIÙ fonti indipendenti: il segnale anti-fregatura.\n const bollo=p.confermato\n ? '<span title=\"confermato da più fonti indipendenti\" style=\"color:var(--ok,#2e8b57)\">★ confermato</span>'\n : '<span style=\"color:var(--dim)\">1 sola fonte — verifica</span>';\n return `<div class=\"kp-free\">\n <div style=\"font-weight:600;font-size:13px\">${escapeHtml(p.name)}${p.knownId?' <small style=\"color:var(--dim)\">(già nel registro, chiave mancante)</small>':''}</div>\n <div style=\"font-size:11px;margin:2px 0\">${bollo}</div>\n ${models?`<div style=\"font-size:11px;color:var(--dim);margin:3px 0\">${models}</div>`:\"\"}\n <div style=\"margin-top:5px\"><a class=\"btn sm kp-link\" href=\"#\" data-url=\"${escapeAttr(p.url||'#')}\" data-pid=\"${escapeAttr(p.knownId||'')}\" onclick=\"event.preventDefault(); if(this.dataset.pid) window.keyPick=this.dataset.pid; openTranslated(this.dataset.url);\" style=\"color:var(--acc,#c96442)\">Ottieni chiave ↗</a></div>\n </div>`;\n };\n out.innerHTML =\n `<div style=\"margin-bottom:6px\">Sondati <b>${j.total||0}</b> provider su <b>${(j.sources||[]).length}</b> fonti${j.stale?\" (cache)\":\"\"}`\n + (j.trialEsclusi?` · <b>${j.trialEsclusi}</b> scartati perché a credito di prova/pagamento`:\"\") + `.</div>`\n + (free.length?`<div style=\"font-weight:600;margin:8px 0 4px\">🆓 Gratis permanenti, non ancora tuoi</div>`+free.map(card).join(\"\")\n :\"🎉 Hai già tutti i provider gratuiti che risultano dalle fonti.\");\n }catch(e){ out.innerHTML=\"⚠️ caccia fallita: \"+escapeHtml(e.message); }\n huntBtn.disabled=false;\n});\n\n// ★ 2026-07-27 — Cacciatore di piattaforme GPU: confronta dove ospitare i TUOI\n// modelli abliterated (Kaggle/Colab/Modal/RunPod/HF) e mostra lo stato live di Kaggle.\nconst gpuBtn=$(\"#gpuBtn\");\nif(gpuBtn) gpuBtn.addEventListener(\"click\",async()=>{\n const out=$(\"#gpuOut\"); if(!out) return;\n gpuBtn.disabled=true; out.innerHTML=\"🚀 confronto piattaforme…\";\n try{\n const r=await fetch(\"/gpuPlatforms?t=\"+encodeURIComponent(T));\n const j=await r.json();\n if(j.error){ out.innerHTML=\"⚠️ \"+escapeHtml(j.error); gpuBtn.disabled=false; return; }\n const card=p=>{\n const tag = p.kind===\"free\" ? \"🟢 gratis\" : p.kind===\"freemium\" ? \"🟡 freemium\" : \"🔴 a pagamento\";\n const gpu = p.freeGpu ? \"🖥️ GPU gratis\" : \"💸 GPU a pagamento\";\n const on = p.alwaysOn ? \"🔆 sempre acceso\" : \"💤 si addormenta\";\n const unc = p.uncensoredOk ? \"✅ modelli senza filtri OK\" : \"⛔ no modelli vietati (termini)\";\n const pros=(p.pros||[]).map(x=>\"✔️ \"+escapeHtml(x)).join(\"<br>\");\n const cons=(p.cons||[]).map(x=>\"➖ \"+escapeHtml(x)).join(\"<br>\");\n const stato = p.statusNote ? `<div style=\"font-size:11px;margin:3px 0\">${escapeHtml(p.statusNote)}</div>` : \"\";\n return `<div class=\"kp-free\">\n <div style=\"font-weight:600;font-size:13px\">${escapeHtml(p.name)}</div>\n <div style=\"font-size:11px;margin:2px 0\">${tag} · ${gpu} · ${on} · ${unc}</div>\n ${stato}\n <div style=\"font-size:11px;color:var(--dim);margin:3px 0\"><b>Costo:</b> ${escapeHtml(p.costo||\"-\")}</div>\n <div style=\"font-size:11px;margin:3px 0\">${pros}</div>\n <div style=\"font-size:11px;color:var(--dim);margin:3px 0\">${cons}</div>\n <div style=\"font-size:11px;color:var(--dim);margin:3px 0\">${escapeHtml(p.howto||\"\")}</div>\n ${p.url?`<div style=\"margin-top:5px\"><a class=\"btn sm\" href=\"#\" data-url=\"${escapeAttr(p.url)}\" onclick=\"event.preventDefault(); openTranslated(this.dataset.url);\" style=\"color:var(--acc,#c96442)\">Vai al sito ↗</a></div>`:\"\"}\n </div>`;\n };\n let html2 = `<div style=\"font-size:11px;margin-bottom:6px\">Dove ospitare i TUOI modelli abliterated (GPU). Non sono provider API: qui PORTI il modello tuo.</div>`;\n html2 += (j.platforms||[]).map(card).join(\"\");\n if(j.kaggle && j.kaggle.quota && !j.kaggle.quota.error){\n const q=j.kaggle.quota;\n html2 += `<div style=\"font-size:11px;color:var(--dim);margin-top:6px\">📊 Quota Kaggle: ${escapeHtml(String(q.remainingH))}/${escapeHtml(String(q.totalH))}h rimaste (reset ${escapeHtml(String(q.reset||\"-\"))})</div>`;\n }\n out.innerHTML=html2;\n }catch(e){ out.innerHTML=\"⚠️ confronto fallito: \"+escapeHtml(e.message); }\n gpuBtn.disabled=false;\n});\n\n// ★ 2026-07-27 — Tab del cacciatore (Provider / Senza filtri / Piattaforme):\n// mostra solo il pannello scelto e tiene il pulsante evidenziato.\ndocument.querySelectorAll(\".tab\").forEach(tab=>{\n tab.addEventListener(\"click\",()=>{\n const which=tab.dataset.tab;\n document.querySelectorAll(\".tab\").forEach(t=>t.classList.remove(\"active\"));\n tab.classList.add(\"active\");\n document.querySelectorAll(\".huntPanel\").forEach(p=>{\n p.style.display = (p.dataset.panel===which) ? \"block\" : \"none\";\n });\n });\n});\n\n// ★ 2026-07-27 — Scheda \"Senza filtri\": chiama la caccia passando ?uncensored=1,\n// così il server propone SOLO provider/modelli abliterated/uncensored.\nconst huntUncBtn=$(\"#huntUncBtn\");\nif(huntUncBtn) huntUncBtn.addEventListener(\"click\",async()=>{\n const out=$(\"#uncOut\"); if(!out) return;\n huntUncBtn.disabled=true; out.innerHTML=\"🔎 caccia senza filtri in corso…\";\n try{\n const r=await fetch(\"/freeProviders/hunt?force=1&uncensored=1&t=\"+encodeURIComponent(T));\n const j=await r.json();\n if(j.error){ out.innerHTML=\"⚠️ \"+escapeHtml(j.error); huntUncBtn.disabled=false; return; }\n const free=(j.nuovi||[]);\n const card=p=>{\n const models=(p.models||[]).slice(0,3).map(m=>escapeHtml(m)).join(\"<br>\");\n const bollo=p.confermato\n ? '<span title=\"confermato da più fonti\" style=\"color:var(--ok,#2e8b57)\">★ confermato</span>'\n : '<span style=\"color:var(--dim)\">1 sola fonte — verifica</span>';\n return `<div class=\"kp-free\">\n <div style=\"font-weight:600;font-size:13px\">${escapeHtml(p.name)}${p.knownId?' <small style=\"color:var(--dim)\">(già nel registro, chiave mancante)</small>':''}</div>\n <div style=\"font-size:11px;margin:2px 0\">${bollo} · <span style=\"color:var(--ok)\">senza filtri</span></div>\n ${models?`<div style=\"font-size:11px;color:var(--dim);margin:3px 0\">${models}</div>`:\"\"}\n <div style=\"margin-top:5px\"><a class=\"btn sm\" href=\"#\" data-url=\"${escapeAttr(p.url||'#')}\" data-pid=\"${escapeAttr(p.knownId||'')}\" onclick=\"event.preventDefault(); if(this.dataset.pid) window.keyPick=this.dataset.pid; openTranslated(this.dataset.url);\" style=\"color:var(--acc,#c96442)\">Ottieni chiave ↗</a></div>\n </div>`;\n };\n out.innerHTML =\n `<div style=\"margin-bottom:6px\">Sondati <b>${j.total||0}</b> provider su <b>${(j.sources||[]).length}</b> fonti${j.stale?\" (cache)\":\"\"}.</div>`\n + (free.length?`<div style=\"font-weight:600;margin:8px 0 4px\">🔓 Senza filtri, non ancora tuoi</div>`+free.map(card).join(\"\")\n :\"🎉 Nessun provider senza filtri nuovo dalle fonti.\");\n }catch(e){ out.innerHTML=\"⚠️ caccia fallita: \"+escapeHtml(e.message); }\n huntUncBtn.disabled=false;\n});\n\n// ★ 2026-07-27 — \"Ottieni chiave\" sempre in ITALIANO: passa l'URL al traduttore\n// Google così anche i siti cinesi/inglesi/russi si aprono tradotti. Se il\n// traduttore non risponde, apre il link originale (mai bloccare l'utente).\nasync function openTranslated(url){\n if(!url||url===\"#\"){ return; }\n try{\n const r=await fetch(\"/translate?u=\"+encodeURIComponent(url)+\"&t=\"+encodeURIComponent(T));\n const j=await r.json();\n if(j.url) window.open(j.url,\"_blank\",\"noopener\"); else window.open(url,\"_blank\",\"noopener\");\n }catch(_){ window.open(url,\"_blank\",\"noopener\"); }\n}\n// Intercetta TUTTI i link \"Ottieni chiave\" del cacciatore (delegation).\ndocument.addEventListener(\"click\",e=>{\n const a=e.target.closest('a.kp-link, a[href*=\"keyUrl\"], .kp-free a.btn');\n if(a && a.dataset.translate!==\"0\"){\n const href=a.getAttribute(\"href\");\n if(href && !href.startsWith(\"#\")){ e.preventDefault(); openTranslated(href); }\n }\n});\n\n// FEATURE 1 — probe live della chiave di un provider configurato.\nasync function checkProviderKey(pid){\n const dot=$(\"#dot-\"+pid), det=$(\"#det-\"+pid), btn=document.querySelector('.kp-check[data-pid=\"'+pid+'\"]');\n if(!dot) return;\n dot.textContent=\"🟡\"; dot.style.color=\"var(--warn,#b8860b)\"; if(det) det.textContent=\"verifica in corso…\"; if(btn) btn.disabled=true;\n try{\n const r=await fetch(\"/keys/check?provider=\"+encodeURIComponent(pid)+\"&t=\"+encodeURIComponent(T));\n const j=await r.json();\n const map={live:[\"🟢\",\"var(--ok,#2e8b57)\"],dead:[\"🔴\",\"var(--err,#c0392b)\"],quota:[\"🟠\",\"var(--warn,#b8860b)\"]};\n const m=map[j.status]||[\"⚪\",\"var(--dim)\"];\n dot.textContent=m[0]; dot.style.color=m[1];\n if(det) det.textContent=(j.detail||j.status||\"\");\n }catch(e){ dot.textContent=\"⚪\"; if(det) det.textContent=\"errore rete\"; }\n if(btn) btn.disabled=false;\n}\n// FEATURE 2 — \"Incolla qui\": porta il focus su keyInput e pre-imposta il provider.\nfunction pasteHere(pid){\n // ★ 2026-07-30 — elenco COMPLETO (catalogo incluso) già preselezionato sul\n // provider che hai scelto dal cacciatore: incolli la chiave e salvi.\n fillProviderSelect(pid).catch(()=>{});\n keyPick=pid;\n const ki=$(\"#keyInput\"); if(ki){ ki.focus(); ki.scrollIntoView({block:\"center\"}); }\n const det=$(\"#keyDetect\"); if(det) det.innerHTML='<span style=\"color:var(--acc,#c96442)\">Incolla la chiave di <b>'+escapeHtml(pid)+'</b> qui sopra.</span>';\n}\nfunction escapeHtml(s){ return String(s==null?\"\":s).replace(/[&<>\"]/g,c=>({\"&\":\"&amp;\",\"<\":\"&lt;\",\">\":\"&gt;\",'\"':\"&quot;\"}[c])); }\nfunction escapeAttr(s){ return String(s==null?\"\":s).replace(/\"/g,\"&quot;\"); }\n// ★ 2026-07-30 — SELETTORE MANUALE COMPLETO. Prima il select veniva riempito solo\n// coi candidati del rilevamento (quindi quasi sempre vuoto) oppure con la lista\n// dei 25 provider scritti a mano: se incollavi la chiave di un provider proposto\n// dal cacciatore ma non presente lì, non c'era modo di salvarla. Ora contiene\n// SEMPRE tutti i provider del registro (catalogo preconfigurato incluso),\n// divisi in \"gratis\" e \"a pagamento\", col formato della chiave nel titolo e\n// ⚠️ sui provider il cui endpoint va confermato con \"Verifica\".\nlet _allProvs=null;\nasync function allProviders(){\n if(_allProvs) return _allProvs;\n try{ const r=await fetch(\"/keys?t=\"+encodeURIComponent(T)); const j=await r.json(); _allProvs=j.providers||[]; }\n catch(_){ _allProvs=[]; }\n return _allProvs;\n}\nasync function fillProviderSelect(preselect){\n const sel=$(\"#keyProvider\"); if(!sel) return null;\n const list=await allProviders();\n if(!list.length) return preselect||null;\n const opt=p=>`<option value=\"${escapeAttr(p.id)}\" title=\"${escapeAttr(p.keyFmt||\"\")}\">${escapeHtml(p.label)}${p.verified===false?\" ⚠️\":\"\"}${p.configured?\" ✓\":\"\"}</option>`;\n const gratis=list.filter(p=>p.free), pagam=list.filter(p=>!p.free);\n sel.innerHTML=(gratis.length?`<optgroup label=\"Gratis / piano free\">${gratis.map(opt).join(\"\")}</optgroup>`:\"\")\n + (pagam.length?`<optgroup label=\"A pagamento o credito di prova\">${pagam.map(opt).join(\"\")}</optgroup>`:\"\");\n if(preselect && list.some(p=>p.id===preselect)) sel.value=preselect;\n sel.style.display=\"block\";\n return sel.value||null;\n}\nlet keyTimer=null;\n$(\"#keyInput\").addEventListener(\"input\",()=>{\n clearTimeout(keyTimer); const key=$(\"#keyInput\").value.trim();\n const det=$(\"#keyDetect\"), selP=$(\"#keyProvider\"), save=$(\"#keySave\");\n keyPick=null;\n if(!key){ det.textContent=\"\"; selP.style.display=\"none\"; save.disabled=true; return; }\n keyTimer=setTimeout(async()=>{\n try{\n const r=await api(\"/detectKey\",{key}); const d=await r.json();\n // ★ 2026-07-30 — AVVISO dal rilevatore (es. token Google \"AQ.\" temporaneo,\n // che sembra una chiave ma scade in un'ora): lo mostriamo PRIMA di salvare,\n // così non ti ritrovi il pannello che dichiara \"chiave obsoleta\" domani.\n const warn=d.warn?`<div style=\"color:var(--warn,#b8860b);font-size:12px;margin-bottom:4px\">⚠️ ${escapeHtml(d.warn)}</div>`:\"\";\n if(d.warn && !d.best){\n det.innerHTML=warn+'Se sai quello che fai puoi comunque scegliere il provider a mano:';\n keyPick=await fillProviderSelect((d.candidates&&d.candidates[0]&&d.candidates[0].id)||null);\n save.disabled=!keyPick; return;\n }\n if(d.best){\n keyPick=d.best.id;\n const altri=(d.candidates&&d.candidates.length>1)\n ? 'Più provider usano questo formato: controlla che sia quello giusto.'\n : 'Non è questo? Cambialo qui sotto: l\\'elenco contiene tutti i provider.';\n det.innerHTML=`Rilevato: <b>${escapeHtml(d.best.label)}</b> · endpoint <code>${escapeHtml(d.best.host+d.best.chatPath)}</code>${d.best.free?' · <span style=\"color:var(--ok)\">gratis</span>':''}`\n + (d.best.keyFmt?`<div style=\"font-size:11px;color:var(--dim);margin-top:2px\">🔑 ${escapeHtml(d.best.keyFmt)}</div>`:\"\")\n + `<div style=\"font-size:11px;color:var(--dim);margin-top:2px\">${altri}</div>`;\n // il selettore resta SEMPRE visibile e completo, preselezionato sul rilevato\n keyPick = (await fillProviderSelect(d.best.id)) || d.best.id;\n } else {\n det.innerHTML='Provider non riconosciuto dal formato. Scegli manualmente (elenco completo):';\n keyPick = await fillProviderSelect(null);\n }\n save.disabled = !keyPick;\n }catch(e){\n det.innerHTML='Non riesco a contattare il rilevatore. Scegli manualmente (elenco completo):';\n keyPick = await fillProviderSelect(null);\n save.disabled = !keyPick;\n }\n },350);\n});\n$(\"#keyProvider\").addEventListener(\"change\",e=>{ keyPick=e.target.value; });\n$(\"#keySave\").addEventListener(\"click\",async()=>{\n const key=$(\"#keyInput\").value.trim(); if(!key) return;\n $(\"#keySave\").disabled=true; $(\"#keySave\").textContent=\"Controllo…\";\n const selP=$(\"#keyProvider\");\n if(!keyPick && selP && selP.value) keyPick = selP.value;\n if(!keyPick && selP && selP.options.length) keyPick = selP.options[0].value;\n if(!keyPick){ $(\"#keyDetect\").innerHTML='<span style=\"color:var(--err)\">Errore: seleziona un provider prima di salvare.</span>'; $(\"#keySave\").disabled=false; $(\"#keySave\").textContent=\"Salva chiave\"; return; }\n try{\n // ★ C2 — TEST CHIAVE LIVE prima di salvare: prova dal vivo il provider rilevato\n // e mostra \"funziona / scaduta / quota esaurita\" PRIMA di confermare.\n // Non blocca il salvataggio (anti-danno): se il probe fallisce o è lento,\n // salva comunque ma avvisa.\n let probeNote=\"\";\n if(keyPick){\n try{\n const pc=await fetch(\"/keys/check?provider=\"+encodeURIComponent(keyPick)+\"&t=\"+encodeURIComponent(T));\n const pj=await pc.json();\n if(pj && pj.status){\n const map={live:[\"🟢\",\"funziona\"],dead:[\"🔴\",\"chiave morta/non valida\"],quota:[\"🟠\",\"quota esaurita/scaduta\"]};\n const m=map[pj.status]||[\"⚪\",pj.status];\n probeNote=` · ${m[0]} ${m[1]}`;\n }\n }catch(_){ probeNote=\" · (test live saltato)\"; }\n }\n $(\"#keySave\").textContent=\"Salvo…\";\n let r=await api(\"/addKey\",{key,provider:keyPick}); let j=await r.json();\n // ★ 2026-07-31 — CONFLITTO: la variabile .env ha già una chiave DIVERSA. Non\n // sovrascriviamo alla cieca (era così che spariva la chiave di groq): chiediamo\n // conferma esplicita e solo allora reinviamo con confirmOverwrite.\n if(!j.ok && j.conflict){\n const ok=confirm((j.error||\"Sovrascrivere la chiave esistente?\")+\"\\n\\nLa vecchia chiave viene salvata in .env.bak, ma verrà sostituita. Procedo?\");\n if(!ok){ $(\"#keyDetect\").innerHTML='<span style=\"color:var(--dim)\">Salvataggio annullato: chiave esistente intatta.</span>'; $(\"#keySave\").textContent=\"Salva chiave\"; $(\"#keySave\").disabled=false; return; }\n r=await api(\"/addKey\",{key,provider:keyPick,confirmOverwrite:true}); j=await r.json();\n }\n if(j.ok){\n $(\"#keyInput\").value=\"\";\n // ★ 2026-07-30 — stato AUTOREVOLE dal server (ha provato la chiave NUOVA dal\n // vivo). Se c'è, vince sul probeNote (che testava la chiave già salvata).\n const smap={live:[\"🟢\",\"funziona\"],quota:[\"🟠\",\"autentica ma senza credito/quota\"]};\n const sm=j.liveStatus&&smap[j.liveStatus];\n const liveTxt=sm?(' · '+sm[0]+' '+sm[1]):probeNote;\n const corr=j.corrected?('<div style=\"font-size:12px;color:var(--warn,#b8860b);margin-top:3px\">↔︎ Avevi indicato «'+escapeHtml(String(j.corrected.from||'?'))+'», ma la chiave autentica su <b>'+escapeHtml(j.corrected.toLabel||j.corrected.to)+'</b>: salvata lì.</div>'):\"\";\n $(\"#keyDetect\").innerHTML='<span style=\"color:var(--ok)\">✓ '+escapeHtml(j.label||j.provider)+' salvato e attivo.'+liveTxt+'</span>'+corr;\n $(\"#keyProvider\").style.display=\"none\"; _allProvs=null; loadKeys(); loadModels&&loadModels();\n }\n else $(\"#keyDetect\").innerHTML='<span style=\"color:var(--err)\">Errore: '+(j.error||\"\")+'</span>';\n }catch(e){ $(\"#keyDetect\").textContent=\"Errore di rete.\"; }\n $(\"#keySave\").textContent=\"Salva chiave\"; $(\"#keySave\").disabled=false;\n});\n/* ===== NAVIGATORE TOR ===== */\n// Siti .onion UFFICIALI e SICURI da cui partire (chi non conosce la rete parte\n// da qui). Sono le versioni onion verificate di servizi noti.\nconst TOR_START=[\n [\"🦆 DuckDuckGo (ricerca)\",\"https://duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion/\"],\n [\"📰 BBC News\",\"https://www.bbcnewsd73hkzno2ini43t4gblxvycyac5aw4gnv7t2rccijh7745uqd.onion/\"],\n [\"🔎 ProPublica\",\"https://p53lf57qovyuvwsc6xnrppyply3vtqm7l6pcobkmyqsiofyeznfu5uqd.onion/\"],\n [\"🧅 Tor Project\",\"https://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion/\"]\n];\nlet torHistory=[];\nfunction torOpen(){ $(\"#torPanel\").style.display=\"flex\"; if(!$(\"#torContent\").dataset.init){ torHome(); $(\"#torContent\").dataset.init=\"1\"; } }\nfunction torClose(){ $(\"#torPanel\").style.display=\"none\"; }\nfunction torHome(){\n torHistory=[];\n $(\"#torUrl\").value=\"\";\n $(\"#torStatus\").textContent=\"Rete Tor — scegli un sito sicuro per iniziare, o scrivi un indirizzo .onion\";\n const cards=TOR_START.map(([n,u])=>`<button class=\"btn\" data-tor-url=\"${u}\" style=\"display:block;width:100%;text-align:left;margin:6px 0;padding:12px\">${n}</button>`).join(\"\");\n $(\"#torContent\").innerHTML=`<div style=\"max-width:520px;margin:0 auto\">\n <div style=\"font-size:13px;color:var(--dim);margin-bottom:14px;line-height:1.5\">🔒 Navighi attraverso Tor: il PC fa da tramite, il telefono non contatta nessun sito direttamente. Gli script dei siti sono disattivati per sicurezza. È un <b>lettore</b>: i siti semplici si vedono bene, quelli pieni di JavaScript no.</div>\n <div style=\"font-weight:600;margin-bottom:6px\">Punti di partenza sicuri</div>${cards}\n <div style=\"font-size:12px;color:var(--dim);margin-top:16px\">💡 Scrivi cosa cerchi nella barra in alto (es. «notizie anonime») e premi Vai: cerco per te su DuckDuckGo dentro Tor. Ogni pagina che apri viene <b>pre-analizzata</b> e ti avviso se c'è qualcosa di rischioso.</div>\n </div>`;\n}\n// Banner del rischio in cima alla pagina (semaforo).\nfunction torRiskBanner(risk){\n if(!risk||!risk.warnings||!risk.warnings.length) return \"\";\n const col = risk.level===\"pericolo\" ? \"#e05252\" : (risk.level===\"attenzione\" ? \"#d69e2e\" : \"#48bb78\");\n const ico = risk.level===\"pericolo\" ? \"⛔ PERICOLO\" : (risk.level===\"attenzione\" ? \"⚠️ ATTENZIONE\" : \"✓\");\n return '<div style=\"border:1px solid '+col+';background:'+col+'18;border-radius:10px;padding:10px 12px;margin-bottom:14px;font-size:13px;line-height:1.5\">'\n +'<b style=\"color:'+col+'\">'+ico+' — pre-analisi della pagina</b><ul style=\"margin:6px 0 0;padding-left:18px\">'\n +risk.warnings.map(x=>\"<li>\"+esc(x)+\"</li>\").join(\"\")+'</ul></div>';\n}\n// Se il testo è un indirizzo → apri; altrimenti → CERCA su DuckDuckGo-onion.\nfunction torGoSmart(text){\n text=(text||\"\").trim(); if(!text) return;\n const looksUrl = /\\.onion(\\/|$)/i.test(text) || /^https?:\\/\\//i.test(text) || /\\.[a-z]{2,}(\\/|$)/i.test(text);\n if(looksUrl){ torVisit(text); }\n else { // ricerca: \"voglio vedere siti di X\" → cerca X su DuckDuckGo-onion\n const q=text.replace(/^\\s*(voglio vedere|cerca|trova|mostrami|siti( di)?)\\s+/i,\"\").trim()||text;\n $(\"#torStatus\").textContent=\"🔎 Cerco «\"+q+\"» su DuckDuckGo (Tor)…\";\n torVisit(\"https://duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion/html/?q=\"+encodeURIComponent(q));\n }\n}\nasync function torVisit(url){\n if(!url) return;\n $(\"#torStatus\").textContent=\"🧅 Carico attraverso Tor… (può richiedere qualche secondo)\";\n $(\"#torContent\").innerHTML='<div style=\"text-align:center;color:var(--dim);margin-top:40px\">⏳ Connessione a Tor…</div>';\n try{\n const r=await fetch(\"/tor/page?u=\"+encodeURIComponent(url)+\"&t=\"+encodeURIComponent(T));\n const j=await r.json();\n if(j.error){ $(\"#torStatus\").textContent=\"⚠️ \"+j.error; $(\"#torContent\").innerHTML='<div style=\"color:var(--dim);margin-top:30px;text-align:center\">'+esc(j.error)+'<br><br><button class=\"btn\" onclick=\"torHome()\">↩ Torna ai siti sicuri</button></div>'; return; }\n torHistory.push(j.url);\n $(\"#torUrl\").value=j.url;\n const rl=j.risk&&j.risk.level; $(\"#torStatus\").textContent=(rl===\"pericolo\"?\"⛔ \":rl===\"attenzione\"?\"⚠️ \":\"🧅 \")+(j.title||j.url);\n $(\"#torContent\").innerHTML=torRiskBanner(j.risk)+(j.html||\"<i>(pagina vuota)</i>\");\n $(\"#torContent\").scrollTop=0;\n }catch(e){ $(\"#torStatus\").textContent=\"⚠️ Errore di rete verso il server.\"; }\n}\n// clic su un link dentro una pagina Tor → riapre DENTRO Tor\n$(\"#torContent\").addEventListener(\"click\",e=>{\n const a=e.target.closest(\"[data-tor-url]\");\n if(a){ e.preventDefault(); torVisit(a.getAttribute(\"data-tor-url\")); }\n});\n$(\"#torBtn\").onclick=torOpen;\n$(\"#torClose\").onclick=torClose;\n$(\"#torGo\").onclick=()=>torGoSmart($(\"#torUrl\").value);\n$(\"#torUrl\").addEventListener(\"keydown\",e=>{ if(e.key===\"Enter\") torGoSmart($(\"#torUrl\").value); });\n$(\"#torBack\").onclick=()=>{ torHistory.pop(); const prev=torHistory.pop(); if(prev) torVisit(prev); else torHome(); };\n\n/* ===== 🎵 MUSICA (cerca brano · testo · anteprima 30s · apri in Spotify) ===== */\nlet _musicResults=[];\nfunction musicOpen(){ $(\"#musicPanel\").style.display=\"flex\"; setTimeout(()=>{try{$(\"#musicQuery\").focus();}catch(_){}} ,50); }\nfunction musicClose(){ try{$(\"#musicAudio\").pause();}catch(_){}; $(\"#musicPanel\").style.display=\"none\"; }\nasync function musicSearch(){\n const q=$(\"#musicQuery\").value.trim(); if(!q) return;\n $(\"#musicStatus\").textContent=\"Cerco «\"+q+\"»…\"; $(\"#musicContent\").innerHTML=\"\";\n let r;\n try{ r=await fetch(\"/music/search?q=\"+encodeURIComponent(q)+\"&t=\"+encodeURIComponent(T)); }\n catch(e){ $(\"#musicStatus\").textContent=\"Nessuna connessione al server.\"; return; }\n // ★ errori PARLANTI: la causa più comune è il server non ancora riavviato (404).\n if(!r.ok){\n $(\"#musicStatus\").textContent =\n r.status===404 ? \"⚠️ Riavvia l'estensione: la funzione Musica non è ancora caricata nel server.\" :\n r.status===401 ? \"Sessione scaduta: ricarica la pagina.\" :\n \"Errore del server (\"+r.status+\").\"; return;\n }\n let j; try{ j=await r.json(); }catch(_){ $(\"#musicStatus\").textContent=\"Risposta non valida dal server (prova a riavviare).\"; return; }\n if(j.error){ $(\"#musicStatus\").textContent=\"Errore: \"+j.error; return; }\n _musicResults=j.results||[];\n if(!_musicResults.length){ $(\"#musicStatus\").textContent=\"Nessun risultato per «\"+q+\"».\"; return; }\n $(\"#musicStatus\").textContent=_musicResults.length+\" risultati\";\n $(\"#musicContent\").innerHTML=_musicResults.map((s,i)=>\n '<div class=\"mrow\" style=\"display:flex;gap:12px;align-items:center;padding:8px;border-radius:6px\">'\n + (s.cover?('<img src=\"'+escapeAttr(s.cover)+'\" style=\"width:52px;height:52px;border-radius:4px;object-fit:cover;flex-shrink:0\" loading=\"lazy\">'):'<div style=\"width:52px;height:52px;border-radius:4px;background:#282828;display:flex;align-items:center;justify-content:center;flex-shrink:0\">🎵</div>')\n + '<div style=\"flex:1;min-width:0;cursor:pointer\" onclick=\"musicLyrics('+i+')\">'\n + '<div style=\"font-weight:600;font-size:15px;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\">'+escapeHtml(s.title)+'</div>'\n + '<div style=\"font-size:13px;color:#b3b3b3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\">'+escapeHtml(s.artist)+(s.album?(\" · \"+escapeHtml(s.album)):\"\")+'</div>'\n + '</div>'\n + '<div style=\"display:flex;gap:8px;align-items:center;flex-shrink:0\">'\n + '<button class=\"mplay\" title=\"Riproduci brano intero\" onclick=\"musicLyrics('+i+')\" style=\"background:#1ED760;border:none;width:38px;height:38px;border-radius:50%;color:#000;font-size:15px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:transform .12s\">▶</button>'\n + '</div></div>').join(\"\");\n}\nfunction musicPlay(i){\n const s=_musicResults[i]; if(!s||!s.preview) return;\n const a=$(\"#musicAudio\"); a.src=s.preview; a.play().catch(()=>{});\n $(\"#musicStatus\").textContent=\"▶️ Anteprima 30s: \"+s.title+\" — \"+s.artist;\n}\nasync function musicLyrics(i){\n const s=_musicResults[i]; if(!s) return;\n $(\"#musicStatus\").textContent=s.title+\" — \"+s.artist;\n const cover=s.cover?('<img src=\"'+escapeAttr(s.cover)+'\" style=\"width:80px;height:80px;border-radius:6px;object-fit:cover;flex-shrink:0\">'):'';\n const spBtn='<a href=\"'+escapeAttr(s.spotify)+'\" target=\"_blank\" rel=\"noopener\" style=\"text-decoration:none;color:#b3b3b3;font-size:12px;padding:6px 12px;border:1px solid #444;border-radius:500px\">Apri in Spotify</a>';\n // Vista brano: PLAYER YOUTUBE (brano intero, gratis) in cima + testo sotto.\n $(\"#musicContent\").innerHTML='<div style=\"padding:6px 8px 24px\">'\n + '<button onclick=\"musicSearch()\" style=\"background:#282828;color:#fff;border:none;border-radius:500px;padding:8px 16px;font-size:13px;font-weight:600;cursor:pointer;margin-bottom:14px\">‹ Risultati</button>'\n + '<div style=\"display:flex;gap:14px;align-items:center;margin-bottom:14px\">'+cover\n + '<div style=\"min-width:0\"><div style=\"font-weight:800;font-size:21px;color:#fff;letter-spacing:-.5px;line-height:1.15\">'+escapeHtml(s.title)+'</div>'\n + '<div style=\"font-size:14px;color:#b3b3b3;margin-top:3px\">'+escapeHtml(s.artist)+'</div></div>'\n + '</div>'\n + '<div id=\"musicPlayer\" style=\"margin-bottom:12px\"><div style=\"color:#b3b3b3;font-size:13px\">▶ Carico il brano intero da YouTube…</div></div>'\n + '<div style=\"margin-bottom:18px\">'+spBtn+'</div>'\n + '<div id=\"musicLyricsBox\" style=\"color:#b3b3b3;font-size:13px\">Carico il testo…</div>'\n + '</div>';\n $(\"#musicContent\").scrollTop=0;\n // 1) Player YOUTUBE: il brano INTERO, gratis, dentro la pagina (autoplay).\n fetch(\"/music/youtube?q=\"+encodeURIComponent(s.artist+\" \"+s.title)+\"&t=\"+encodeURIComponent(T))\n .then(r=>r.ok?r.json():null).then(d=>{\n const box=$(\"#musicPlayer\"); if(!box) return;\n if(d&&d.videoId){ box.innerHTML='<iframe src=\"https://www.youtube.com/embed/'+encodeURIComponent(d.videoId)+'?autoplay=1&rel=0\" style=\"width:100%;aspect-ratio:16/9;border:0;border-radius:12px\" allow=\"autoplay; encrypted-media; picture-in-picture; fullscreen\" allowfullscreen loading=\"lazy\"></iframe>'; }\n else { box.innerHTML='<div style=\"color:#8b8b8b;font-size:13px\">Brano non trovato su YouTube. Prova «Apri in Spotify».</div>'; }\n }).catch(()=>{ const box=$(\"#musicPlayer\"); if(box) box.innerHTML='<div style=\"color:#8b8b8b;font-size:13px\">Player non caricato (riavvia l\\'estensione?).</div>'; });\n // 2) Testo canzone.\n fetch(\"/music/lyrics?artist=\"+encodeURIComponent(s.artist)+\"&title=\"+encodeURIComponent(s.title)+\"&t=\"+encodeURIComponent(T))\n .then(r=>r.ok?r.json():null).then(j=>{\n const box=$(\"#musicLyricsBox\"); if(!box) return;\n if(j&&j.found){ box.style.color=\"#e8e8e8\"; box.innerHTML='<pre style=\"white-space:pre-wrap;font-family:inherit;font-size:15px;line-height:1.75;color:#e8e8e8;margin:0\">'+escapeHtml(j.lyrics)+'</pre>'; }\n else { box.style.color=\"#8b8b8b\"; box.textContent=\"Testo non trovato per questo brano.\"; }\n }).catch(()=>{ const box=$(\"#musicLyricsBox\"); if(box){ box.style.color=\"#8b8b8b\"; box.textContent=\"Testo non caricato.\"; } });\n}\n$(\"#musicBtn\").onclick=musicOpen;\n$(\"#musicClose\").onclick=musicClose;\n$(\"#musicGo\").onclick=musicSearch;\n$(\"#musicQuery\").addEventListener(\"keydown\",e=>{ if(e.key===\"Enter\") musicSearch(); });\n\n// ★ 2026-07-25 — Pulsante 🌓 (solo telefono): Automatico → Chiaro → Scuro.\nfunction themeIcon(mode){\n var b=document.getElementById(\"themeBtn\"); if(!b) return;\n b.textContent = mode===\"auto\" ? \"🌓\" : mode===\"light\" ? \"☀️\" : \"🌙\";\n b.title = mode===\"auto\" ? \"Tema: automatico (segui il telefono)\" : mode===\"light\" ? \"Tema: chiaro (forzato)\" : \"Tema: scuro (forzato)\";\n}\nif(document.documentElement.classList.contains(\"phone\")){\n themeIcon(window.__agThemeMode||\"auto\");\n $(\"#themeBtn\").addEventListener(\"click\",function(){\n var cur=window.__agThemeMode||\"auto\";\n var next=cur===\"auto\"?\"light\":cur===\"light\"?\"dark\":\"auto\";\n localStorage.setItem(\"ag-theme\",next); window.__agThemeMode=next;\n if(window.__agApplyTheme) window.__agApplyTheme(next);\n themeIcon(next);\n });\n}\n// ===== ★ 2026-07-30 — CANTIERE + PIATTAFORME ==============================\n// Il cantiere è la risposta a \"me lo distruggono mentre lo costruisco\": finché è\n// aperto, selfHeal / torna indietro / Hermes / la sync dell'estensione NON toccano\n// i file dichiarati. \"Riprendi\" rimanda al modello il punto esatto in cui eri.\n\nasync function cantiereAggiorna(){\n try{\n const j = await (await fetch(apiUrl(\"/cantiere\"))).json();\n const c = j.cantiere || {};\n const box = $(\"#cantiereStato\");\n if(!c.aperto){\n box.textContent = \"Nessun cantiere aperto: le manutenzioni automatiche sono libere di intervenire.\";\n $(\"#cantiereChiuso\").style.display=\"\"; $(\"#cantiereAperto\").style.display=\"none\";\n return;\n }\n $(\"#cantiereChiuso\").style.display=\"none\"; $(\"#cantiereAperto\").style.display=\"\";\n box.textContent =\n \"🔒 «\"+c.titolo+\"» — \"+c.files.length+\" file protetti, \"+c.passi+\" passi\"\n + (c.blocchi ? \", \"+c.blocchi+\" tentativi bloccati\" : \"\")\n + (c.sospeso ? \"\\n⚠️ fermo da più di 24h (resta protetto)\" : \"\")\n + (c.prossimo ? \"\\n➡️ prossimo: \"+c.prossimo : \"\")\n + (c.ultimoBlocco ? \"\\n🛡️ ultimo bloccato: \"+c.ultimoBlocco.chi+\" su \"+c.ultimoBlocco.file : \"\");\n }catch(e){ $(\"#cantiereStato\").textContent = \"Stato cantiere non leggibile: \"+e.message; }\n}\n\n$(\"#cantApri\").addEventListener(\"click\", async () => {\n const titolo = $(\"#cantTitolo\").value.trim();\n if(!titolo){ $(\"#cantiereStato\").textContent = \"Serve un titolo: è come ritrovi il lavoro quando riprendi.\"; return; }\n const files = $(\"#cantFiles\").value.split(\",\").map(s=>s.trim()).filter(Boolean);\n try{\n const r = await fetch(apiUrl(\"/cantiere/apri\"), { method:\"POST\", headers:{\"Content-Type\":\"application/json\"},\n body: JSON.stringify({ titolo, files, origine:\"mobile\" }) });\n const j = await r.json();\n if(j.nota) $(\"#cantiereStato\").textContent = j.nota;\n $(\"#cantTitolo\").value=\"\"; $(\"#cantFiles\").value=\"\";\n cantiereAggiorna();\n }catch(e){ $(\"#cantiereStato\").textContent = \"Errore: \"+e.message; }\n});\n\n$(\"#cantSegna\").addEventListener(\"click\", async () => {\n const testo = $(\"#cantPasso\").value.trim();\n if(!testo) return;\n await fetch(apiUrl(\"/cantiere/passo\"), { method:\"POST\", headers:{\"Content-Type\":\"application/json\"},\n body: JSON.stringify({ testo, prossimo: $(\"#cantProssimo\").value.trim() || undefined }) });\n $(\"#cantPasso\").value=\"\";\n cantiereAggiorna();\n});\n\n// Un pulsante solo al posto di \"procedi, continua, riprendi da dove ti eri\n// interrotto\": il mandato lo ricostruisce il server dal diario e lo manda in coda.\n$(\"#cantRiprendi\").addEventListener(\"click\", async () => {\n try{\n const j = await (await fetch(apiUrl(\"/cantiere/ripresa\")+\"&invia=1\")).json();\n if(!j.ok) $(\"#cantiereStato\").textContent = j.testo || \"Nessun cantiere da riprendere.\";\n }catch(e){ $(\"#cantiereStato\").textContent = \"Errore: \"+e.message; }\n});\n\n$(\"#cantChiudi\").addEventListener(\"click\", async () => {\n if(!confirm(\"Chiudo il cantiere? Da quel momento le manutenzioni automatiche possono di nuovo toccare quei file.\")) return;\n await fetch(apiUrl(\"/cantiere/chiudi\"), { method:\"POST\", headers:{\"Content-Type\":\"application/json\"},\n body: JSON.stringify({ esito: \"chiuso dal telefono\" }) });\n cantiereAggiorna();\n});\n\nasync function piattaformeAggiorna(){\n const box = $(\"#piattElenco\");\n try{\n const j = await (await fetch(apiUrl(\"/piattaforme/scelte\"))).json();\n box.innerHTML = \"\";\n (j.piattaforme||[]).forEach(p => {\n const riga = document.createElement(\"div\");\n riga.style.cssText = \"display:flex;align-items:center;gap:6px;margin:4px 0;flex-wrap:wrap\";\n const et = document.createElement(\"span\");\n et.style.cssText = \"flex:1;min-width:120px\";\n et.textContent = (p.stato===\"acceso\"?\"🟢 \":p.cablata?\"⚪ \":\"🔧 \") + p.nome\n + (p.uncensoredOk ? \"\" : \" ⚠️ senza filtri vietati\")\n + (p.cablata ? \"\" : \" (driver da scrivere)\");\n riga.appendChild(et);\n if(p.cablata && p.configurato){\n const on = document.createElement(\"button\");\n on.className=\"btn sm\"; on.textContent = p.stato===\"acceso\" ? \"spegni\" : \"accendi\";\n on.addEventListener(\"click\", async () => {\n on.disabled = true;\n const rotta = p.stato===\"acceso\" ? \"/piattaforme/spegni\" : \"/piattaforme/accendi\";\n try{ await fetch(apiUrl(rotta), { method:\"POST\", headers:{\"Content-Type\":\"application/json\"}, body: JSON.stringify({ id: p.id }) }); }\n finally{ on.disabled=false; piattaformeAggiorna(); }\n });\n riga.appendChild(on);\n } else if(p.cablata){\n const cfgb = document.createElement(\"button\");\n cfgb.className=\"btn sm\"; cfgb.textContent=\"configura\";\n cfgb.addEventListener(\"click\", async () => {\n const d = await (await fetch(apiUrl(\"/piattaforme/domande\")+\"&id=\"+encodeURIComponent(p.id))).json();\n const valori = {};\n for(const c of (d.campi||[])){\n const v = prompt(c.etichetta + \"\\n\\nDove si prende:\\n\" + (c.dove||\"\"), \"\");\n if(v) valori[c.chiave] = v;\n }\n if(!Object.keys(valori).length) return;\n const s = await (await fetch(apiUrl(\"/piattaforme/salva\"), { method:\"POST\", headers:{\"Content-Type\":\"application/json\"},\n body: JSON.stringify({ id: p.id, valori }) })).json();\n alert(s.prossimo || s.error || \"Salvato.\");\n piattaformeAggiorna();\n });\n riga.appendChild(cfgb);\n }\n box.appendChild(riga);\n });\n if(j.consigliata){\n const n = document.createElement(\"div\");\n n.style.cssText=\"margin-top:6px;font-size:11px\";\n n.textContent = \"Consigliata: \" + j.consigliata.nome + \" (gratis, sempre accesa, i tuoi modelli sono ammessi).\";\n box.appendChild(n);\n }\n }catch(e){ box.textContent = \"Piattaforme non leggibili: \"+e.message; }\n}\n$(\"#piattAggiorna\").addEventListener(\"click\", piattaformeAggiorna);\n\ncantiereAggiorna(); piattaformeAggiorna();\nsetInterval(cantiereAggiorna, 60000);\n\nconnect(); loadModels();\n</script>\n</body>\n</html>\n"}}}}