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}
navigator.clipboard?.writeText(deviceId)}
title="Скопировать deviceId">
)}
{!configured && (
Чтобы появилось подключение, задай в окружении{" "}
OPENCLAW_URL=ws://192.168.31.101:18789 {" "}
и перезапусти pikasrv.
)}
{configured && !paired && (
{busy ? <> запрашиваем…> : <> Попробовать подключиться>}
— проверяет, есть ли уже работающий токен
{ 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 нужно пройти заново.
{" "}
{busy ? <> проверяем…> : "Проверить подключение"}
)}
);
}
// 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)" }}/>
{busy ? <> отправляю…> : "Запросить pairing"}
{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 (
e.stopPropagation()}
style={{
background: "var(--surface)", border: "1px solid var(--line)",
borderRadius: "var(--r-lg)", padding: 16,
width: "min(900px, 100%)", maxHeight: "90vh", overflow: "auto",
boxShadow: "0 10px 40px rgba(0,0,0,0.3)",
}}>
{children}
);
}
// HistoryEntry — одна запись из prompts.history.jsonl с разворачивающимся
// diff'ом (просто двумя блоками для краткости — Лена не разработчик).
function HistoryEntry({ e }) {
const [open, setOpen] = React.useState(false);
const dt = new Date(e.time);
const time = dt.toLocaleString("ru-RU", { hour12: false });
const oldLen = (e.oldText || "").length;
const newLen = (e.newText || "").length;
const delta = newLen - oldLen;
return (
Логи сервиса
Последние записи pikasrv. Auto-refresh каждые 3 сек. Видно ошибки распознавания,
обращения к OpenClaw, Yandex и т.п.
Уровень:
setLevel(e.target.value)}
style={{ padding: "4px 22px 4px 8px", fontSize: 12.5 }}>
все
INFO+
WARN+
только ERROR
setPaused((p) => !p)}>
{paused ? "▶ возобновить" : "⏸ пауза"}
обновить
{entries.length} записей
{entries.length === 0 && (
нет записей такого уровня
)}
{entries.map((e, i) => (
{new Date(e.time).toLocaleTimeString("ru-RU", { hour12: false }) + "." +
String(new Date(e.time).getMilliseconds()).padStart(3, "0")}
{e.level}
{e.msg}
{e.attrs && Object.keys(e.attrs).length > 0 && (
{Object.entries(e.attrs).map(([k, v]) => `${k}=${v}`).join(" ")}
)}
{e.source && (
@{e.source}
)}
))}
);
}
window.LogsSection = LogsSection;
window.SettingsScreen = SettingsScreen;