// web/screens/settings.jsx — экран Настройки + device-flow подключения Я.Диска. function SettingsScreen() { const [status, reload] = useAsync(() => api.yandex.Status(), []); const [step, setStep] = React.useState(null); // null | "starting" | "polling" const [code, setCode] = React.useState(null); // { userCode, deviceCode, verificationUrl } const [_, toast] = useToasts(); const start = async () => { setStep("starting"); try { const dc = await api.yandex.StartConnect(); setCode(dc); setStep("polling"); // Стартуем поллинг сами. pollLoop(dc.deviceCode); } catch (e) { setStep(null); toast("Не удалось начать подключение: " + e.message, "error"); } }; const pollLoop = async (deviceCode) => { const startedAt = Date.now(); const timeout = 10 * 60 * 1000; // 10 минут на ввод кода while (Date.now() - startedAt < timeout) { try { const r = await api.yandex.PollConnect(deviceCode); if (r.status === "connected") { toast("Я.Диск подключён", "info"); setStep(null); setCode(null); reload(); return; } if (r.status === "expired") { toast("Код истёк — начни заново", "error"); setStep(null); setCode(null); return; } if (r.status === "denied") { toast("Доступ отклонён", "error"); setStep(null); setCode(null); return; } // status === "pending" — ждём } catch (e) { toast("Ошибка при опросе: " + e.message, "error"); setStep(null); return; } await new Promise((r) => setTimeout(r, 5000)); } toast("Время на подключение истекло", "error"); setStep(null); }; return (

Настройки

Подключения и параметры конвейера

Яндекс Диск

Один токен, подключается через device-код один раз. Дальше живёт в БД, обновляется автоматически.
Статус
Реальный ответ yandex.Status
{status.loading && } {status.error && {status.error.message}} {status.data && status.data.connected ? Подключено{status.data.account ? ` · ${status.data.account}` : ""} : status.data && Не подключено}

{!step && (!status.data || !status.data.connected) && ( )} {step === "starting" && (
запрашиваем код у Яндекса…
)} {step === "polling" && code && (
Введи этот код на
{code.verificationUrl} ↗
{code.userCode}
ждём подтверждения…
Открой ссылку, введи код, разреши приложение pikasrv — страница сама обновится.
)} {status.data && status.data.connected && !step && (
Токен живёт в БД. Чтобы переподключить — сначала удали запись через VT-админку.
)}
{/* Параметры конвейера */}

Параметры конвейера

Технические дефолты — в обычной работе не трогаются.
Ресайз для распознавания
по большей стороне
512 px · q80 JPEG
Группировка серий
основной ключ — префикс ГГММДД в имени
по дате в имени
Порог разрыва времени
fallback, если префикса нет
1 ч 30 мин
Соотношение баннера
шире — баннер 21:9 / выше — 9:16
16:9
Чистка 512-кэша
после ухода серии в архив
через 30 дней
); } // OpenClawSection — статус подключения к OpenClaw + self-enroll по кнопке. // Поток: POST /v1/openclaw/connect → "paired" / "pending" / "error". При // pending показываем deviceId, который Лена передаёт админу gpu1 для // `openclaw devices approve `; после approve следующий клик уже // вернёт "paired". function OpenClawSection() { const [status, reload] = useAsync(() => api.openclaw.status(), []); const [busy, setBusy] = React.useState(false); const [lastResp, setLastResp] = React.useState(null); const [_, toast] = useToasts(); const connect = async () => { setBusy(true); try { const r = await api.openclaw.connect(); setLastResp(r); if (r.status === "paired") { toast("OpenClaw подключён", "info"); reload(); } else if (r.status === "pending") { toast("Pairing запрошен — попроси админа approve", "info"); reload(); } else { toast("OpenClaw: " + (r.error || r.status), "error"); } } catch (e) { toast("Запрос не прошёл: " + e.message, "error"); } finally { setBusy(false); } }; const data = status.data || {}; const paired = data.paired; const configured = data.configured; const deviceId = data.deviceId || ""; return (

OpenClaw — провайдер моделей

