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.
1 line
203 KiB
JSON
1 line
203 KiB
JSON
{"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,\"&\").replace(/</g,\"<\").replace(/>/g,\">\"); }\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 : 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=>({\"&\":\"&\",\"<\":\"<\",\">\":\">\",'\"':\""\"}[c])); }\nfunction escapeAttr(s){ return String(s==null?\"\":s).replace(/\"/g,\""\"); }\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"}}}} |