Загрузка данных
<!DOCTYPE html><html lang="ru"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Чат</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Lora:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<style>
:root{
--bg:#0e1014; --panel:#14171d; --elev:#1b1f27; --elev2:#232833; --border:#2a303c;
--text:#e7e9ee; --muted:#9aa3b2; --faint:#6b7382;
--accent:#3ecf8e; --accent-d:#2fa874; --accent-bg:rgba(62,207,142,.12);
--danger:#f16a6a; --radius:14px;
--font:'Inter',system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
--mono:'JetBrains Mono',ui-monospace,Consolas,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:15px;line-height:1.6;overflow:hidden;-webkit-font-smoothing:antialiased}
button{font:inherit;color:inherit;background:none;border:none;cursor:pointer}
input,textarea,select{font:inherit;color:inherit}
svg{width:18px;height:18px;flex-shrink:0;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
a{color:var(--accent)}
::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-thumb{background:#2c323e;border-radius:8px}::-webkit-scrollbar-track{background:transparent}
.hidden{display:none!important}
/* ---------- SETUP ---------- */
#setup{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:20px;overflow:auto;
background:radial-gradient(900px 500px at 50% -10%,rgba(62,207,142,.10),transparent 60%),var(--bg)}
.setup-card{width:100%;max-width:620px;background:var(--panel);border:1px solid var(--border);border-radius:22px;padding:34px;box-shadow:0 30px 80px rgba(0,0,0,.45)}
.logo{width:52px;height:52px;border-radius:14px;background:var(--accent-bg);color:var(--accent);display:grid;place-items:center;margin:0 auto 16px}
.logo svg{width:28px;height:28px}
.setup-card h1{text-align:center;font-size:24px;font-weight:700;letter-spacing:-.02em}
.setup-card .sub{text-align:center;color:var(--muted);margin:6px 0 26px}
.prov-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.prov{display:flex;align-items:center;gap:12px;padding:14px;border:1px solid var(--border);border-radius:var(--radius);background:var(--elev);text-align:left;transition:.15s}
.prov:hover{border-color:#3a4252;background:var(--elev2)}
.prov.active{border-color:var(--accent);background:var(--accent-bg)}
.prov .pi{width:38px;height:38px;border-radius:10px;display:grid;place-items:center;background:var(--elev2);color:var(--accent)}
.prov b{display:block;font-weight:600}
.prov small{color:var(--muted);font-size:12.5px}
.key-area{margin-top:22px;display:flex;flex-direction:column;gap:12px}
.field label{display:block;font-size:13px;color:var(--muted);margin-bottom:6px}
.inp{width:100%;background:var(--elev);border:1px solid var(--border);border-radius:12px;padding:12px 14px;outline:none;transition:.15s}
.inp:focus{border-color:var(--accent)}
.inp-wrap{position:relative}
.inp-wrap .inp{padding-right:44px}
.inp-wrap button{position:absolute;right:8px;top:50%;transform:translateY(-50%);color:var(--muted);padding:6px;border-radius:8px}
.inp-wrap button:hover{color:var(--text);background:var(--elev2)}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:12px 18px;border-radius:12px;font-weight:600;transition:.15s}
.btn-primary{background:var(--accent);color:#06140d}
.btn-primary:hover{background:#5bdca2}
.btn-primary:disabled{opacity:.5;cursor:not-allowed}
.btn-ghost{border:1px solid var(--border);background:var(--elev)}
.btn-ghost:hover{background:var(--elev2)}
.hint{font-size:12.5px;color:var(--faint)}
.err{color:var(--danger);font-size:13.5px}
.spin{animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
/* ---------- APP ---------- */
#app{display:flex;height:100vh;height:100dvh}
#sidebar{width:280px;flex-shrink:0;background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;transition:margin .25s ease,transform .25s ease}
#app.side-closed #sidebar{margin-left:-280px}
.side-top{padding:14px;display:flex;gap:8px}
.side-top .btn{flex:1;padding:10px 12px}
.side-search{margin:0 14px 8px;position:relative}
.side-search svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--faint);width:16px;height:16px}
.side-search input{width:100%;background:var(--elev);border:1px solid var(--border);border-radius:10px;padding:8px 10px 8px 34px;outline:none;font-size:14px}
.chat-list{flex:1;overflow-y:auto;padding:4px 8px}
.group-title{font-size:11.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--faint);padding:12px 10px 6px}
.chat-item{display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:10px;cursor:pointer;color:var(--muted);font-size:14px}
.chat-item:hover{background:var(--elev);color:var(--text)}
.chat-item.active{background:var(--elev2);color:var(--text)}
.chat-item span{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chat-item .del{opacity:0;padding:4px;border-radius:6px;color:var(--faint)}
.chat-item:hover .del{opacity:1}
.chat-item .del:hover{color:var(--danger);background:rgba(241,106,106,.1)}
.chat-item svg{width:16px;height:16px}
.side-bottom{border-top:1px solid var(--border);padding:10px}
.side-link{display:flex;align-items:center;gap:10px;width:100%;padding:10px;border-radius:10px;color:var(--muted);font-size:14px}
.side-link:hover{background:var(--elev);color:var(--text)}
.prov-badge{display:flex;align-items:center;gap:10px;padding:10px;font-size:13px;color:var(--muted)}
.dot{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent)}
#main{flex:1;display:flex;flex-direction:column;min-width:0;position:relative}
.topbar{height:58px;display:flex;align-items:center;gap:8px;padding:0 14px;border-bottom:1px solid var(--border);background:rgba(14,16,20,.85);backdrop-filter:blur(10px);z-index:5}
.icon-btn{width:38px;height:38px;border-radius:10px;display:grid;place-items:center;color:var(--muted);transition:.15s}
.icon-btn:hover{background:var(--elev);color:var(--text)}
.icon-btn.on{color:var(--accent);background:var(--accent-bg)}
.model-btn{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:10px;font-weight:600;max-width:420px}
.model-btn:hover{background:var(--elev)}
.model-btn span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.model-btn svg{width:16px;height:16px;color:var(--muted)}
.model-pop{position:absolute;top:54px;left:60px;width:min(440px,calc(100vw - 24px));background:var(--elev);border:1px solid var(--border);border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.5);z-index:30;display:flex;flex-direction:column;max-height:60vh}
.model-pop .ms{padding:10px;border-bottom:1px solid var(--border)}
.model-pop .ml{overflow-y:auto;padding:6px}
.m-item{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;text-align:left;padding:9px 10px;border-radius:9px;font-size:14px;font-family:var(--mono)}
.m-item:hover,.m-item.sel{background:var(--elev2)}
.m-item.sel{color:var(--accent)}
.tag{font-family:var(--font);font-size:11px;padding:2px 7px;border-radius:20px;background:var(--accent-bg);color:var(--accent);flex-shrink:0}
.spacer{flex:1}
#messages{flex:1;overflow-y:auto;scroll-behavior:auto}
.thread{max-width:820px;margin:0 auto;padding:28px 20px 40px}
.empty{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:20px}
.empty h2{font-size:28px;letter-spacing:-.02em;margin:14px 0 6px}
.empty p{color:var(--muted)}
.suggest{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:26px;max-width:620px;width:100%}
.suggest button{text-align:left;padding:14px;border:1px solid var(--border);border-radius:12px;background:var(--panel);color:var(--muted);font-size:14px;display:flex;gap:10px;align-items:flex-start}
.suggest button:hover{border-color:#3a4252;color:var(--text)}
.suggest svg{color:var(--accent);margin-top:2px}
.msg{margin-bottom:26px}
.msg.user{display:flex;flex-direction:column;align-items:flex-end}
.msg.user .bubble{background:var(--elev2);border:1px solid var(--border);padding:11px 15px;border-radius:18px 18px 4px 18px;max-width:85%;white-space:pre-wrap;word-wrap:break-word}
.att-row{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;justify-content:flex-end}
.att-row img{width:120px;height:90px;object-fit:cover;border-radius:10px;border:1px solid var(--border)}
.file-chip{display:inline-flex;align-items:center;gap:8px;padding:8px 10px;background:var(--elev);border:1px solid var(--border);border-radius:10px;font-size:13px;max-width:240px}
.file-chip span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.file-chip svg{color:var(--accent);width:16px;height:16px}
.ai-head{display:flex;align-items:center;gap:8px;margin-bottom:6px;color:var(--muted);font-size:13px}
.ai-av{width:26px;height:26px;border-radius:8px;background:var(--accent-bg);color:var(--accent);display:grid;place-items:center}
.ai-av svg{width:15px;height:15px}
.status{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13.5px;padding:6px 0}
.status svg{width:15px;height:15px;color:var(--accent)}
.typing span{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--muted);margin-right:4px;animation:blink 1.2s infinite}
.typing span:nth-child(2){animation-delay:.2s}.typing span:nth-child(3){animation-delay:.4s}
@keyframes blink{0%,80%,100%{opacity:.25}40%{opacity:1}}
.md{word-wrap:break-word}
.md>*:first-child{margin-top:0}
.md p,.md ul,.md ol,.md blockquote,.md table{margin:10px 0}
.md ul,.md ol{padding-left:24px}
.md li{margin:4px 0}
.md h1,.md h2,.md h3,.md h4{margin:18px 0 8px;line-height:1.3}
.md h1{font-size:22px}.md h2{font-size:19px}.md h3{font-size:17px}
.md code{font-family:var(--mono);font-size:13.5px;background:var(--elev2);padding:2px 6px;border-radius:6px}
.md blockquote{border-left:3px solid var(--accent);padding-left:14px;color:var(--muted)}
.md table{border-collapse:collapse;width:100%;display:block;overflow-x:auto}
.md th,.md td{border:1px solid var(--border);padding:7px 11px;text-align:left}
.md th{background:var(--elev)}
.md hr{border:none;border-top:1px solid var(--border);margin:16px 0}
.md img{max-width:100%;border-radius:10px}
details.think{margin:6px 0 12px;border:1px solid var(--border);border-radius:10px;background:var(--panel)}
details.think summary{cursor:pointer;padding:8px 12px;color:var(--muted);font-size:13px}
details.think div{padding:0 12px 10px;color:var(--faint);font-size:13.5px;white-space:pre-wrap;max-height:300px;overflow:auto}
.code-card,.poem-card{margin:14px 0;border:1px solid var(--border);border-radius:12px;overflow:hidden;background:#0b0d11}
.card-head{display:flex;align-items:center;gap:6px;padding:6px 8px 6px 14px;background:var(--elev);border-bottom:1px solid var(--border);font-size:12.5px;color:var(--muted)}
.card-head .lang{flex:1;font-family:var(--mono);text-transform:lowercase;display:flex;align-items:center;gap:8px}
.card-head .lang svg{width:15px;height:15px;color:var(--accent)}
.cbtn{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:7px;font-size:12.5px;color:var(--muted)}
.cbtn:hover{background:var(--elev2);color:var(--text)}
.cbtn svg{width:15px;height:15px}
.code-card pre{margin:0;padding:14px 16px;overflow-x:auto;max-height:480px}
.code-card pre code{background:none!important;padding:0!important;font-family:var(--mono);font-size:13.5px;line-height:1.55}
.poem-card{background:linear-gradient(180deg,rgba(62,207,142,.05),transparent),var(--panel)}
.poem-body{padding:20px 24px;font-family:'Lora',Georgia,serif;font-size:16.5px;line-height:1.8;white-space:pre-wrap;font-style:italic}
.actions{display:flex;gap:2px;margin-top:8px}
.act{display:inline-flex;align-items:center;gap:6px;padding:6px 8px;border-radius:8px;color:var(--faint);font-size:12.5px}
.act:hover{background:var(--elev);color:var(--text)}
.act.on{color:var(--accent)}
.act svg{width:16px;height:16px}
.sources{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
.src{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:20px;background:var(--panel);border:1px solid var(--border);font-size:12.5px;color:var(--muted);text-decoration:none;max-width:260px}
.src:hover{color:var(--text);border-color:#3a4252}
.src span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.src svg{width:13px;height:13px}
.err-box{border:1px solid rgba(241,106,106,.35);background:rgba(241,106,106,.08);color:#f3a3a3;padding:10px 14px;border-radius:10px;font-size:14px}
/* composer */
.composer-wrap{padding:0 20px 18px}
.composer{max-width:820px;margin:0 auto;background:var(--panel);border:1px solid var(--border);border-radius:20px;padding:10px;transition:border-color .15s}
.composer:focus-within{border-color:#3a4252}
.composer.drag{border-color:var(--accent);background:var(--accent-bg)}
.pending{display:flex;flex-wrap:wrap;gap:8px;padding:4px 4px 8px}
.pend{position:relative}
.pend img{width:64px;height:64px;object-fit:cover;border-radius:10px;border:1px solid var(--border);display:block}
.pend .x{position:absolute;top:-6px;right:-6px;width:20px;height:20px;border-radius:50%;background:var(--elev2);border:1px solid var(--border);display:grid;place-items:center}
.pend .x svg{width:11px;height:11px}
#input{width:100%;background:none;border:none;outline:none;resize:none;padding:6px 8px;max-height:220px;min-height:28px;line-height:1.5}
.comp-row{display:flex;align-items:center;gap:4px}
.pill{display:inline-flex;align-items:center;gap:6px;padding:7px 11px;border-radius:20px;color:var(--muted);font-size:13px;border:1px solid transparent}
.pill:hover{background:var(--elev);color:var(--text)}
.pill.on{color:var(--accent);background:var(--accent-bg);border-color:rgba(62,207,142,.3)}
.pill svg{width:16px;height:16px}
.send{width:38px;height:38px;border-radius:12px;background:var(--accent);color:#06140d;display:grid;place-items:center}
.send:disabled{background:var(--elev2);color:var(--faint);cursor:not-allowed}
.send.stop{background:var(--text)}
.foot{text-align:center;color:var(--faint);font-size:12px;margin-top:8px}
/* modal */
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);backdrop-filter:blur(4px);z-index:50;display:flex;align-items:center;justify-content:center;padding:20px}
.modal{width:100%;max-width:880px;max-height:88vh;background:var(--panel);border:1px solid var(--border);border-radius:18px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 30px 90px rgba(0,0,0,.6)}
.modal-head{display:flex;align-items:center;gap:8px;padding:12px 14px 12px 18px;border-bottom:1px solid var(--border)}
.modal-head h3{flex:1;font-size:16px;display:flex;align-items:center;gap:10px}
.modal-head h3 svg{color:var(--accent)}
.modal-body{overflow:auto;flex:1}
.modal-body pre{margin:0;padding:18px 20px;font-family:var(--mono);font-size:14px;line-height:1.6}
.modal-body pre code{background:none!important;padding:0!important}
.modal-body .poem-body{font-size:18px;padding:28px 34px}
.modal-body iframe{width:100%;height:70vh;border:none;background:#fff}
.modal.small{max-width:520px}
.form{padding:18px;display:flex;flex-direction:column;gap:16px}
.row2{display:flex;gap:10px;align-items:center}
.toggle{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:14px}
.switch{width:40px;height:22px;border-radius:20px;background:var(--elev2);position:relative;transition:.2s;flex-shrink:0;border:1px solid var(--border)}
.switch::after{content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--muted);transition:.2s}
.switch.on{background:var(--accent)}.switch.on::after{left:20px;background:#06140d}
#toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(20px);background:var(--elev2);border:1px solid var(--border);padding:10px 16px;border-radius:12px;font-size:14px;opacity:0;pointer-events:none;transition:.25s;z-index:100;display:flex;gap:8px;align-items:center}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
#toast svg{color:var(--accent);width:16px;height:16px}
.backdrop{display:none}
@media (max-width:820px){
#sidebar{position:fixed;left:0;top:0;bottom:0;z-index:40;margin-left:0!important;transform:translateX(0)}
#app.side-closed #sidebar{transform:translateX(-100%)}
#app:not(.side-closed) .backdrop{display:block;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:35}
.prov-grid,.suggest{grid-template-columns:1fr}
.setup-card{padding:24px}
.thread{padding:20px 14px}
.composer-wrap{padding:0 10px 12px}
.pill .lbl{display:none}
.model-pop{left:12px}
}
</style>
</head>
<body>
<!-- ========== SETUP SCREEN ========== -->
<div id="setup" class="">
<div class="setup-card">
<div class="logo" data-icon="spark" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></div>
<h1>Подключите AI-провайдера</h1>
<p class="sub">Выберите, для какого сервиса у вас API-ключ</p>
<div class="prov-grid" id="provGrid"><button class="prov" data-id="groq"><div class="pi"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></div><div><b>Groq</b><small>Очень быстрые Llama, Qwen, GPT-OSS</small></div></button><button class="prov" data-id="openrouter"><div class="pi"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></div><div><b>OpenRouter</b><small>Сотни моделей через один ключ</small></div></button><button class="prov" data-id="unorouter"><div class="pi"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></div><div><b>UnoRouter</b><small>230+ моделей, есть бесплатные</small></div></button><button class="prov" data-id="openai"><div class="pi"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></div><div><b>OpenAI Compatible</b><small>OpenAI или любой совместимый API</small></div></button></div>
<div class="key-area hidden" id="keyArea">
<div class="field hidden" id="baseField">
<label>Base URL (OpenAI-совместимый API)</label>
<input class="inp" id="baseInput" placeholder="https://api.openai.com/v1">
</div>
<div class="field">
<label id="keyLabel">API-ключ</label>
<div class="inp-wrap">
<input class="inp" id="keyInput" type="password" autocomplete="off" placeholder="Вставьте ключ">
<button id="eyeBtn" title="Показать/скрыть" data-icon="eye" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></button>
</div>
<p class="hint" style="margin-top:6px">Ключ хранится только в вашем браузере (localStorage). <a id="keyLink" target="_blank" rel="noopener">Где взять ключ?</a></p>
</div>
<p class="err hidden" id="setupErr"></p>
<div class="row2">
<button class="btn btn-primary" id="connectBtn" style="flex:1"><span data-icon="plug" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></span>Подключить</button>
<button class="btn btn-ghost hidden" id="skipBtn">Без проверки</button>
</div>
<button class="btn btn-ghost hidden" id="backToChat"><span data-icon="arrowLeft" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></span>Вернуться в чат</button>
</div>
</div>
</div>
<!-- ========== APP ========== -->
<div id="app" class="hidden">
<div class="backdrop" id="backdrop"></div>
<aside id="sidebar">
<div class="side-top">
<button class="btn btn-ghost" id="newChat"><span data-icon="plus" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></span>Новый чат</button>
<button class="icon-btn" id="closeSide" title="Скрыть историю" data-icon="panel" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></button>
</div>
<div class="side-search"><span data-icon="search" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></span><input id="histSearch" placeholder="Поиск по чатам"></div>
<div class="chat-list" id="chatList"></div>
<div class="side-bottom">
<div class="prov-badge"><span class="dot"></span><span id="provName"></span></div>
<button class="side-link" id="changeProv"><span data-icon="key" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></span>Сменить провайдера / ключ</button>
<button class="side-link" id="openSettings"><span data-icon="settings" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></span>Настройки</button>
</div>
</aside>
<main id="main">
<div class="topbar">
<button class="icon-btn" id="toggleSide" title="История чатов" data-icon="history" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></button>
<button class="model-btn" id="modelBtn"><span id="modelName">Модель</span><i data-icon="chevron" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></i></button>
<div class="spacer"></div>
<button class="icon-btn" id="newChat2" title="Новый чат" data-icon="edit" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></button>
</div>
<div class="model-pop hidden" id="modelPop">
<div class="ms"><input class="inp" id="modelSearch" placeholder="Поиск модели или введите ID"></div>
<div class="ml" id="modelList"></div>
</div>
<div id="messages"><div class="thread" id="thread"></div></div>
<div class="composer-wrap">
<div class="composer" id="composer">
<div class="pending hidden" id="pending"></div>
<textarea id="input" rows="1" placeholder="Напишите сообщение…"></textarea>
<div class="comp-row">
<button class="pill" id="attachBtn" title="Прикрепить файл"><span data-icon="clip" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></span><span class="lbl">Файл</span></button>
<button class="pill" id="webBtn" title="Доступ к интернету"><span data-icon="globe" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></span><span class="lbl">Интернет</span></button>
<div class="spacer"></div>
<button class="send" id="sendBtn" disabled="" title="Отправить" data-icon="send" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></button>
</div>
<input type="file" id="fileInput" multiple="" hidden="">
</div>
<p class="foot">ИИ может ошибаться. Проверяйте важную информацию.</p>
</div>
</main>
</div>
<div id="modalRoot"></div>
<div id="toast"><span data-icon="check" data-done="1"><svg width="1" height="1" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="transparent"></rect></svg></span><span id="toastText"></span></div>
<script>
/* ================= ICONS (SVG) ================= */
const I = {
spark:'<svg viewBox="0 0 24 24"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/></svg>',
bolt:'<svg viewBox="0 0 24 24"><path d="M13 2L3 14h9l-1 8 10-12h-9z"/></svg>',
route:'<svg viewBox="0 0 24 24"><circle cx="6" cy="19" r="3"/><circle cx="18" cy="5" r="3"/><path d="M9 19h8.5a3.5 3.5 0 000-7h-11a3.5 3.5 0 010-7H15"/></svg>',
layers:'<svg viewBox="0 0 24 24"><path d="M12 2l10 5-10 5L2 7z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>',
cube:'<svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><path d="M3.3 7L12 12l8.7-5M12 22V12"/></svg>',
eye:'<svg viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',
plug:'<svg viewBox="0 0 24 24"><path d="M12 22v-5M9 8V2M15 8V2M18 8v5a6 6 0 01-12 0V8z"/></svg>',
arrowLeft:'<svg viewBox="0 0 24 24"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>',
plus:'<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>',
panel:'<svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/></svg>',
history:'<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5M12 7v5l4 2"/></svg>',
search:'<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>',
key:'<svg viewBox="0 0 24 24"><circle cx="7.5" cy="15.5" r="5.5"/><path d="M21 2l-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>',
settings:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>',
chevron:'<svg viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>',
edit:'<svg viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4z"/></svg>',
clip:'<svg viewBox="0 0 24 24"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>',
globe:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>',
send:'<svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg>',
stop:'<svg viewBox="0 0 24 24" style="fill:currentColor;stroke:none"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>',
check:'<svg viewBox="0 0 24 24"><path d="M20 6L9 17l-5-5"/></svg>',
copy:'<svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>',
speaker:'<svg viewBox="0 0 24 24"><path d="M11 5L6 9H2v6h4l5 4z"/><path d="M15.54 8.46a5 5 0 010 7.07M19.07 4.93a10 10 0 010 14.14"/></svg>',
speakerOff:'<svg viewBox="0 0 24 24"><path d="M11 5L6 9H2v6h4l5 4z"/><path d="M23 9l-6 6M17 9l6 6"/></svg>',
refresh:'<svg viewBox="0 0 24 24"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>',
trash:'<svg viewBox="0 0 24 24"><path d="M3 6h18M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>',
msg:'<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>',
code:'<svg viewBox="0 0 24 24"><path d="M16 18l6-6-6-6M8 6l-6 6 6 6"/></svg>',
feather:'<svg viewBox="0 0 24 24"><path d="M20.24 12.24a6 6 0 00-8.49-8.49L5 10.5V19h8.5z"/><path d="M16 8L2 22M17.5 15H9"/></svg>',
expand:'<svg viewBox="0 0 24 24"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>',
download:'<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>',
play:'<svg viewBox="0 0 24 24"><path d="M5 3l14 9-14 9z"/></svg>',
x:'<svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg>',
file:'<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></svg>',
link:'<svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>',
loader:'<svg viewBox="0 0 24 24" class="spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>',
lightbulb:'<svg viewBox="0 0 24 24"><path d="M9 18h6M10 22h4M12 2a7 7 0 00-4 12.7V17h8v-2.3A7 7 0 0012 2z"/></svg>',
};
function ico(n){return I[n]||''}
function hydrateIcons(root=document){root.querySelectorAll('[data-icon]').forEach(el=>{if(!el.dataset.done){el.insertAdjacentHTML('afterbegin',ico(el.dataset.icon));el.dataset.done=1}})}
/* ================= CONFIG ================= */
const PROVIDERS = {
groq:{name:'Groq',desc:'Очень быстрые Llama, Qwen, GPT-OSS',icon:'bolt',base:'https://api.groq.com/openai/v1',keyUrl:'https://console.groq.com/keys',ph:'gsk_...',pref:['llama-3.3-70b-versatile','openai/gpt-oss-120b']},
openrouter:{name:'OpenRouter',desc:'Сотни моделей через один ключ',icon:'route',base:'https://openrouter.ai/api/v1',keyUrl:'https://openrouter.ai/keys',ph:'sk-or-v1-...',pref:['openai/gpt-4o-mini','google/gemini-2.0-flash-001']},
unorouter:{name:'UnoRouter',desc:'230+ моделей, есть бесплатные',icon:'layers',base:'https://api.unorouter.com/v1',keyUrl:'https://unorouter.com/en',ph:'sk-...',pref:['gpt-oss-120b:free']},
openai:{name:'OpenAI Compatible',desc:'OpenAI или любой совместимый API',icon:'cube',base:'https://api.openai.com/v1',keyUrl:'https://platform.openai.com/api-keys',ph:'sk-...',custom:true,pref:['gpt-4o-mini','gpt-4.1-mini']},
};
const LS = {get(k,d){try{const v=localStorage.getItem(k);return v?JSON.parse(v):d}catch{return d}},set(k,v){localStorage.setItem(k,JSON.stringify(v))}};
const cfg = Object.assign({provider:null,keys:{},bases:{},models:{},web:true,jinaKey:'',voice:'',rate:1,orNative:false,sysExtra:''}, LS.get('aichat.cfg',{}));
let chats = LS.get('aichat.chats',[]);
let currentId = null, modelList = [], abortCtl = null, pending = [], busy = false, setupSel = null;
const $ = s=>document.querySelector(s);
const saveCfg = ()=>LS.set('aichat.cfg',cfg);
function saveChats(){
try{LS.set('aichat.chats',chats)}catch(e){
// переполнение хранилища: удаляем картинки из старых чатов
chats.forEach((c,i)=>{if(c.id!==currentId)c.messages.forEach(m=>(m.files||[]).forEach(f=>{if(f.dataUrl){f.dataUrl=null}}))});
try{LS.set('aichat.chats',chats)}catch{toast('Хранилище переполнено — удалите старые чаты')}
}
}
const base = ()=> (cfg.bases[cfg.provider]||PROVIDERS[cfg.provider].base).replace(/\/+$/,'');
const key = ()=> cfg.keys[cfg.provider]||'';
const model = ()=> cfg.models[cfg.provider]||'';
function headers(){
const h={'Content-Type':'application/json','Authorization':'Bearer '+key()};
if(cfg.provider==='openrouter'){h['HTTP-Referer']=location.origin||'https://localhost';h['X-Title']='AI Chat'}
return h;
}
const esc = s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const uid = ()=>Date.now().toString(36)+Math.random().toString(36).slice(2,7);
function toast(t){$('#toastText').textContent=t;$('#toast').classList.add('show');clearTimeout(toast._t);toast._t=setTimeout(()=>$('#toast').classList.remove('show'),1800)}
async function copyText(t){try{await navigator.clipboard.writeText(t)}catch{const a=document.createElement('textarea');a.value=t;document.body.appendChild(a);a.select();document.execCommand('copy');a.remove()}toast('Скопировано')}
/* ================= SETUP ================= */
function showSetup(fromApp){
$('#app').classList.add('hidden');$('#setup').classList.remove('hidden');
$('#backToChat').classList.toggle('hidden',!(fromApp&&cfg.provider));
const g=$('#provGrid');g.innerHTML='';
Object.entries(PROVIDERS).forEach(([id,p])=>{
const b=document.createElement('button');b.className='prov';b.dataset.id=id;
b.innerHTML=`<div class="pi">${ico(p.icon)}</div><div><b>${p.name}</b><small>${p.desc}</small></div>`;
b.onclick=()=>selectProv(id);g.appendChild(b);
});
if(cfg.provider)selectProv(cfg.provider);
}
function selectProv(id){
setupSel=id;const p=PROVIDERS[id];
document.querySelectorAll('.prov').forEach(b=>b.classList.toggle('active',b.dataset.id===id));
$('#keyArea').classList.remove('hidden');
$('#baseField').classList.toggle('hidden',!p.custom);
$('#baseInput').value=cfg.bases[id]||p.base;
$('#keyInput').value=cfg.keys[id]||'';$('#keyInput').placeholder=p.ph;
$('#keyLabel').textContent='API-ключ '+p.name;
$('#keyLink').href=p.keyUrl;
$('#setupErr').classList.add('hidden');$('#skipBtn').classList.add('hidden');
setTimeout(()=>$('#keyInput').focus(),50);
}
$('#eyeBtn').onclick=()=>{const i=$('#keyInput');i.type=i.type==='password'?'text':'password'};
$('#keyInput').addEventListener('keydown',e=>{if(e.key==='Enter')$('#connectBtn').click()});
$('#connectBtn').onclick=async()=>{
const k=$('#keyInput').value.trim();if(!k){showSetupErr('Введите API-ключ');return}
const btn=$('#connectBtn');btn.disabled=true;btn.innerHTML=ico('loader')+'Проверяю ключ…';
const prevP=cfg.provider;cfg.provider=setupSel;cfg.keys[setupSel]=k;
if(PROVIDERS[setupSel].custom)cfg.bases[setupSel]=$('#baseInput').value.trim()||PROVIDERS[setupSel].base;
try{await loadModels(true);saveCfg();enterApp()}
catch(e){showSetupErr('Не удалось получить список моделей: '+e.message);$('#skipBtn').classList.remove('hidden');}
finally{btn.disabled=false;btn.innerHTML=ico('plug')+'Подключить'}
};
$('#skipBtn').onclick=()=>{saveCfg();enterApp()};
$('#backToChat').onclick=()=>{enterApp()};
function showSetupErr(t){const e=$('#setupErr');e.textContent=t;e.classList.remove('hidden')}
/* ================= MODELS ================= */
async function loadModels(strict){
const r=await fetch(base()+'/models',{headers:headers()});
if(!r.ok){let t='';try{t=(await r.json()).error?.message||''}catch{};throw new Error(`HTTP ${r.status} ${t}`)}
const j=await r.json();const arr=(j.data||j.models||j||[]);
modelList=arr.map(m=>{
const id=typeof m==='string'?m:m.id;
const free=/:free$/.test(id)||(m.pricing&&+m.pricing.prompt===0&&+m.pricing.completion===0);
return {id,free};
}).filter(m=>m.id&&!/whisper|tts|playai|orpheus|embedding|embed|dall-e|moderation|guard|image|audio|realtime|transcribe/i.test(m.id))
.sort((a,b)=>a.id.localeCompare(b.id));
if(!modelList.length&&strict)throw new Error('пустой список моделей');
if(!model()||!modelList.some(m=>m.id===model())){
const p=PROVIDERS[cfg.provider];
const pick=p.pref.find(x=>modelList.some(m=>m.id===x))||(modelList.find(m=>m.free)||modelList[0]||{}).id||p.pref[0];
cfg.models[cfg.provider]=pick;saveCfg();
}
}
function renderModelList(){
const q=$('#modelSearch').value.trim().toLowerCase();const l=$('#modelList');
const items=modelList.filter(m=>m.id.toLowerCase().includes(q)).slice(0,300);
let h=items.map(m=>`<button class="m-item ${m.id===model()?'sel':''}" data-id="${esc(m.id)}"><span>${esc(m.id)}</span>${m.free?'<span class="tag">free</span>':''}</button>`).join('');
if(q&&!modelList.some(m=>m.id.toLowerCase()===q))h+=`<button class="m-item" data-id="${esc($('#modelSearch').value.trim())}"><span>Использовать «${esc($('#modelSearch').value.trim())}»</span></button>`;
if(!h)h='<p class="hint" style="padding:10px">Модели не найдены. Введите ID вручную.</p>';
l.innerHTML=h;
}
$('#modelBtn').onclick=e=>{e.stopPropagation();const p=$('#modelPop');p.classList.toggle('hidden');if(!p.classList.contains('hidden')){$('#modelSearch').value='';renderModelList();$('#modelSearch').focus()}};
$('#modelSearch').oninput=renderModelList;
$('#modelList').onclick=e=>{const b=e.target.closest('.m-item');if(!b)return;cfg.models[cfg.provider]=b.dataset.id;saveCfg();$('#modelName').textContent=b.dataset.id;$('#modelPop').classList.add('hidden')};
document.addEventListener('click',e=>{if(!e.target.closest('#modelPop')&&!e.target.closest('#modelBtn'))$('#modelPop').classList.add('hidden')});
/* ================= APP ================= */
function enterApp(){
$('#setup').classList.add('hidden');$('#app').classList.remove('hidden');
$('#provName').textContent=PROVIDERS[cfg.provider].name;
$('#modelName').textContent=model()||'Выберите модель';
$('#webBtn').classList.toggle('on',cfg.web);
if(window.innerWidth<=820)$('#app').classList.add('side-closed');
if(!modelList.length)loadModels().then(()=>$('#modelName').textContent=model()).catch(()=>{});
renderHistory();
if(currentId&&chats.find(c=>c.id===currentId))openChat(currentId);else newChat();
$('#input').focus();
}
const curChat=()=>chats.find(c=>c.id===currentId);
function newChat(){currentId=null;renderThread();renderHistory();if(window.innerWidth<=820)$('#app').classList.add('side-closed')}
function openChat(id){currentId=id;renderThread();renderHistory();if(window.innerWidth<=820)$('#app').classList.add('side-closed')}
function renderHistory(){
const q=$('#histSearch').value.trim().toLowerCase();const l=$('#chatList');
const list=[...chats].sort((a,b)=>b.updated-a.updated).filter(c=>!q||c.title.toLowerCase().includes(q)||c.messages.some(m=>(m.content||'').toLowerCase().includes(q)));
if(!list.length){l.innerHTML='<p class="hint" style="padding:14px 10px">'+(q?'Ничего не найдено':'История пуста')+'</p>';return}
const now=new Date();const day=86400000;const sod=new Date(now.getFullYear(),now.getMonth(),now.getDate()).getTime();
const groups={'Сегодня':[],'Вчера':[],'Последние 7 дней':[],'Ранее':[]};
list.forEach(c=>{const t=c.updated;(t>=sod?groups['Сегодня']:t>=sod-day?groups['Вчера']:t>=sod-7*day?groups['Последние 7 дней']:groups['Ранее']).push(c)});
l.innerHTML=Object.entries(groups).filter(([,v])=>v.length).map(([g,v])=>`<div class="group-title">${g}</div>`+v.map(c=>`<div class="chat-item ${c.id===currentId?'active':''}" data-id="${c.id}">${ico('msg')}<span>${esc(c.title)}</span><button class="del" data-del="${c.id}" title="Удалить">${ico('trash')}</button></div>`).join('')).join('');
}
$('#chatList').onclick=e=>{
const d=e.target.closest('[data-del]');
if(d){e.stopPropagation();if(!confirm('Удалить этот чат?'))return;chats=chats.filter(c=>c.id!==d.dataset.del);saveChats();if(currentId===d.dataset.del)newChat();else renderHistory();return}
const it=e.target.closest('.chat-item');if(it)openChat(it.dataset.id);
};
$('#histSearch').oninput=renderHistory;
$('#newChat').onclick=newChat;$('#newChat2').onclick=newChat;
$('#toggleSide').onclick=()=>$('#app').classList.toggle('side-closed');
$('#closeSide').onclick=()=>$('#app').classList.add('side-closed');
$('#backdrop').onclick=()=>$('#app').classList.add('side-closed');
$('#changeProv').onclick=()=>showSetup(true);
$('#webBtn').onclick=()=>{cfg.web=!cfg.web;saveCfg();$('#webBtn').classList.toggle('on',cfg.web);toast(cfg.web?'Доступ к интернету включён':'Доступ к интернету выключен')};
/* ================= RENDER ================= */
const SUGGEST=[
['code','Напиши на Python игру «Змейка» на pygame'],
['feather','Сочини короткое стихотворение про осенний дождь'],
['globe','Какие главные новости технологий сегодня?'],
['lightbulb','Объясни простыми словами, как работает нейросеть'],
];
function renderThread(){
const t=$('#thread');const c=curChat();
if(!c||!c.messages.length){
t.innerHTML=`<div class="empty" style="min-height:calc(100dvh - 260px)"><div class="logo">${ico('spark')}</div><h2>Чем могу помочь?</h2><p>${esc(PROVIDERS[cfg.provider].name)} · ${esc(model()||'модель не выбрана')}</p>
<div class="suggest">${SUGGEST.map(([i,s])=>`<button data-s="${esc(s)}">${ico(i)}<span>${esc(s)}</span></button>`).join('')}</div></div>`;
t.querySelectorAll('[data-s]').forEach(b=>b.onclick=()=>{$('#input').value=b.dataset.s;autosize();updateSend();send()});
return;
}
t.innerHTML='';c.messages.forEach((m,i)=>t.appendChild(msgEl(m,i,c)));
scrollBottom(true);
}
function msgEl(m,i,c){
const d=document.createElement('div');d.className='msg '+m.role;d.dataset.i=i;
if(m.role==='user'){
let att='';
if(m.files&&m.files.length)att='<div class="att-row">'+m.files.map(f=>f.kind==='image'&&f.dataUrl?`<img src="${f.dataUrl}" alt="${esc(f.name)}">`:`<div class="file-chip">${ico(f.kind==='image'?'file':'file')}<span>${esc(f.name)}</span></div>`).join('')+'</div>';
d.innerHTML=att+(m.content?`<div class="bubble">${esc(m.content)}</div>`:'');
}else{
d.innerHTML=`<div class="ai-head"><div class="ai-av">${ico('spark')}</div><span>${esc(m.model||'AI')}</span></div><div class="status hidden"></div><div class="md"></div><div class="sources hidden"></div><div class="actions"></div>`;
fillAssistant(d,m,false);
}
return d;
}
function fillAssistant(d,m,streaming){
const md=d.querySelector('.md');
if(m.error){md.innerHTML=(m.content?renderMd(m.content):'')+`<div class="err-box">${esc(m.error)}</div>`}
else if(!m.content&&!m.reasoning&&streaming){md.innerHTML='<div class="typing"><span></span><span></span><span></span></div>'}
else md.innerHTML=renderMd(m.content||'',m.reasoning);
enhance(md,streaming);
const s=d.querySelector('.sources');
if(m.sources&&m.sources.length){s.classList.remove('hidden');s.innerHTML=m.sources.slice(0,8).map(u=>{let h=u;try{h=new URL(u).hostname.replace('www.','')}catch{};return `<a class="src" href="${esc(u)}" target="_blank" rel="noopener">${ico('link')}<span>${esc(h)}</span></a>`}).join('')}
else s.classList.add('hidden');
const a=d.querySelector('.actions');
if(streaming){a.innerHTML=''}
else a.innerHTML=`<button class="act" data-act="copy" title="Скопировать">${ico('copy')}<span>Копировать</span></button><button class="act" data-act="speak" title="Озвучить">${ico('speaker')}<span>Озвучить</span></button><button class="act" data-act="regen" title="Сгенерировать заново">${ico('refresh')}</button>`;
}
function setStatus(d,html){const s=d.querySelector('.status');if(!html){s.classList.add('hidden');return}s.classList.remove('hidden');s.innerHTML=html}
marked.setOptions({gfm:true,breaks:true});
function splitThink(t){let think='';t=t.replace(/<think>([\s\S]*?)(<\/think>|$)/g,(_,a)=>{think+=a;return ''});return [t.trim(),think.trim()]}
function renderMd(text,reasoning){
let [t,th]=splitThink(text);th=[reasoning||'',th].filter(Boolean).join('\n');
let h='';if(th)h+=`<details class="think"><summary>Размышления модели</summary><div>${esc(th)}</div></details>`;
return h+DOMPurify.sanitize(marked.parse(t));
}
const POEM=['poem','poetry','verse','стих','стихи','стихотворение','lyrics'];
const EXT={python:'py',py:'py',javascript:'js',js:'js',typescript:'ts',ts:'ts',html:'html',css:'css',java:'java',c:'c',cpp:'cpp','c++':'cpp',csharp:'cs',cs:'cs',go:'go',rust:'rs',php:'php',ruby:'rb',kotlin:'kt',swift:'swift',sql:'sql',bash:'sh',sh:'sh',shell:'sh',powershell:'ps1',json:'json',yaml:'yml',yml:'yml',xml:'xml',lua:'lua',dart:'dart',r:'r',scala:'scala',perl:'pl',jsx:'jsx',tsx:'tsx',vue:'vue',markdown:'md',md:'md',dockerfile:'Dockerfile',ini:'ini',toml:'toml',asm:'asm',haskell:'hs',elixir:'ex',kt:'kt'};
function enhance(root,streaming){
root.querySelectorAll('pre > code').forEach(code=>{
const pre=code.parentElement;const lang=((code.className.match(/language-(\S+)/)||[])[1]||'').toLowerCase();
const raw=code.textContent.replace(/\n$/,'');
const card=document.createElement('div');
if(POEM.includes(lang)){
card.className='poem-card';card._raw=raw;card._kind='poem';
card.innerHTML=`<div class="card-head"><span class="lang">${ico('feather')}стихотворение</span>
<button class="cbtn" data-c="speak">${ico('speaker')}<span>Озвучить</span></button>
<button class="cbtn" data-c="open">${ico('expand')}<span>Открыть</span></button>
<button class="cbtn" data-c="copy">${ico('copy')}<span>Копировать</span></button></div><div class="poem-body"></div>`;
card.querySelector('.poem-body').textContent=raw;
}else{
card.className='code-card';card._raw=raw;card._lang=lang;card._kind='code';
const isHtml=['html','htm','svg','xml'].includes(lang)&&/<(html|body|div|svg|!doctype)/i.test(raw);
card.innerHTML=`<div class="card-head"><span class="lang">${ico('code')}${esc(lang||'код')}</span>
${isHtml?`<button class="cbtn" data-c="run">${ico('play')}<span>Запустить</span></button>`:''}
<button class="cbtn" data-c="open">${ico('expand')}<span>Открыть</span></button>
<button class="cbtn" data-c="copy">${ico('copy')}<span>Копировать</span></button></div><pre><code class="hljs"></code></pre>`;
const c=card.querySelector('code');
try{
if(lang&&hljs.getLanguage(lang))c.innerHTML=hljs.highlight(raw,{language:lang,ignoreIllegals:true}).value;
else if(!streaming&&raw.length<20000){const r=hljs.highlightAuto(raw);c.innerHTML=r.value;if(!lang&&r.language){card._lang=r.language;card.querySelector('.lang').lastChild.textContent=r.language}}
else c.textContent=raw;
}catch{c.textContent=raw}
}
pre.replaceWith(card);
});
root.querySelectorAll('a').forEach(a=>{a.target='_blank';a.rel='noopener'});
}
/* card buttons + message actions (delegation) */
$('#thread').addEventListener('click',e=>{
const cb=e.target.closest('[data-c]');
if(cb){const card=cb.closest('.code-card,.poem-card');const act=cb.dataset.c;
if(act==='copy')copyText(card._raw);
if(act==='open')openViewer(card._kind,card._raw,card._lang);
if(act==='run')openPreview(card._raw);
if(act==='speak')speak(card._raw,cb);
return}
const ab=e.target.closest('[data-act]');if(!ab)return;
const md=ab.closest('.msg');const i=+md.dataset.i;const c=curChat();const m=c.messages[i];
if(ab.dataset.act==='copy')copyText(splitThink(m.content||'')[0]);
if(ab.dataset.act==='speak')speak(splitThink(m.content||'')[0],ab);
if(ab.dataset.act==='regen'&&!busy){c.messages.splice(i);saveChats();renderThread();generate()}
});
/* ================= VIEWER MODAL ================= */
function modal(html,cls=''){
const r=$('#modalRoot');r.innerHTML=`<div class="overlay"><div class="modal ${cls}">${html}</div></div>`;
const ov=r.firstChild;ov.addEventListener('click',e=>{if(e.target===ov||e.target.closest('[data-close]'))closeModal()});
return ov.querySelector('.modal');
}
function closeModal(){$('#modalRoot').innerHTML='';stopSpeak()}
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeModal()});
function openViewer(kind,raw,lang){
const poem=kind==='poem';
const m=modal(`<div class="modal-head"><h3>${ico(poem?'feather':'code')}${poem?'Стихотворение':esc(lang||'Код')}</h3>
${poem?`<button class="cbtn" data-v="speak">${ico('speaker')}<span>Озвучить</span></button>`:`<button class="cbtn" data-v="dl">${ico('download')}<span>Скачать</span></button>`}
<button class="btn btn-primary" data-v="copy" style="padding:8px 14px">${ico('copy')}Копировать ${poem?'стих':'код'}</button>
<button class="icon-btn" data-close title="Закрыть">${ico('x')}</button></div>
<div class="modal-body">${poem?'<div class="poem-body"></div>':'<pre><code class="hljs"></code></pre>'}</div>`);
if(poem)m.querySelector('.poem-body').textContent=raw;
else{const c=m.querySelector('code');try{c.innerHTML=lang&&hljs.getLanguage(lang)?hljs.highlight(raw,{language:lang,ignoreIllegals:true}).value:hljs.highlightAuto(raw).value}catch{c.textContent=raw}}
m.querySelector('[data-v="copy"]').onclick=()=>copyText(raw);
const s=m.querySelector('[data-v="speak"]');if(s)s.onclick=()=>speak(raw,s);
const dl=m.querySelector('[data-v="dl"]');if(dl)dl.onclick=()=>{const ext=EXT[lang]||'txt';const b=new Blob([raw],{type:'text/plain'});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=ext==='Dockerfile'?'Dockerfile':'code.'+ext;a.click();URL.revokeObjectURL(a.href)};
}
function openPreview(raw){
const m=modal(`<div class="modal-head"><h3>${ico('play')}Предпросмотр HTML</h3><button class="icon-btn" data-close>${ico('x')}</button></div><div class="modal-body"><iframe sandbox="allow-scripts allow-modals allow-forms"></iframe></div>`);
m.querySelector('iframe').srcdoc=raw;
}
/* ================= TTS (Web Speech API) ================= */
let speakBtn=null;
function stripMd(t){return t.replace(/```[\s\S]*?```/g,' Блок кода. ').replace(/`([^`]+)`/g,'$1').replace(/!\[[^\]]*\]\([^)]*\)/g,'').replace(/\[([^\]]+)\]\([^)]*\)/g,'$1').replace(/[#*_>~|]+/g,' ').replace(/^\s*[-+]\s+/gm,'').replace(/\n{2,}/g,'. ').replace(/\s+/g,' ').trim()}
function setSpeakIcon(b,on){if(!b)return;b.classList.toggle('on',on);const svg=b.querySelector('svg');if(svg)svg.outerHTML=ico(on?'speakerOff':'speaker');const sp=b.querySelector('span');if(sp)sp.textContent=on?'Стоп':'Озвучить'}
function stopSpeak(){speechSynthesis.cancel();setSpeakIcon(speakBtn,false);speakBtn=null}
function speak(text,btn){
if(!('speechSynthesis' in window)){toast('Браузер не поддерживает озвучку');return}
if(speakBtn===btn){stopSpeak();return}
stopSpeak();
const clean=stripMd(text);if(!clean)return;
const ru=/[а-яё]/i.test(clean);const voices=speechSynthesis.getVoices();
let v=voices.find(x=>x.voiceURI===cfg.voice);
if(!v||(ru&&!/^ru/i.test(v.lang)))v=voices.find(x=>ru?/^ru/i.test(x.lang):/^en/i.test(x.lang))||v;
const parts=clean.match(/[^.!?…\n]{1,220}[.!?…]*|\S+/g)||[clean];
speakBtn=btn;setSpeakIcon(btn,true);
parts.forEach((p,i)=>{const u=new SpeechSynthesisUtterance(p);if(v){u.voice=v;u.lang=v.lang}else u.lang=ru?'ru-RU':'en-US';u.rate=cfg.rate||1;
if(i===parts.length-1)u.onend=()=>{if(speakBtn===btn){setSpeakIcon(btn,false);speakBtn=null}};speechSynthesis.speak(u)});
}
speechSynthesis&&speechSynthesis.getVoices();
/* ================= FILES ================= */
const MAX_TEXT=120000;
$('#attachBtn').onclick=()=>$('#fileInput').click();
$('#fileInput').onchange=e=>{addFiles([...e.target.files]);e.target.value=''};
const comp=$('#composer');
['dragenter','dragover'].forEach(ev=>comp.addEventListener(ev,e=>{e.preventDefault();comp.classList.add('drag')}));
['dragleave','drop'].forEach(ev=>comp.addEventListener(ev,e=>{e.preventDefault();comp.classList.remove('drag')}));
comp.addEventListener('drop',e=>addFiles([...e.dataTransfer.files]));
$('#input').addEventListener('paste',e=>{const f=[...e.clipboardData.files];if(f.length){e.preventDefault();addFiles(f)}});
const readAs=(f,m)=>new Promise((res,rej)=>{const r=new FileReader();r.onload=()=>res(r.result);r.onerror=rej;r[m](f)});
async function loadPdfJs(){if(window.pdfjsLib)return;await new Promise((res,rej)=>{const s=document.createElement('script');s.src='https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';s.onload=res;s.onerror=rej;document.head.appendChild(s)});pdfjsLib.GlobalWorkerOptions.workerSrc='https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'}
async function addFiles(files){
for(const f of files){
try{
if(f.type.startsWith('image/')){
if(f.size>8e6){toast('Изображение больше 8 МБ');continue}
pending.push({name:f.name,kind:'image',dataUrl:await shrinkImage(f)});
}else if(f.type==='application/pdf'||/\.pdf$/i.test(f.name)){
toast('Читаю PDF…');await loadPdfJs();const pdf=await pdfjsLib.getDocument({data:await readAs(f,'readAsArrayBuffer')}).promise;let txt='';
for(let p=1;p<=Math.min(pdf.numPages,60);p++){const pg=await pdf.getPage(p);const tc=await pg.getTextContent();txt+=tc.items.map(i=>i.str).join(' ')+'\n\n'}
pending.push({name:f.name,kind:'text',text:txt.slice(0,MAX_TEXT)});
}else{
const t=await readAs(f,'readAsText');
if(/\u0000/.test(t.slice(0,2000))){toast('Формат не поддерживается: '+f.name);continue}
pending.push({name:f.name,kind:'text',text:t.slice(0,MAX_TEXT)});
}
}catch(err){toast('Ошибка чтения файла '+f.name)}
}
renderPending();updateSend();
}
function shrinkImage(f){return new Promise(async res=>{const url=await readAs(f,'readAsDataURL');const img=new Image();img.onload=()=>{const max=1536;let{width:w,height:h}=img;if(w<=max&&h<=max&&f.size<1.5e6){res(url);return}const k=Math.min(max/w,max/h,1);const c=document.createElement('canvas');c.width=w*k;c.height=h*k;c.getContext('2d').drawImage(img,0,0,c.width,c.height);res(c.toDataURL('image/jpeg',.85))};img.onerror=()=>res(url);img.src=url})}
function renderPending(){
const p=$('#pending');p.classList.toggle('hidden',!pending.length);
p.innerHTML=pending.map((f,i)=>`<div class="pend">${f.kind==='image'?`<img src="${f.dataUrl}">`:`<div class="file-chip">${ico('file')}<span>${esc(f.name)}</span></div>`}<button class="x" data-rm="${i}">${ico('x')}</button></div>`).join('');
p.querySelectorAll('[data-rm]').forEach(b=>b.onclick=()=>{pending.splice(+b.dataset.rm,1);renderPending();updateSend()});
}
/* ================= COMPOSER ================= */
const inp=$('#input');
function autosize(){inp.style.height='auto';inp.style.height=Math.min(inp.scrollHeight,220)+'px'}
function updateSend(){const b=$('#sendBtn');if(busy){b.disabled=false;b.classList.add('stop');b.innerHTML=ico('stop');b.title='Остановить'}else{b.classList.remove('stop');b.innerHTML=ico('send');b.title='Отправить';b.disabled=!inp.value.trim()&&!pending.length}}
inp.addEventListener('input',()=>{autosize();updateSend()});
inp.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey&&!e.isComposing){e.preventDefault();if(!busy)send()}});
$('#sendBtn').onclick=()=>{if(busy){abortCtl&&abortCtl.abort();return}send()};
function send(){
const text=inp.value.trim();if(!text&&!pending.length)return;
if(!model()){toast('Сначала выберите модель');$('#modelBtn').click();return}
let c=curChat();
if(!c){c={id:uid(),title:(text||pending[0]?.name||'Новый чат').slice(0,48),messages:[],created:Date.now(),updated:Date.now()};chats.push(c);currentId=c.id}
c.messages.push({role:'user',content:text,files:pending.map(f=>({...f}))});
c.updated=Date.now();pending=[];renderPending();inp.value='';autosize();
saveChats();renderThread();renderHistory();generate();
}
/* ================= WEB TOOLS ================= */
const TOOLS=[
{type:'function',function:{name:'web_search',description:'Поиск актуальной информации в интернете. Используй для новостей, фактов, цен, погоды, событий после твоей даты обучения.',parameters:{type:'object',properties:{query:{type:'string',description:'Поисковый запрос'}},required:['query']}}},
{type:'function',function:{name:'fetch_page',description:'Открыть веб-страницу по URL и получить её текст.',parameters:{type:'object',properties:{url:{type:'string',description:'Полный URL'}},required:['url']}}},
];
function jinaH(extra={}){const h={...extra};if(cfg.jinaKey)h.Authorization='Bearer '+cfg.jinaKey;return h}
async function webSearch(q,sources){
// 1) Jina Search (если указан ключ)
if(cfg.jinaKey){try{const r=await fetch('https://s.jina.ai/?q='+encodeURIComponent(q),{headers:jinaH({Accept:'application/json','X-Respond-With':'no-content'})});
if(r.ok){const j=await r.json();const d=(j.data||[]).slice(0,8);d.forEach(x=>sources.push(x.url));if(d.length)return d.map((x,i)=>`[${i+1}] ${x.title}\n${x.url}\n${x.description||x.content||''}`).join('\n\n').slice(0,9000)}}catch{}}
// 2) DuckDuckGo через Jina Reader
try{const r=await fetch('https://r.jina.ai/https://html.duckduckgo.com/html/?q='+encodeURIComponent(q),{headers:jinaH({'X-Return-Format':'markdown'})});
if(r.ok){let t=await r.text();
t=t.replace(/\(https?:\/\/duckduckgo\.com\/l\/\?uddg=([^&)]+)[^)]*\)/g,(_,u)=>'('+decodeURIComponent(u)+')').replace(/\(\/\/duckduckgo\.com\/l\/\?uddg=([^&)]+)[^)]*\)/g,(_,u)=>'('+decodeURIComponent(u)+')');
const urls=[...new Set([...t.matchAll(/\]\((https?:\/\/(?!duckduckgo|html\.duckduckgo)[^)\s]+)\)/g)].map(m=>m[1]))].slice(0,8);
if(urls.length){urls.forEach(u=>sources.push(u));const i=t.indexOf('Markdown Content:');return (i>0?t.slice(i+17):t).slice(0,9000)}}}catch{}
// 3) DuckDuckGo Instant Answer + Wikipedia
let out='';
try{const r=await fetch('https://api.duckduckgo.com/?format=json&no_html=1&skip_disambig=1&q='+encodeURIComponent(q));const j=await r.json();
if(j.AbstractText){out+=`${j.Heading}: ${j.AbstractText}\n${j.AbstractURL}\n\n`;sources.push(j.AbstractURL)}
(j.RelatedTopics||[]).slice(0,5).forEach(x=>{if(x.Text){out+=`- ${x.Text} (${x.FirstURL})\n`}})}catch{}
try{const lang=/[а-яё]/i.test(q)?'ru':'en';
const r=await fetch(`https://${lang}.wikipedia.org/w/api.php?action=query&list=search&format=json&origin=*&srlimit=4&srsearch=`+encodeURIComponent(q));const j=await r.json();
const titles=(j.query?.search||[]).map(s=>s.title);
if(titles.length){const r2=await fetch(`https://${lang}.wikipedia.org/w/api.php?action=query&prop=extracts&exintro=1&explaintext=1&format=json&origin=*&titles=`+encodeURIComponent(titles.join('|')));const j2=await r2.json();
Object.values(j2.query?.pages||{}).forEach(p=>{const u=`https://${lang}.wikipedia.org/wiki/`+encodeURIComponent(p.title.replace(/ /g,'_'));sources.push(u);out+=`\n[Википедия] ${p.title}\n${u}\n${(p.extract||'').slice(0,1500)}\n`})}}catch{}
return out||'Поиск не дал результатов (возможно, сервис поиска недоступен). Попробуй другой запрос.';
}
async function fetchPage(url,sources){
try{const r=await fetch('https://r.jina.ai/'+url,{headers:jinaH({'X-Return-Format':'markdown'})});if(!r.ok)throw new Error('HTTP '+r.status);const t=await r.text();sources.push(url);return t.slice(0,12000)}
catch(e){return 'Не удалось открыть страницу: '+e.message}
}
/* ================= CHAT COMPLETION ================= */
function sysPrompt(){
const now=new Date().toLocaleString('ru-RU',{dateStyle:'full',timeStyle:'short'});
let s=`Ты — полезный AI-ассистент. Сейчас: ${now}. Отвечай на языке пользователя, используй Markdown.
Правила оформления:
- Любой программный код (HTML, Python, JavaScript, Java, C++, и т.д.) всегда помещай в блок \`\`\`язык ... \`\`\` с правильным названием языка.
- Любое стихотворение, песню или поэтический текст всегда помещай в отдельный блок \`\`\`poem ... \`\`\` (только сам текст стиха, без пояснений внутри).`;
if(cfg.web)s+=`\nУ тебя есть доступ к интернету через инструменты web_search и fetch_page. Используй их для актуальных данных (новости, погода, цены, свежие события, факты, в которых не уверен). В конце ответа указывай использованные источники ссылками.`;
if(cfg.sysExtra)s+='\n'+cfg.sysExtra;
return s;
}
function buildMessages(c,extraSys){
const msgs=[{role:'system',content:sysPrompt()+(extraSys||'')}];
c.messages.forEach(m=>{
if(m.role==='user'){
let text=m.content||'';
(m.files||[]).filter(f=>f.kind==='text').forEach(f=>{text+=`\n\n[Файл: ${f.name}]\n\`\`\`\n${f.text}\n\`\`\``});
const imgs=(m.files||[]).filter(f=>f.kind==='image'&&f.dataUrl);
if(imgs.length)msgs.push({role:'user',content:[{type:'text',text:text||'Опиши изображение'},...imgs.map(f=>({type:'image_url',image_url:{url:f.dataUrl}}))]});
else msgs.push({role:'user',content:text});
}else if(m.content&&!m.error)msgs.push({role:'assistant',content:splitThink(m.content)[0]});
});
return msgs;
}
function nearBottom(){const m=$('#messages');return m.scrollHeight-m.scrollTop-m.clientHeight<140}
function scrollBottom(force){const m=$('#messages');if(force||nearBottom())m.scrollTop=m.scrollHeight}
async function generate(){
const c=curChat();if(!c)return;
const am={role:'assistant',content:'',model:model(),sources:[]};c.messages.push(am);
const idx=c.messages.length-1;const el=msgEl(am,idx,c);$('#thread').appendChild(el);fillAssistant(el,am,true);scrollBottom(true);
busy=true;updateSend();abortCtl=new AbortController();
let raf=null;const redraw=()=>{if(raf)return;raf=requestAnimationFrame(()=>{raf=null;const nb=nearBottom();fillAssistant(el,am,true);if(nb)scrollBottom(true)})};
const useNative=cfg.web&&cfg.provider==='openrouter'&&cfg.orNative;
let useTools=cfg.web&&!useNative;let extraSys='';
let messages=buildMessages(c.messages.length?{messages:c.messages.slice(0,-1)}:c);
try{
for(let iter=0;iter<6;iter++){
const body={model:model(),messages,stream:true};
if(useTools&&iter<5){body.tools=TOOLS;body.tool_choice='auto'}
if(useNative)body.plugins=[{id:'web',max_results:5}];
const res=await fetch(base()+'/chat/completions',{method:'POST',headers:headers(),body:JSON.stringify(body),signal:abortCtl.signal});
if(!res.ok){
let t='';try{const j=await res.json();t=j.error?.message||JSON.stringify(j)}catch{}
if(body.tools&&[400,404,422,500].includes(res.status)&&iter===0){
// модель не поддерживает инструменты -> ищем заранее и подставляем результаты в контекст
useTools=false;setStatus(el,ico('globe')+'Модель без поддержки инструментов — ищу в интернете заранее…');
const lastUser=[...c.messages].reverse().find(m=>m.role==='user');
const r=await webSearch((lastUser?.content||'').slice(0,200),am.sources);
extraSys=`\n\nРезультаты веб-поиска по запросу пользователя (используй при необходимости, ссылайся на источники):\n${r}`;
messages=buildMessages({messages:c.messages.slice(0,-1)},extraSys);iter--;setStatus(el,'');continue;
}
throw new Error(`Ошибка API (${res.status}): ${t||res.statusText}`);
}
setStatus(el,'');
const calls=[];let finish=null;
const reader=res.body.getReader();const dec=new TextDecoder();let buf='';
while(true){
const {done,value}=await reader.read();if(done)break;
buf+=dec.decode(value,{stream:true});const lines=buf.split('\n');buf=lines.pop();
for(let line of lines){
line=line.trim();if(!line.startsWith('data:'))continue;const d=line.slice(5).trim();if(d==='[DONE]')continue;
let j;try{j=JSON.parse(d)}catch{continue}
if(j.error)throw new Error(j.error.message||JSON.stringify(j.error));
const ch=j.choices?.[0];if(!ch)continue;const delta=ch.delta||{};
if(delta.content){am.content+=delta.content;redraw()}
const rs=delta.reasoning||delta.reasoning_content;if(rs){am.reasoning=(am.reasoning||'')+rs;redraw()}
if(delta.tool_calls)delta.tool_calls.forEach(tc=>{const k=tc.index??calls.length;calls[k]=calls[k]||{id:'',type:'function',function:{name:'',arguments:''}};
if(tc.id)calls[k].id=tc.id;if(tc.function?.name)calls[k].function.name+=tc.function.name;if(tc.function?.arguments)calls[k].function.arguments+=tc.function.arguments});
const ann=delta.annotations||ch.message?.annotations;if(ann)ann.forEach(a=>{const u=a.url_citation?.url||a.url;if(u&&!am.sources.includes(u))am.sources.push(u)});
if(ch.finish_reason)finish=ch.finish_reason;
}
}
const real=calls.filter(Boolean).filter(x=>x.function.name);
if(!real.length)break;
real.forEach((x,i)=>{if(!x.id)x.id='call_'+uid()+i});
messages.push({role:'assistant',content:am.content||null,tool_calls:real});
for(const tc of real){
let args={};try{args=JSON.parse(tc.function.arguments||'{}')}catch{}
let out='';
if(tc.function.name==='web_search'){setStatus(el,ico('search')+'Ищу в интернете: <b>'+esc(args.query||'')+'</b>');out=await webSearch(args.query||'',am.sources)}
else if(tc.function.name==='fetch_page'){setStatus(el,ico('globe')+'Читаю страницу: '+esc(args.url||''));out=await fetchPage(args.url||'',am.sources)}
else out='Неизвестный инструмент';
messages.push({role:'tool',tool_call_id:tc.id,content:out});
}
am.sources=[...new Set(am.sources)];
if(am.content)am.content+='\n\n';
setStatus(el,ico('loader')+'Анализирую найденное…');
}
}catch(e){
if(e.name==='AbortError'){if(!am.content)am.content='_Генерация остановлена._'}
else am.error=/Failed to fetch/i.test(e.message)?'Сетевая ошибка: не удалось связаться с API (проверьте ключ, Base URL и CORS).':e.message;
}finally{
busy=false;abortCtl=null;updateSend();setStatus(el,'');
am.content=am.content.trim();am.sources=[...new Set(am.sources)];
fillAssistant(el,am,false);c.updated=Date.now();saveChats();renderHistory();
}
}
/* ================= SETTINGS ================= */
$('#openSettings').onclick=()=>{
const voices=speechSynthesis.getVoices();
const m=modal(`<div class="modal-head"><h3>${ico('settings')}Настройки</h3><button class="icon-btn" data-close>${ico('x')}</button></div>
<div class="modal-body"><div class="form">
<div class="field"><label>Голос озвучки</label><select class="inp" id="sVoice"><option value="">Автоматически (по языку текста)</option>${voices.map(v=>`<option value="${esc(v.voiceURI)}" ${v.voiceURI===cfg.voice?'selected':''}>${esc(v.name)} — ${esc(v.lang)}</option>`).join('')}</select></div>
<div class="field"><label>Скорость речи: <b id="rateV">${cfg.rate}</b></label><input type="range" id="sRate" min="0.5" max="2" step="0.1" value="${cfg.rate}" style="width:100%;accent-color:var(--accent)"></div>
<div class="field"><label>Jina AI ключ (необязательно — улучшает веб-поиск)</label><input class="inp" id="sJina" type="password" value="${esc(cfg.jinaKey)}" placeholder="jina_..."><p class="hint" style="margin-top:6px">Бесплатно на <a href="https://jina.ai/" target="_blank">jina.ai</a>. Без ключа используется DuckDuckGo и Википедия.</p></div>
${cfg.provider==='openrouter'?`<div class="toggle"><span>Встроенный веб-поиск OpenRouter (плагин web, платно)</span><button class="switch ${cfg.orNative?'on':''}" id="sNative"></button></div>`:''}
<div class="field"><label>Дополнительные инструкции для ИИ</label><textarea class="inp" id="sSys" rows="3" placeholder="Например: отвечай кратко">${esc(cfg.sysExtra)}</textarea></div>
<div class="row2"><button class="btn btn-primary" id="sSave" style="flex:1">Сохранить</button><button class="btn btn-ghost" id="sClear">${ico('trash')}Очистить историю</button></div>
</div></div>`,'small');
m.querySelector('#sRate').oninput=e=>m.querySelector('#rateV').textContent=e.target.value;
const n=m.querySelector('#sNative');if(n)n.onclick=()=>n.classList.toggle('on');
m.querySelector('#sSave').onclick=()=>{cfg.voice=m.querySelector('#sVoice').value;cfg.rate=+m.querySelector('#sRate').value;cfg.jinaKey=m.querySelector('#sJina').value.trim();cfg.sysExtra=m.querySelector('#sSys').value.trim();if(n)cfg.orNative=n.classList.contains('on');saveCfg();closeModal();toast('Настройки сохранены')};
m.querySelector('#sClear').onclick=()=>{if(confirm('Удалить всю историю чатов?')){chats=[];saveChats();closeModal();newChat()}};
};
/* ================= INIT ================= */
hydrateIcons();
if(cfg.provider&&cfg.keys[cfg.provider])enterApp();else showSetup(false);
</script>
</body></html>