Внешние сервисы (Vision + Text-модель) ходят через OpenClaw. Подключение — self-enroll: сервис формирует Ed25519-identity и отправляет device-запрос; админ gateway подтверждает pairing.
Gateway URL
из переменной OPENCLAW_URL
{data.url || "не задано"}
{!configured ? не настроен : paired ? paired : требует pairing}
Агент
по умолчанию pika (glm-5.1)
{data.agent || "pika"}
{configured && deviceId && (
Device ID
hex(sha256(public-key)) — стабильный, переживает рестарт
{deviceId}
)}
{!configured && (
Чтобы появилось подключение, задай в окружении{" "} OPENCLAW_URL=ws://192.168.31.101:18789{" "} и перезапусти pikasrv.
)} {configured && !paired && (
— проверяет, есть ли уже работающий токен
{ setLastResp(null); reload(); }} /> {lastResp?.status === "pending" && (
Запрос отправлен. Передай админу gpu1 device-id выше — он выполнит approve, и при следующем клике статус станет paired. Команда админа:
{`ssh gpu1 'docker exec bot node openclaw.mjs devices list'
ssh gpu1 'docker exec bot node openclaw.mjs devices approve '`}
              
После approve нажми кнопку ещё раз — увидишь paired.
)} {lastResp?.status === "error" && (
{lastResp.error}
)}
)} {paired && (
Device-token живёт в openclaw-token.json{" "} рядом с бинарём. Если файл удалить — pairing нужно пройти заново. {" "}
)}
); } // TokenForm — ввод одноразового bootstrap-токена от админа gpu1. Bootstrap // подписывает identity-блок, gateway создаёт pending; после `devices approve` // у админа следующий connect автоматически вернёт постоянный device-token. function TokenForm({ onSaved }) { const [token, setToken] = React.useState(""); const [busy, setBusy] = React.useState(false); const [resp, setResp] = React.useState(null); const [_, toast] = useToasts(); const submit = async () => { if (!token.trim()) return; setBusy(true); try { const r = await api.openclaw.bootstrap(token.trim()); setResp(r); if (r.status === "paired") { toast("Подключение установлено — device-token получен", "info"); setToken(""); onSaved && onSaved(); } else if (r.status === "pending") { toast("Устройство ушло в pending — попроси админа approve", "info"); onSaved && onSaved(); } else { toast("Ошибка: " + (r.error || r.status), "error"); } } catch (e) { toast("Запрос не прошёл: " + e.message, "error"); } finally { setBusy(false); } }; return (
Bootstrap-token от админа. Одноразовый ключ для первичного enroll — admin gpu1 выдаёт его по нашему device-id. После успешного pairing gateway вернёт постоянный device-token, он ляжет в{" "} openclaw-token.json, и bootstrap больше не нужен.
setToken(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} style={{ flex: 1, padding: "6px 10px", fontSize: 13, fontFamily: "var(--font-mono)" }}/>
{resp?.status === "pending" && (
Запрос принят, device в pending у gateway. Попроси админа выполнить {" "}openclaw devices approve {""}. После approve нажми «Попробовать подключиться» сверху — gateway отдаст device-token, и статус станет paired.
)} {resp?.status === "error" && (
{resp.error}
)}
); } window.OpenClawSection = OpenClawSection; window.TokenForm = TokenForm; // PromptsSection — список 5 шаблонов с метаданными + раскрываемый полный текст. // Тексты подсасываются прямо из живого кода pikasrv (`/v1/prompts`), чтобы // Лена видела что именно сервис сейчас шлёт моделям — без рассинхрона с // архитектурой или этим UI. function PromptsSection() { const [list, reload] = useAsync(() => api.prompts(), []); const [open, setOpen] = React.useState({}); // {id: bool} const items = list.data || []; const toggle = (id) => setOpen((s) => ({ ...s, [id]: !s[id] })); return (

Промпты к моделям

Шаблоны запросов, которые pikasrv шлёт в OpenClaw. Vision — только английский (требование llama3.2-vision). Текстовые — на русском, GLM-5.1 их прекрасно понимает.
{list.loading &&
загружаем…
} {list.error && (
Не удалось получить промпты: {list.error.message}
)} {items.map((p) => ( toggle(p.id)} onSaved={reload}/> ))}
); } // PromptCard — раскрываемая карточка промпта с режимом редактирования. // View-mode: показывает текст. Edit-mode: textarea + Сохранить / Отменить / // Сбросить к дефолту. После сохранения дёргаем onSaved чтобы родитель // перезагрузил список (для актуального флага overridden). function PromptCard({ p, isOpen, toggle, onSaved }) { const [edit, setEdit] = React.useState(false); const [text, setText] = React.useState(p.text || ""); const [busy, setBusy] = React.useState(false); const [preview, setPreview] = React.useState(null); // {open, text} const [history, setHistory] = React.useState(null); // {open, list} const [_, toast] = useToasts(); React.useEffect(() => { setText(p.text || ""); setEdit(false); }, [p.text]); // Подгрузка серий + фоток для дропдаунов в Preview. const [feed] = useAsync(async () => { const shoots = await api.feed.shoots(); return shoots || []; }, []); const [shootID, setShootID] = React.useState(0); const [photoID, setPhotoID] = React.useState(0); const [photos] = useAsync(async () => { if (!shootID) return []; return api.feed.photos(shootID); }, [shootID]); React.useEffect(() => { if (photoID === 0 && (photos.data || []).length) setPhotoID(photos.data[0].ID); }, [photos.data]); // miniplan тоже требует фотку — он работает по конкретному gist'у. // recognition и plan — по всей серии или вообще без подстановок. const needsPhoto = p.id === "analyse" || p.id === "generate" || p.id === "miniplan"; const doPreview = async () => { try { const r = await api.previewPrompt(p.id, shootID, needsPhoto ? photoID : 0); setPreview({ open: true, text: r.text || "" }); } catch (e) { toast("Не удалось: " + e.message, "error"); } }; const doHistory = async () => { try { const list = await api.promptHistory(p.id, 20); setHistory({ open: true, list: list || [] }); } catch (e) { toast("Не удалось: " + e.message, "error"); } }; const save = async () => { setBusy(true); try { await api.setPrompt(p.id, text); toast("Промпт сохранён, действует с следующего хода модели", "info"); setEdit(false); onSaved && onSaved(); } catch (e) { toast("Не удалось сохранить: " + e.message, "error"); } finally { setBusy(false); } }; const reset = async () => { if (!confirm("Сбросить «" + p.title + "» к зашитому тексту?\nТекущие правки будут потеряны.")) return; setBusy(true); try { await api.resetPrompt(p.id); toast("Сброшено к дефолту", "info"); setEdit(false); onSaved && onSaved(); } catch (e) { toast("Не удалось: " + e.message, "error"); } finally { setBusy(false); } }; const cancel = () => { setText(p.text || ""); setEdit(false); }; return (
{isOpen && (
{/* Toolbar */}
{!edit ? ( ) : ( <> )} {p.overridden && !edit && ( )} {!edit && ( )}
{text.length} символов
{/* Предпросмотр итогового промпта */} {!edit && (
Предпросмотр для: {needsPhoto && shootID > 0 && ( )}
)} {edit ? (