fix frontend css issue

This commit is contained in:
daniel wang 2026-09-10 06:38:31 +00:00
parent 0f6e33dd76
commit 79952b088b
22 changed files with 1354 additions and 151 deletions

View File

@ -49,6 +49,10 @@ LAZYBOY_MEMORY_ENABLED=true
LAZYBOY_MEMORY_MODEL_CACHE=./data/fastembed
LAZYBOY_MEMORY_TOP_K=8
LAZYBOY_MEMORY_BYTE_BUDGET=6000
# 一次模型請求裡,系統提示 + 工具 schema + 對話(含網頁快照)的字元上限。
# 超過就先把較舊的畫面紀錄收成摘要,再丟掉更舊的輪次,避免整份任務因
# context 爆掉而失敗。預設對 128k 視窗留有餘地;不要開到比模型窗口還大。
LAZYBOY_MODEL_CONTEXT_CHARS=200000
# Hourly cleanup of diagnostics; conversations and current memories are retained.
LAZYBOY_EVENT_RETENTION_DAYS=30

View File

@ -4,6 +4,15 @@ All notable changes to LazyBoy are documented here.
## [Unreleased]
Long computer-use runs no longer die when the prompt creeps a few dozen tokens
over the model's context window. Stale browser snapshots are stubbed, oversized
tool dumps are capped, and an `exceed_context_size` response compact-retries
the same turn instead of failing the job.
Each model provider keeps its own API key, model, and endpoint in the
database. Switching xAI → OpenCode Go no longer wipes the xAI key, and the
settings field shows dots instead of the secret.
Sign in with your own account. The shared install token is gone, and so are the
model keys in the environment.
@ -24,9 +33,20 @@ model keys in the environment.
- Removed: the token login screen, `LAZYBOY_APP_TOKEN`, and the rule that a
non-loopback bind required a 32-character token. `SANDBOX_SUPERVISOR_TOKEN`
and `LAZYBOY_VAULT_KEY` are still required in `.env`.
- **Context window fitting.** Long computer-use runs stub stale browser
snapshots, cap tool dumps, and compact-retry on `exceed_context_size`
instead of failing the job 42 tokens over a 128k window. Tune with
`LAZYBOY_MODEL_CONTEXT_CHARS`.
- **Model keys stay put.** Each provider keeps its own key, model, and base
URL. Switching provider no longer clears the previous key; the form shows
dots and never echoes the token.
### 繁體中文
長時間操作電腦時,舊的網頁快照不再把模型上下文塞爆:較舊的觀察會收成摘要,工具輸出有上限,模型回 `exceed_context_size` 時會壓縮後重試同一輪,而不是整份任務失敗。
每個供應商的 API 金鑰、模型與端點會各自記住。從 xAI 換到 OpenCode Go 不會清掉另一家的金鑰;設定畫面只顯示圓點,真正的 token 留在資料庫。
改成自己的帳號登入:共享的安裝 token 拿掉了,環境變數裡的模型金鑰也拿掉了。
- **在 App 裡註冊與登入**:密碼以 PBKDF2-HMAC-SHA256120,000 輪、每組密碼各自的
@ -41,6 +61,11 @@ model keys in the environment.
用 IP 或 `localhost` 不用設定。
- 移除token 登入畫面、`LAZYBOY_APP_TOKEN`,以及「綁非 loopback 需 32 字元 token」
的規則。`.env` 仍需要 `SANDBOX_SUPERVISOR_TOKEN``LAZYBOY_VAULT_KEY`
- **模型上下文配額。** 長時間操作電腦時,較舊的網頁快照會收成摘要、工具輸出有上限,
模型回 `exceed_context_size` 時會壓縮後重試同一輪,而不是只超出幾十個 token 就
整份任務失敗。可用 `LAZYBOY_MODEL_CONTEXT_CHARS` 調整。
- **模型金鑰會記住。** 每個供應商的金鑰、模型與端點各自存在資料庫。從 xAI 換到
OpenCode Go 不會清掉 xAI 的金鑰;畫面上只顯示圓點,真正的 token 不會再出現。
## [v0.1.0-alpha] - 2026-09-09

View File

@ -22,12 +22,13 @@ import { clockTime, dayLabel, sameDay } from "./chat-time";
import { HANDOFF_MS, VEIL_FADE_MS, handoffRemaining, keepScreenUrl, nextVeil, viewOnlyFor, viewerPath, type Veil } from "./handoff";
import { Avatar, AvatarLookProvider, AvatarStack, BLOBATAR_BACKGROUNDS, BLOBATAR_EXPRESSIONS, BLOBATAR_SHAPES, DEFAULT_LOOK, persistBlobatarShape, readAvatarLooks, resolveBlobatarShape, writeAvatarLook, type AvatarBackground, type AvatarExpression, type AvatarLook } from "./avatar";
import { dateLocale, getLocale, listJoin, setLocale, t, useLocale, type MessageKey } from "./i18n";
import type { AvatarShape, Bot, ComputerMode, ComputerStatus, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, MemoryStatus, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings, WorkspaceSettings } from "./types";
import type { AvatarShape, Bot, ComputerMode, ComputerStatus, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, MemoryStatus, Message, MessageFile, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings } from "./types";
import { ChatMarkdown, MentionText, copyText } from "./markdown";
import { RunProbe, errorActions, errorTitle } from "./run-monitor";
import { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, scheduleWhen, type CronPreset, type ScheduleItem } from "./schedule";
import { CallOverlay, PhoneIcon } from "./call";
import { VoiceSettingsDialog } from "./voice-settings";
import { ModelSettingsDialog } from "./model-settings";
const blankComputer:ComputerStatus={botId:"",mode:"team",state:"stopped",sharedInput:true,controlHolder:"none",takeoverRequested:false,busyBotName:null,busySessionId:null,busyRunId:null,busyStep:null,usingComputer:false,waitingRunId:null,waitingSessionId:null,queuedRuns:0,display:null,profileMode:"per-bot",screenAvailable:false};
const SESSION_STORE="lazyboy.sessionByBot";
@ -1086,67 +1087,6 @@ function WorkspaceSettingsDialog({name,setName,showHidden,setShowHidden,rightCol
<div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary">{t("save")}</button></div>
</form></div>
}
function ModelSettingsDialog({close}:{close:()=>void}){
const[settings,setSettings]=useState<WorkspaceSettings|null>(null);
const[provider,setProvider]=useState<ModelProviderId>("xai");
const[modelId,setModelId]=useState("");
const[baseUrl,setBaseUrl]=useState("");
const[apiKey,setApiKey]=useState("");
const[clearKey,setClearKey]=useState(false);
const[models,setModels]=useState<{id:string;name:string}[]>([]);
const[busy,setBusy]=useState(false);
const[error,setError]=useState("");
const current=settings?.providers.find(item=>item.id===provider);
// The stored key belongs to the provider it was entered for; the server drops
// it on a provider switch, so the form must stop claiming one is stored.
const keyKept=Boolean(settings?.apiKeySet)&&provider===settings?.provider;
useEffect(()=>{api<WorkspaceSettings>("/api/workspace/settings").then(value=>{setSettings(value);setProvider(value.provider);setModelId(value.modelId);setBaseUrl(value.baseUrl);setModels(value.models)}).catch(e=>setError(e instanceof Error?localizeError(e.message):t("loadFailed")))},[]);
async function loadModels(nextProvider:ModelProviderId,nextBaseUrl:string){
if(nextProvider==="openai-compatible"&&!nextBaseUrl.trim()){setModels([]);return}
try{
const query=new URLSearchParams({provider:nextProvider});
if(nextBaseUrl.trim())query.set("baseUrl",nextBaseUrl.trim());
const result=await api<{models:{id:string;name:string}[]}>(`/api/workspace/models?${query}`);
setModels(result.models);
setModelId(current=>result.models.some(item=>item.id===current)?current:result.models[0]?.id||current);
}catch{if(nextProvider==="openai-compatible")setModels([])}
}
function pickProvider(id:ModelProviderId){
setProvider(id);
const info=settings?.providers.find(item=>item.id===id);
if(id==="openai-compatible"){
setModels([]);
setBaseUrl(current=>current.includes("opencode.ai")||current.includes("api.x.ai")?"":current);
if(info?.defaultModel)setModelId(info.defaultModel);else setModelId("");
return;
}
if(info?.defaultBaseUrl)setBaseUrl(info.defaultBaseUrl);
if(info?.defaultModel)setModelId(info.defaultModel);
void loadModels(id,info?.defaultBaseUrl||"");
}
return <div className="modal-backdrop"><form className="dialog settings-dialog" onSubmit={async e=>{e.preventDefault();setBusy(true);setError("");try{await api("/api/workspace/settings",{method:"PATCH",body:JSON.stringify({provider,modelId:modelId.trim(),baseUrl:baseUrl.trim()||null,apiKey:clearKey?"":apiKey.trim()||null,clearApiKey:clearKey})});close()}catch(e){setError(e instanceof Error?localizeError(e.message):t("settingsFailed"))}finally{setBusy(false)}}}>
<div className="dialog-title"><h2>{t("modelSettings")}</h2><button type="button" onClick={close}><X/></button></div>
<p className="dialog-lead">{t("modelSettingsHint")}</p>
<fieldset className="provider-fieldset"><legend>{t("modelProvider")}</legend>
<div className="provider-grid">{(settings?.providers||[{id:"xai" as const,name:t("providerXai")},{id:"opencode-go" as const,name:t("providerOpencodeGo")},{id:"openai-compatible" as const,name:t("providerOpenaiCompatible")}]).map(item=><button type="button" key={item.id} className={provider===item.id?"picked":""} onClick={()=>pickProvider(item.id)}>{item.id==="xai"?t("providerXai"):item.id==="opencode-go"?t("providerOpencodeGo"):t("providerOpenaiCompatible")}</button>)}</div>
<p className="dialog-lead">{provider==="xai"?t("providerXaiHint"):provider==="opencode-go"?t("providerOpencodeGoHint"):t("providerOpenaiCompatibleHint")}</p>
</fieldset>
<label>{t("apiKey")}<input type="password" autoComplete="off" value={apiKey} onChange={e=>{setApiKey(e.target.value);setClearKey(false)}} placeholder={keyKept?t("apiKeyStored"):t("apiKeyPlaceholder")}/></label>
{!keyKept&&!apiKey&&!clearKey&&<p className="dialog-lead">{settings?.apiKeySet?t("modelKeyNeededForProvider"):t("modelKeyNotSet")}</p>}
{keyKept&&<label className="memory-toggle"><input type="checkbox" checked={clearKey} onChange={e=>setClearKey(e.target.checked)}/> {t("clearApiKey")}</label>}
{(current?.needsBaseUrl||provider==="openai-compatible")&&<label>{t("baseUrl")}<input value={baseUrl} onChange={e=>setBaseUrl(e.target.value)} placeholder={t("baseUrlPlaceholder")}/><small>{t("baseUrlHint")}</small></label>}
<label>{t("modelId")}
{models.length>0?<select value={models.some(item=>item.id===modelId)?modelId:""} onChange={e=>setModelId(e.target.value)}>
{models.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}
{!models.some(item=>item.id===modelId)&&modelId?<option value={modelId}>{modelId}</option>:null}
</select>:<input value={modelId} onChange={e=>setModelId(e.target.value)} placeholder={t("modelIdPlaceholder")}/>}
{models.length>0&&<input value={modelId} onChange={e=>setModelId(e.target.value)} placeholder={t("modelIdPlaceholder")}/>}
</label>
<button type="button" className="outline" onClick={()=>void loadModels(provider,baseUrl)}>{t("reloadModels")}</button>
{error&&<div className="pane-error">{error}</div>}
<div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={busy||!modelId.trim()||(provider==="openai-compatible"&&!baseUrl.trim())}>{busy?t("saving"):t("save")}</button></div>
</form></div>
}
function AboutDialog({close}:{close:()=>void}){
const[health,setHealth]=useState<"ok"|"bad"|"…">("…");
useEffect(()=>{fetch("/api/health").then(r=>r.ok?setHealth("ok"):setHealth("bad")).catch(()=>setHealth("bad"))},[]);

View File

@ -322,7 +322,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
collapseRightSidebar: "Collapse right sidebar",
workspaceSettingsHint: "Avatar, memory, and MCP live in the right-hand tabs. This only changes how this workspace is shown.",
modelSettings: "Models",
modelSettingsHint: "This provider and model apply to bots that dont have their own.",
modelSettingsHint: "Each provider keeps its own key, model, and endpoint. The key stays on the server; this screen only shows dots.",
modelProvider: "Model provider",
providerXai: "xAI",
providerOpencodeGo: "OpenCode Go",
@ -332,7 +332,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
providerOpenaiCompatibleHint: "Self-hosted vLLM, Ollama, LiteLLM, or another OpenAI-compatible endpoint.",
apiKey: "API key",
apiKeyPlaceholder: "sk-…",
apiKeyStored: "A key is saved. Leave blank to keep it.",
apiKeyStored: "Saved. Dots only — the real key stays on the server.",
clearApiKey: "Clear saved key",
modelId: "Model",
modelIdPlaceholder: "For example: qwen2.5 or llama3.1",
@ -423,7 +423,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
loginPasswordTooLong: "That password is too long.",
loginPasswordCharacters: "The password contains characters we cant use.",
modelKeyNotSet: "This workspace has no API key yet — paste one below before an agent can work.",
modelKeyNeededForProvider: "You switched provider, so the stored key no longer applies. Paste a key for this provider, or agents will not be able to start after saving.",
modelKeyNeededForProvider: "No key saved for this provider yet. Paste one, or agents will not be able to start after saving.",
agentComputer: "Agent computer",
url: "URL",
stdio: "stdio",
@ -568,6 +568,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
errorTitleNetwork: "Cant reach the model",
errorTitleComputerGone: "Computer is gone",
errorTitleLeaseLost: "Work picked up elsewhere",
errorTitleContextOverflow: "Ran out of model context",
errorTitleUnknown: "Something broke",
errorChip: "Task failed",
errorRetry: "Retry",

View File

@ -104,12 +104,12 @@ export const zhTW = {
phoneAccessDescription: "同一區網的手機用瀏覽器打開這個網址,再用同一個存取 token 登入。", localAddressWarning: "這是本機位址,手機打不開。請改成這台電腦的區網 IP例如 http://192.168.x.x:3101。",
close: "關閉", copied: "已複製", copyUrl: "複製網址", workspaceName: "工作區名稱", showHiddenBots: "顯示已隱藏的機器人", collapseRightSidebar: "收合右側欄",
workspaceSettingsHint: "機器人外觀、記憶與 MCP 外掛在右側分頁。這裡只改這個工作區的顯示。",
modelSettings: "模型", modelSettingsHint: "這裡的供應商與模型會套用到還沒單獨指定模型的機器人。",
modelSettings: "模型", modelSettingsHint: "每個供應商的金鑰、模型與端點會各自記住。金鑰留在伺服器,這裡只顯示圓點。",
modelProvider: "模型供應商", providerXai: "xAI", providerOpencodeGo: "OpenCode Go", providerOpenaiCompatible: "OpenAI 相容",
providerXaiHint: "Grok。金鑰從 console.x.ai 取得。",
providerOpencodeGoHint: "OpenCode Go 訂閱。金鑰從 opencode.ai/auth 取得。",
providerOpenaiCompatibleHint: "自架 vLLM、Ollama、LiteLLM 或其他 OpenAI 相容端點。",
apiKey: "API 金鑰", apiKeyPlaceholder: "sk-…", apiKeyStored: "已儲存金鑰,留空表示沿用。",
apiKey: "API 金鑰", apiKeyPlaceholder: "sk-…", apiKeyStored: "已儲存。只顯示圓點,真正的金鑰留在伺服器。",
clearApiKey: "清除已存金鑰",
modelId: "模型", modelIdPlaceholder: "例如qwen2.5 或 llama3.1",
reloadModels: "重新載入模型列表",
@ -148,7 +148,7 @@ export const zhTW = {
loginUsernameTooShort: "帳號至少 3 個字元。", loginUsernameTooLong: "帳號最多 32 個字元。", loginUsernameCharacters: "帳號只能用字母、數字與 . _ -。", loginUsernameEdges: "帳號必須以字母或數字開頭與結尾。",
loginPasswordTooShort: "密碼至少 8 個字元。", loginPasswordTooLong: "密碼太長了。", loginPasswordCharacters: "密碼含有無法使用的字元。",
modelKeyNotSet: "這個工作區還沒有 API 金鑰,貼上金鑰後 agent 才能開始工作。",
modelKeyNeededForProvider: "換了供應商,原本存的金鑰不能沿用;請貼上這個供應商的金鑰,否則儲存後 agent 會無法開始。",
modelKeyNeededForProvider: "這個供應商還沒存過金鑰。請貼上,否則儲存後 agent 會無法開始。",
agentComputer: "Agent 電腦", url: "URL", stdio: "stdio", http: "HTTP", sse: "SSE",
argumentsPlaceholder: "-y @modelcontextprotocol/server-github", environmentVariablesPlaceholder: "GITHUB_TOKEN=…",
urlPlaceholder: "https://mcp.example.com/mcp", headersPlaceholder: "Authorization=Bearer …",
@ -242,6 +242,7 @@ export const zhTW = {
errorTitleNetwork: "連不到模型",
errorTitleComputerGone: "電腦不見了",
errorTitleLeaseLost: "工作被另一邊接手",
errorTitleContextOverflow: "對話超過模型能記住的長度",
errorTitleUnknown: "出錯了",
errorChip: "任務失敗",
errorRetry: "重試",

58
apps/web/src/model-key.ts Normal file
View File

@ -0,0 +1,58 @@
/** Mask shown in the API key field when a key is already stored. The real
* token never leaves the server; this is only so the field looks filled. */
export const STORED_API_KEY_MASK = "••••••••";
export type ProviderDraft = {
modelId: string;
baseUrl: string;
apiKey: string;
keyEdited: boolean;
clearKey: boolean;
};
export function emptyProviderDraft(modelId = "", baseUrl = ""): ProviderDraft {
return { modelId, baseUrl, apiKey: "", keyEdited: false, clearKey: false };
}
export function storedApiKeyFieldValue(
stored: boolean,
draft: Pick<ProviderDraft, "keyEdited" | "clearKey" | "apiKey">,
): string {
if (stored && !draft.keyEdited && !draft.clearKey) return STORED_API_KEY_MASK;
return draft.apiKey;
}
export function applyApiKeyInput(
showingMask: boolean,
next: string,
): Pick<ProviderDraft, "apiKey" | "keyEdited" | "clearKey"> {
if (showingMask) {
const value = next.split("•").join("");
return { apiKey: value, keyEdited: value.length > 0, clearKey: false };
}
return { apiKey: next, keyEdited: true, clearKey: false };
}
/** What to send on save. `apiKey: null` means keep the stored secret. */
export function apiKeySavePayload(
draft: Pick<ProviderDraft, "keyEdited" | "clearKey" | "apiKey">,
): { apiKey: string | null; clearApiKey: boolean } {
if (draft.clearKey) return { apiKey: "", clearApiKey: true };
if (!draft.keyEdited) return { apiKey: null, clearApiKey: false };
const value = draft.apiKey.trim();
if (!value || value === STORED_API_KEY_MASK) return { apiKey: null, clearApiKey: false };
return { apiKey: value, clearApiKey: false };
}
export function providerHasStoredKey(
settings: {
apiKeySet: boolean;
provider: string;
providers?: { id: string; apiKeySet?: boolean }[];
} | null,
provider: string,
): boolean {
const listed = settings?.providers?.find((item) => item.id === provider);
if (listed && typeof listed.apiKeySet === "boolean") return listed.apiKeySet;
return Boolean(settings?.apiKeySet && settings.provider === provider);
}

View File

@ -0,0 +1,211 @@
import { useEffect, useState } from "react";
import { api } from "./api";
import { t } from "./i18n";
import { X } from "./animated-icons";
import {
applyApiKeyInput,
apiKeySavePayload,
emptyProviderDraft,
providerHasStoredKey,
storedApiKeyFieldValue,
type ProviderDraft,
} from "./model-key";
import type { ModelProviderId, WorkspaceSettings } from "./types";
export function ModelSettingsDialog({ close }: { close: () => void }) {
const [settings, setSettings] = useState<WorkspaceSettings | null>(null);
const [provider, setProvider] = useState<ModelProviderId>("xai");
const [drafts, setDrafts] = useState<Partial<Record<ModelProviderId, ProviderDraft>>>({});
const [models, setModels] = useState<{ id: string; name: string }[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const current = settings?.providers.find((item) => item.id === provider);
const draft = drafts[provider] ?? emptyProviderDraft();
const stored = providerHasStoredKey(settings, provider);
const showingMask = stored && !draft.keyEdited && !draft.clearKey;
const fieldValue = storedApiKeyFieldValue(stored, draft);
const needsKey = current?.needsKey ?? provider !== "openai-compatible";
const hasTypedKey = draft.keyEdited && Boolean(draft.apiKey.trim());
const missingKey = needsKey && !stored && !hasTypedKey && !draft.clearKey;
useEffect(() => {
api<WorkspaceSettings>("/api/workspace/settings")
.then((value) => {
setSettings(value);
setProvider(value.provider);
setModels(value.models);
const next: Partial<Record<ModelProviderId, ProviderDraft>> = {};
for (const item of value.providers) {
const active = item.id === value.provider;
next[item.id] = emptyProviderDraft(
active ? value.modelId : item.modelId || item.defaultModel || "",
active ? value.baseUrl : item.baseUrl || item.defaultBaseUrl || "",
);
}
if (!next[value.provider]) {
next[value.provider] = emptyProviderDraft(value.modelId, value.baseUrl);
}
setDrafts(next);
})
.catch((err) => setError(err instanceof Error ? err.message : t("loadFailed")));
}, []);
function patchDraft(id: ModelProviderId, patch: Partial<ProviderDraft>) {
setDrafts((currentDrafts) => {
const previous = currentDrafts[id] ?? emptyProviderDraft();
return { ...currentDrafts, [id]: { ...previous, ...patch } };
});
}
async function loadModels(nextProvider: ModelProviderId, nextBaseUrl: string) {
if (nextProvider === "openai-compatible" && !nextBaseUrl.trim()) {
setModels([]);
return;
}
try {
const query = new URLSearchParams({ provider: nextProvider });
if (nextBaseUrl.trim()) query.set("baseUrl", nextBaseUrl.trim());
const result = await api<{ models: { id: string; name: string }[] }>(`/api/workspace/models?${query}`);
setModels(result.models);
setDrafts((currentDrafts) => {
const previous = currentDrafts[nextProvider] ?? emptyProviderDraft();
if (previous.modelId.trim()) return currentDrafts;
const modelId = result.models[0]?.id || previous.modelId;
return { ...currentDrafts, [nextProvider]: { ...previous, modelId } };
});
} catch {
if (nextProvider === "openai-compatible") setModels([]);
}
}
function pickProvider(id: ModelProviderId) {
setProvider(id);
const info = settings?.providers.find((item) => item.id === id);
setDrafts((currentDrafts) => {
if (currentDrafts[id]) return currentDrafts;
return {
...currentDrafts,
[id]: emptyProviderDraft(info?.defaultModel || "", info?.defaultBaseUrl || ""),
};
});
const existing = drafts[id];
const baseUrl = existing?.baseUrl ?? info?.baseUrl ?? info?.defaultBaseUrl ?? "";
if (id === "openai-compatible" && !baseUrl.trim()) {
setModels([]);
return;
}
void loadModels(id, baseUrl);
}
return (
<div className="modal-backdrop">
<form
className="dialog settings-dialog"
onSubmit={async (event) => {
event.preventDefault();
setBusy(true);
setError("");
const key = apiKeySavePayload(draft);
try {
await api("/api/workspace/settings", {
method: "PATCH",
body: JSON.stringify({
provider,
modelId: draft.modelId.trim(),
baseUrl: draft.baseUrl.trim() || null,
apiKey: key.apiKey,
clearApiKey: key.clearApiKey,
}),
});
close();
} catch (err) {
setError(err instanceof Error ? err.message : t("settingsFailed"));
} finally {
setBusy(false);
}
}}
>
<div className="dialog-title">
<h2>{t("modelSettings")}</h2>
<button type="button" onClick={close}><X /></button>
</div>
<p className="dialog-lead">{t("modelSettingsHint")}</p>
<fieldset className="provider-fieldset">
<legend>{t("modelProvider")}</legend>
<div className="provider-grid">
{(settings?.providers.map((item) => item.id) || (["xai", "opencode-go", "openai-compatible"] as ModelProviderId[])).map((id) => (
<button type="button" key={id} className={provider === id ? "picked" : ""} onClick={() => pickProvider(id)}>
{id === "xai" ? t("providerXai") : id === "opencode-go" ? t("providerOpencodeGo") : t("providerOpenaiCompatible")}
</button>
))}
</div>
<p className="dialog-lead">
{provider === "xai" ? t("providerXaiHint") : provider === "opencode-go" ? t("providerOpencodeGoHint") : t("providerOpenaiCompatibleHint")}
</p>
</fieldset>
<label>
{t("apiKey")}
<input
type="password"
autoComplete="new-password"
spellCheck={false}
value={fieldValue}
onFocus={(event) => { if (showingMask) event.currentTarget.select(); }}
onChange={(event) => patchDraft(provider, applyApiKeyInput(showingMask, event.target.value))}
placeholder={stored ? t("apiKeyStored") : t("apiKeyPlaceholder")}
/>
</label>
{missingKey ? (
<p className="dialog-lead">
{settings?.providers.some((item) => item.apiKeySet) || settings?.apiKeySet
? t("modelKeyNeededForProvider")
: t("modelKeyNotSet")}
</p>
) : null}
{stored ? (
<label className="memory-toggle">
<input
type="checkbox"
checked={draft.clearKey}
onChange={(event) => patchDraft(provider, { clearKey: event.target.checked, keyEdited: false, apiKey: "" })}
/>
{t("clearApiKey")}
</label>
) : null}
{(current?.needsBaseUrl || provider === "openai-compatible") ? (
<label>
{t("baseUrl")}
<input value={draft.baseUrl} onChange={(event) => patchDraft(provider, { baseUrl: event.target.value })} placeholder={t("baseUrlPlaceholder")} />
<small>{t("baseUrlHint")}</small>
</label>
) : null}
<label>
{t("modelId")}
{models.length > 0 ? (
<select
value={models.some((item) => item.id === draft.modelId) ? draft.modelId : ""}
onChange={(event) => patchDraft(provider, { modelId: event.target.value })}
>
{models.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
{!models.some((item) => item.id === draft.modelId) && draft.modelId ? <option value={draft.modelId}>{draft.modelId}</option> : null}
</select>
) : (
<input value={draft.modelId} onChange={(event) => patchDraft(provider, { modelId: event.target.value })} placeholder={t("modelIdPlaceholder")} />
)}
{models.length > 0 ? (
<input value={draft.modelId} onChange={(event) => patchDraft(provider, { modelId: event.target.value })} placeholder={t("modelIdPlaceholder")} />
) : null}
</label>
<button type="button" className="outline" onClick={() => void loadModels(provider, draft.baseUrl)}>{t("reloadModels")}</button>
{error ? <div className="pane-error">{error}</div> : null}
<div className="dialog-actions">
<button type="button" className="outline" onClick={close}>{t("cancel")}</button>
<button className="primary" disabled={busy || !draft.modelId.trim() || (provider === "openai-compatible" && !draft.baseUrl.trim())}>
{busy ? t("saving") : t("save")}
</button>
</div>
</form>
</div>
);
}

View File

@ -33,6 +33,7 @@ const ACTIONS: Record<string, RunActionId[]> = {
network: ["retry", "settings"],
computer_gone: ["screen", "retry"],
lease_lost: ["retry"],
context_overflow: ["retry"],
unknown: ["retry"],
};
@ -48,6 +49,7 @@ const TITLES: Record<string, MessageKey> = {
network: "errorTitleNetwork",
computer_gone: "errorTitleComputerGone",
lease_lost: "errorTitleLeaseLost",
context_overflow: "errorTitleContextOverflow",
unknown: "errorTitleUnknown",
};

View File

@ -43,7 +43,17 @@ export interface VoiceSettings {
models: { id: string; name: string }[];
voices: { id: string; name: string }[];
}
export interface WorkspaceProvider { id:ModelProviderId; name:string; needsBaseUrl:boolean; needsKey:boolean; defaultBaseUrl:string|null; defaultModel:string|null }
export interface WorkspaceProvider {
id: ModelProviderId;
name: string;
needsBaseUrl: boolean;
needsKey: boolean;
defaultBaseUrl: string | null;
defaultModel: string | null;
apiKeySet: boolean;
modelId: string;
baseUrl: string;
}
export interface WorkspaceModel { id:string; name:string }
export interface WorkspaceSettings { provider:ModelProviderId; modelId:string; baseUrl:string; apiKeySet:boolean; providers:WorkspaceProvider[]; models:WorkspaceModel[] }

View File

@ -0,0 +1,453 @@
//! Fit an in-run conversation into the model's context window.
//!
//! Computer-use runs have no turn quota, so every browser snapshot and shell
//! dump stays in `history` until the provider rejects the prompt. The latest
//! observation must stay readable (element ids live there). Older page dumps
//! are stubbed, long parts are capped, then the oldest turns are dropped.
use rig_core::completion::message::{
AssistantContent, Message, ReasoningContent, ToolResultContent, UserContent,
};
/// Marker written into a stubbed observation so we do not stub twice.
const STUB_MARK: &str = "…(earlier page dump omitted; use the latest observation)";
const TRUNCATED_MARK: &str = "\n…(truncated)";
/// Conservative stand-in for one high-detail screenshot in the char budget.
const IMAGE_CHARS: usize = 8_000;
/// Latest history messages that stay "hot": capped, never stubbed.
const KEEP_RECENT: usize = 6;
/// Cap for a single text part on the latest turns / pending.
pub const HOT_PART: usize = 24 * 1024;
/// Cap for stubbed stale observations.
const STUB_PART: usize = 700;
/// Cap for other stale text (assistant thinking).
const COLD_PART: usize = 2 * 1024;
/// Default payload budget in bytes (preamble + tool schemas + messages).
/// ~2 bytes/token is conservative for mixed CJK; English is cheaper, so this
/// leaves headroom under a 128k-token window after output and images.
const DEFAULT_BUDGET: usize = 200_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FitReport {
pub compacted: bool,
pub dropped: usize,
pub chars_before: usize,
pub chars_after: usize,
}
/// Bytes we are willing to send as text (+ image stand-ins) in one request.
pub fn char_budget() -> usize {
std::env::var("LAZYBOY_MODEL_CONTEXT_CHARS")
.ok()
.and_then(|value| value.trim().parse().ok())
.filter(|value: &usize| *value >= 32_000)
.unwrap_or(DEFAULT_BUDGET)
}
pub fn is_context_overflow(error: &str) -> bool {
let text = error.to_lowercase();
text.contains("exceed_context_size")
|| text.contains("exceeds the available context size")
|| text.contains("context_length_exceeded")
|| text.contains("maximum context length")
|| text.contains("prompt is too long")
|| text.contains("context window")
|| (text.contains("too many tokens") && text.contains("context"))
}
/// `n_prompt_tokens` / `n_ctx` when the provider included them in the body.
pub fn parse_context_window(error: &str) -> Option<(u64, u64)> {
let prompt = json_u64(error, "n_prompt_tokens")?;
let ctx = json_u64(error, "n_ctx")?;
(prompt > 0 && ctx > 0).then_some((prompt, ctx))
}
/// After an overflow, how many chars we should aim to send next.
pub fn overflow_char_budget(error: &str, chars_now: usize) -> usize {
if let Some((prompt, ctx)) = parse_context_window(error)
&& prompt > 0
{
let ratio = (ctx as f64 * 0.85) / prompt as f64;
return ((chars_now as f64 * ratio) as usize).clamp(32_000, chars_now.saturating_sub(1));
}
chars_now.saturating_mul(3) / 4
}
pub fn truncate_chars(text: &str, max_bytes: usize) -> String {
if text.len() <= max_bytes {
return text.to_string();
}
let keep = max_bytes.saturating_sub(TRUNCATED_MARK.len()).max(1);
let mut cut = keep.min(text.len());
while cut > 0 && !text.is_char_boundary(cut) {
cut -= 1;
}
let mut out = text[..cut].to_string();
out.push_str(TRUNCATED_MARK);
out
}
pub fn cap_message_parts(message: &mut Message, max_bytes: usize) {
for_each_text_mut(message, |text| {
if text.len() > max_bytes {
*text = truncate_chars(text, max_bytes);
}
});
}
pub fn estimate_payload_chars(
preamble: &str,
history: &[Message],
pending: &Message,
defs_chars: usize,
) -> usize {
preamble.len()
+ defs_chars
+ history.iter().map(message_chars).sum::<usize>()
+ message_chars(pending)
}
/// Shrink `history` and `pending` until they fit `budget` (or `char_budget()`).
pub fn fit_model_context(
history: &mut Vec<Message>,
pending: &mut Message,
preamble: &str,
defs_chars: usize,
budget: Option<usize>,
) -> FitReport {
let budget = budget.unwrap_or_else(char_budget);
let chars_before = estimate_payload_chars(preamble, history, pending, defs_chars);
let mut dropped = 0;
let mut compacted = false;
compacted |= stub_and_cap(history);
compacted |= cap_message_parts_all(history, pending, HOT_PART, COLD_PART);
while estimate_payload_chars(preamble, history, pending, defs_chars) > budget
&& history.len() > KEEP_RECENT
{
history.remove(0);
dropped += 1;
compacted = true;
}
while estimate_payload_chars(preamble, history, pending, defs_chars) > budget
&& history.len() > 2
{
history.remove(0);
dropped += 1;
compacted = true;
}
if estimate_payload_chars(preamble, history, pending, defs_chars) > budget {
let tight = HOT_PART / 2;
compacted |= cap_message_parts_all(history, pending, tight, tight.min(COLD_PART));
}
while estimate_payload_chars(preamble, history, pending, defs_chars) > budget
&& history.len() > 1
{
history.remove(0);
dropped += 1;
compacted = true;
}
FitReport {
compacted,
dropped,
chars_before,
chars_after: estimate_payload_chars(preamble, history, pending, defs_chars),
}
}
fn stub_and_cap(history: &mut [Message]) -> bool {
let hot_from = history.len().saturating_sub(KEEP_RECENT);
let mut changed = false;
for (index, message) in history.iter_mut().enumerate() {
if index >= hot_from {
continue;
}
changed |= stub_observation_message(message);
cap_message_parts(message, COLD_PART);
}
changed
}
fn cap_message_parts_all(
history: &mut [Message],
pending: &mut Message,
hot: usize,
cold: usize,
) -> bool {
let hot_from = history.len().saturating_sub(KEEP_RECENT);
let mut changed = false;
for (index, message) in history.iter_mut().enumerate() {
let before = message_chars(message);
cap_message_parts(message, if index >= hot_from { hot } else { cold });
changed |= message_chars(message) != before;
}
let before = message_chars(pending);
cap_message_parts(pending, hot);
changed || message_chars(pending) != before
}
fn stub_observation_message(message: &mut Message) -> bool {
let mut changed = false;
for_each_text_mut(message, |text| {
if looks_like_observation(text) && !text.contains(STUB_MARK) {
*text = stub_observation(text);
changed = true;
}
});
changed
}
fn looks_like_observation(text: &str) -> bool {
text.contains("Clickable page elements:")
|| text.contains("Clickable controls")
|| text.contains("Clickable windows")
|| text.contains("Visible text:\n")
|| text.contains("computer observed")
}
fn stub_observation(text: &str) -> String {
let head: Vec<&str> = text.lines().take(4).collect();
let stub = format!("{}\n{STUB_MARK}", head.join("\n"));
if stub.len() > STUB_PART {
truncate_chars(&stub, STUB_PART)
} else {
stub
}
}
fn message_chars(message: &Message) -> usize {
let mut total = 0;
match message {
Message::System { content } => total += content.len(),
Message::User { content } => {
for part in content {
match part {
UserContent::Text(text) => total += text.text.len(),
UserContent::ToolResult(result) => {
for item in &result.content {
match item {
ToolResultContent::Text(text) => total += text.text.len(),
ToolResultContent::Image(_) => total += IMAGE_CHARS,
ToolResultContent::Json { value } => {
total += value.to_string().len();
}
}
}
}
UserContent::Image(_) => total += IMAGE_CHARS,
UserContent::Document(doc) => {
total += serde_json::to_string(doc).map(|s| s.len()).unwrap_or(0);
}
_ => {}
}
}
}
Message::Assistant { content, .. } => {
for part in content {
match part {
AssistantContent::Text(text) => total += text.text.len(),
AssistantContent::ToolCall(call) => {
total +=
call.function.name.len() + call.function.arguments.to_string().len();
}
AssistantContent::Reasoning(reasoning) => {
for item in &reasoning.content {
match item {
ReasoningContent::Text { text, .. }
| ReasoningContent::Summary(text) => total += text.len(),
ReasoningContent::Encrypted(data) => total += data.len(),
ReasoningContent::Redacted { data } => total += data.len(),
}
}
}
AssistantContent::Image(_) => total += IMAGE_CHARS,
}
}
}
}
total
}
fn for_each_text_mut(message: &mut Message, mut visit: impl FnMut(&mut String)) {
match message {
Message::System { content } => visit(content),
Message::User { content } => {
for part in content {
match part {
UserContent::Text(text) => visit(&mut text.text),
UserContent::ToolResult(result) => {
for item in &mut result.content {
if let ToolResultContent::Text(text) = item {
visit(&mut text.text);
}
}
}
_ => {}
}
}
}
Message::Assistant { content, .. } => {
for part in content {
match part {
AssistantContent::Text(text) => visit(&mut text.text),
AssistantContent::Reasoning(reasoning) => {
for item in &mut reasoning.content {
match item {
ReasoningContent::Text { text, .. }
| ReasoningContent::Summary(text) => visit(text),
_ => {}
}
}
}
_ => {}
}
}
}
}
}
fn json_u64(error: &str, key: &str) -> Option<u64> {
let needle = format!("\"{key}\":");
let rest = error.split(&needle).nth(1)?.trim_start();
let digits: String = rest.chars().take_while(|ch| ch.is_ascii_digit()).collect();
digits.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use rig_core::completion::message::{AssistantContent, Message, UserContent};
fn user(text: &str) -> Message {
Message::User {
content: vec![UserContent::text(text)],
}
}
fn assistant(text: &str) -> Message {
Message::Assistant {
id: None,
content: vec![AssistantContent::text(text)],
}
}
fn observation(page: &str, dump: &str) -> String {
format!(
"browser snapshot\nPage: {page} https://example/{page}\nClickable page elements: [1] Go\nVisible text:\n{dump}"
)
}
#[test]
fn the_gmail_overflow_error_is_detected_and_parsed() {
let error = r#"ProviderResponseError: status 400 Bad Request: {"error":{"code":400,"message":"request (132117 tokens) exceeds the available context size (131072 tokens), try increasing it","type":"exceed_context_size_error","n_prompt_tokens":132117,"n_ctx":131072}}"#;
assert!(is_context_overflow(error));
assert_eq!(parse_context_window(error), Some((132117, 131072)));
let next = overflow_char_budget(error, 200_000);
assert!(next < 200_000);
assert!(next >= 32_000);
}
#[test]
fn stale_page_dumps_are_stubbed_and_the_latest_stays() {
let latest = observation("inbox", &"unread ".repeat(2000));
let mut history = Vec::new();
history.push(user(&observation("github", &"readme ".repeat(2000))));
history.push(assistant("click 73"));
for turn in 0..8 {
history.push(user(&observation(
"mid",
&format!("step {turn} ").repeat(200),
)));
history.push(assistant("next"));
}
history.push(user(&observation("oauth", &"consent ".repeat(2000))));
history.push(assistant("continue"));
let mut pending = user(&latest);
let report = fit_model_context(&mut history, &mut pending, "sys", 0, Some(80_000));
assert!(report.compacted);
let Message::User { content } = &history[0] else {
panic!("expected user");
};
let UserContent::Text(text) = &content[0] else {
panic!("expected text");
};
assert!(text.text.contains(STUB_MARK), "{}", text.text);
assert!(!text.text.contains("readme readme"));
let Message::User { content } = &pending else {
panic!("pending");
};
let UserContent::Text(text) = &content[0] else {
panic!("pending text");
};
assert!(text.text.contains("unread unread"));
assert!(!text.text.contains(STUB_MARK));
}
#[test]
fn a_72_turn_trace_fits_a_128k_class_budget() {
let dump = observation("page", &"x".repeat(4_000));
let mut history = Vec::new();
for turn in 0..72 {
history.push(user(&format!("{dump} turn {turn}")));
history.push(assistant("next"));
}
let mut pending = user(&observation("now", &"y".repeat(3_000)));
let before = estimate_payload_chars("preamble", &history, &pending, 10_000);
assert!(before > 200_000, "fixture should start oversized: {before}");
let report = fit_model_context(
&mut history,
&mut pending,
"preamble",
10_000,
Some(200_000),
);
assert!(report.compacted);
assert!(report.chars_after <= 200_000);
let Message::User { content } = &history[0] else {
panic!("expected user");
};
let UserContent::Text(text) = &content[0] else {
panic!("expected text");
};
assert!(
text.text.contains(STUB_MARK),
"oldest dumps should be stubbed"
);
let Message::User { content } = &pending else {
panic!("pending");
};
let UserContent::Text(text) = &content[0] else {
panic!("text");
};
assert!(text.text.contains("yyyy"));
assert!(!text.text.contains(STUB_MARK));
}
#[test]
fn tool_result_dumps_are_capped() {
let mut message = Message::User {
content: vec![UserContent::tool_result(
"c1",
"browser",
vec![ToolResultContent::text(
"Clickable page elements:\n".to_string() + &"z".repeat(80_000),
)],
)],
};
cap_message_parts(&mut message, HOT_PART);
assert!(message_chars(&message) <= HOT_PART + TRUNCATED_MARK.len());
}
#[test]
fn truncate_respects_char_boundaries() {
let text = "你好世界".repeat(20);
let out = truncate_chars(&text, 20);
assert!(out.ends_with("…(truncated)"));
assert!(out.is_char_boundary(out.len() - TRUNCATED_MARK.len()));
}
}

View File

@ -137,6 +137,16 @@ pub struct SpaceRow {
pub voice_api_key: Option<String>,
}
#[derive(Debug, Clone, FromRow)]
#[allow(dead_code)]
pub struct SpaceModelProviderRow {
pub space_id: String,
pub provider: String,
pub model_id: String,
pub base_url: Option<String>,
pub api_key: Option<String>,
}
impl Db {
/// Register a person.
///
@ -345,6 +355,40 @@ impl Db {
.await
}
pub async fn space_model_provider(
&self,
actor: &Actor,
provider: &str,
) -> Result<Option<SpaceModelProviderRow>, sqlx::Error> {
sqlx::query_as(
"SELECT p.space_id, p.provider, p.model_id, p.base_url, p.api_key
FROM space_model_providers p
JOIN spaces s ON s.id = p.space_id
WHERE p.space_id = $1 AND s.user_id = $2 AND p.provider = $3",
)
.bind(&actor.space_id)
.bind(&actor.user_id)
.bind(provider)
.fetch_optional(&self.pool)
.await
}
pub async fn list_space_model_providers(
&self,
actor: &Actor,
) -> Result<Vec<SpaceModelProviderRow>, sqlx::Error> {
sqlx::query_as(
"SELECT p.space_id, p.provider, p.model_id, p.base_url, p.api_key
FROM space_model_providers p
JOIN spaces s ON s.id = p.space_id
WHERE p.space_id = $1 AND s.user_id = $2",
)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_all(&self.pool)
.await
}
pub async fn update_workspace_model(
&self,
actor: &Actor,
@ -353,6 +397,7 @@ impl Db {
base_url: Option<&str>,
api_key: Option<Option<&str>>,
) -> Result<SpaceRow, sqlx::Error> {
let mut tx = self.pool.begin().await?;
match api_key {
Some(key) => {
sqlx::query(
@ -367,7 +412,23 @@ impl Db {
.bind(model_id)
.bind(base_url)
.bind(key)
.execute(&self.pool)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO space_model_providers (space_id, provider, model_id, base_url, api_key)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (space_id, provider) DO UPDATE
SET model_id = EXCLUDED.model_id,
base_url = EXCLUDED.base_url,
api_key = EXCLUDED.api_key,
updated_at = now()",
)
.bind(&actor.space_id)
.bind(provider)
.bind(model_id)
.bind(base_url)
.bind(key)
.execute(&mut *tx)
.await?;
}
None => {
@ -382,10 +443,25 @@ impl Db {
.bind(provider)
.bind(model_id)
.bind(base_url)
.execute(&self.pool)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO space_model_providers (space_id, provider, model_id, base_url)
VALUES ($1, $2, $3, $4)
ON CONFLICT (space_id, provider) DO UPDATE
SET model_id = EXCLUDED.model_id,
base_url = EXCLUDED.base_url,
updated_at = now()",
)
.bind(&actor.space_id)
.bind(provider)
.bind(model_id)
.bind(base_url)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
self.get_space(actor).await?.ok_or(sqlx::Error::RowNotFound)
}
@ -780,4 +856,63 @@ mod tests {
);
assert!(scoped.contains(&(bob, bob_actor.space_id)));
}
#[sqlx::test(migrations = "../../migrations")]
async fn switching_provider_keeps_the_previous_key(pool: sqlx::PgPool) {
let db = Db { pool };
let user = db.create_account("sam", "hash").await.unwrap();
let space_id = db.ensure_default_space(&user).await.unwrap();
let actor = Actor {
user_id: user,
space_id,
};
db.update_workspace_model(&actor, "xai", "grok-4.6", None, Some(Some("xai-key")))
.await
.unwrap();
db.update_workspace_model(&actor, "opencode-go", "glm-5.1", None, Some(Some("go-key")))
.await
.unwrap();
let xai = db
.space_model_provider(&actor, "xai")
.await
.unwrap()
.expect("xAI row stays after switching away");
assert_eq!(xai.api_key.as_deref(), Some("xai-key"));
assert_eq!(xai.model_id, "grok-4.6");
let go = db
.space_model_provider(&actor, "opencode-go")
.await
.unwrap()
.unwrap();
assert_eq!(go.api_key.as_deref(), Some("go-key"));
let stored = db
.space_model_provider(&actor, "xai")
.await
.unwrap()
.unwrap();
db.update_workspace_model(
&actor,
"xai",
"grok-4.5",
None,
Some(stored.api_key.as_deref()),
)
.await
.unwrap();
let space = db.get_space(&actor).await.unwrap().unwrap();
assert_eq!(space.default_model_provider, "xai");
assert_eq!(space.default_model_id, "grok-4.5");
assert_eq!(space.default_model_api_key.as_deref(), Some("xai-key"));
let go = db
.space_model_provider(&actor, "opencode-go")
.await
.unwrap()
.unwrap();
assert_eq!(go.api_key.as_deref(), Some("go-key"));
}
}

View File

@ -2,6 +2,7 @@ mod accounts;
mod attachments;
mod auth;
mod computer;
mod context_fit;
mod db;
mod file_skills;
mod mcp;

View File

@ -325,6 +325,22 @@ pub fn classify_run_error(error: &str) -> RunFailure {
false,
);
}
if has(&[
"exceed_context_size",
"exceeds the available context size",
"context_length_exceeded",
"maximum context length",
"prompt is too long",
]) {
return RunFailure::new(
"context_overflow",
"這一輪對話超過模型能記住的長度,中途的網頁快照把上下文塞滿了。",
"按重試;系統會先把較舊的畫面紀錄收成摘要再繼續,不會重做已完成的步驟。",
true,
false,
false,
);
}
RunFailure::new(
"unknown",
"我卡住了,這一輪沒有完成。",
@ -551,10 +567,21 @@ mod tests {
#[test]
fn please_in_a_provider_message_is_not_a_lost_lease() {
assert_eq!(
code(
r#"ProviderResponseError: status 400 Bad Request: {"error":{"message":"request (132117 tokens) exceeds the available context size (131072 tokens)","type":"exceed_context_size_error"}}"#
),
"context_overflow"
);
let overflow = classify_run_error("exceed_context_size_error");
assert_eq!(overflow.code, "context_overflow");
assert!(overflow.retryable);
assert_eq!(code("run lease was lost before completion"), "lease_lost");
assert_eq!(code("run lease lost before tool dispatch"), "lease_lost");
assert_ne!(
code("status 400 Bad Request: Request is missing x-opencode-session. Please see https://opencode.ai/docs/go/"),
code(
"status 400 Bad Request: Request is missing x-opencode-session. Please see https://opencode.ai/docs/go/"
),
"lease_lost"
);
}

View File

@ -451,13 +451,17 @@ async fn router_model(
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let (base_url, api_key) =
crate::workspace::credentials_for_provider(&state.db, actor, &space, provider.as_str())
.await
.map_err(|error| error.to_string())?;
let backend = resolve_backend(ResolveModelRequest {
provider,
model_id: override_id.or(Some(space.default_model_id)),
base_url: space.default_model_base_url.clone(),
base_url,
credentials: CredentialChain {
bot: None,
space: space.default_model_api_key.clone(),
space: api_key,
},
})
.map_err(|error| error.to_string())?;

View File

@ -1036,6 +1036,51 @@ async fn execute_run(
}
}
drop_history_screenshots(&mut history, &pending);
let defs_chars = serde_json::to_string(&defs).map(|s| s.len()).unwrap_or(0);
let fit = crate::context_fit::fit_model_context(
&mut history,
&mut pending,
&preamble,
defs_chars,
None,
);
if fit.compacted {
tracing::info!(
run_id,
turn = turns,
dropped = fit.dropped,
chars_before = fit.chars_before,
chars_after = fit.chars_after,
"fitted run history into the model context window"
);
crate::monitor::record(
state,
run_id,
"notice",
json!({
"turn": turns,
"text": if fit.dropped > 0 {
format!(
"對話太長,已把較舊的網頁快照收成摘要(丟掉 {} 則舊輪次),繼續用最新畫面做事。",
fit.dropped
)
} else {
"對話太長,已把較舊的網頁快照收成摘要,繼續用最新畫面做事。".to_string()
},
}),
)
.await;
let _ = save_harness_checkpoint(
state,
run_id,
lease_owner,
&history,
&pending,
turns,
steering_seq,
)
.await;
}
set_run_progress(state, run_id, MODEL_STEP, turns, turn_limit).await;
let model_started = std::time::Instant::now();
let content = tokio::select! {
@ -1055,9 +1100,9 @@ async fn execute_run(
}
result = complete_with_retry(
&model,
pending.clone(),
&mut pending,
&preamble,
&history,
&mut history,
&defs,
Trace { state, run_id, thread_id, bot_id, turn: turns },
) => result
@ -1394,7 +1439,10 @@ async fn execute_run(
call.id.clone(),
call.provider.clone(),
name,
vec![ToolResultContent::text(&outcome.text)],
vec![ToolResultContent::text(crate::context_fit::truncate_chars(
&outcome.text,
crate::context_fit::HOT_PART,
))],
));
if !outcome.blocks.is_empty() && !outcome.pause {
let _ = append_bot_message_with(
@ -1622,13 +1670,17 @@ pub(crate) async fn bot_model(
.clone()
.filter(|value| !value.is_empty())
.or_else(|| Some(space.default_model_id.clone()).filter(|value| !value.is_empty()));
let (base_url, api_key) =
crate::workspace::credentials_for_provider(&state.db, actor, &space, provider.as_str())
.await
.map_err(|error| error.to_string())?;
let backend = resolve_backend(ResolveModelRequest {
provider,
model_id,
base_url: space.default_model_base_url.clone(),
base_url,
credentials: CredentialChain {
bot: None,
space: space.default_model_api_key.clone(),
space: api_key,
},
})
.map_err(|error| error.to_string())?;
@ -1674,13 +1726,14 @@ struct Trace<'a> {
async fn complete_with_retry(
model: &DynModel,
pending: Message,
pending: &mut Message,
preamble: &str,
history: &[Message],
history: &mut Vec<Message>,
defs: &[ToolDefinition],
trace: Trace<'_>,
) -> Result<Vec<AssistantContent>, String> {
let mut last = String::new();
let defs_chars = serde_json::to_string(defs).map(|s| s.len()).unwrap_or(0);
for attempt in 0..3 {
if attempt > 0 {
let failure = crate::monitor::classify_run_error(&last);
@ -1704,6 +1757,7 @@ async fn complete_with_retry(
match result {
Ok(Ok(content)) => return Ok(content),
Ok(Err(error)) => {
let overflow = crate::context_fit::is_context_overflow(&error);
crate::monitor::record(
trace.state,
trace.run_id,
@ -1712,19 +1766,51 @@ async fn complete_with_retry(
"turn": trace.turn,
"attempt": attempt + 1,
"error": error,
"gaveUp": attempt == 2 || !retryable_run_error(&error),
"gaveUp": attempt == 2 || (!overflow && !retryable_run_error(&error)),
}),
)
.await;
if !retryable_run_error(&error) {
if overflow {
let now = crate::context_fit::estimate_payload_chars(
preamble, history, pending, defs_chars,
);
let budget = crate::context_fit::overflow_char_budget(&error, now);
let fit = crate::context_fit::fit_model_context(
history,
pending,
preamble,
defs_chars,
Some(budget),
);
tracing::warn!(
attempt = attempt + 1,
dropped = fit.dropped,
chars_before = fit.chars_before,
chars_after = fit.chars_after,
budget,
"model rejected an oversized prompt; compacted and retrying"
);
crate::monitor::record(
trace.state,
trace.run_id,
"notice",
json!({
"turn": trace.turn,
"text": "模型說這輪對話超過上下文上限,已再壓縮一次後重試,不會重做已完成的步驟。",
}),
)
.await;
last = error;
} else if !retryable_run_error(&error) {
return Err(error);
} else {
tracing::warn!(
attempt = attempt + 1,
elapsed_ms = started.elapsed().as_millis() as u64,
"model attempt failed: {error}"
);
last = error;
}
tracing::warn!(
attempt = attempt + 1,
elapsed_ms = started.elapsed().as_millis() as u64,
"model attempt failed: {error}"
);
last = error;
}
Err(_) => {
let error = "model request timed out after 165 seconds";
@ -1951,26 +2037,10 @@ fn assistant_texts(history: &[Message]) -> Vec<String> {
fn shrink_checkpoint(history: &mut Vec<Message>, pending: &mut Message) {
const LIMIT: usize = 768 * 1024;
const MAX_PART: usize = 48 * 1024;
let cap_parts = |message: &mut Message| {
let Message::User { content } = message else {
return;
};
for part in content.iter_mut() {
let UserContent::Text(text) = part else {
continue;
};
if text.text.len() > MAX_PART {
let mut cut = MAX_PART;
while cut > 0 && !text.text.is_char_boundary(cut) {
cut -= 1;
}
text.text.truncate(cut);
text.text.push_str("\n…(truncated)");
}
}
};
cap_parts(pending);
history.iter_mut().for_each(cap_parts);
crate::context_fit::cap_message_parts(pending, MAX_PART);
history
.iter_mut()
.for_each(|message| crate::context_fit::cap_message_parts(message, MAX_PART));
let fits = |turns: &[Message]| {
json!({"harnessHistory": turns, "harnessPending": pending})
.to_string()
@ -3761,5 +3831,11 @@ mod tests {
"ProviderResponseError: status 429 Too Many Requests"
));
assert!(retryable_run_error("connection reset"));
// 400 overflow is handled inside the model turn (compact + retry).
// Re-queuing the same oversized checkpoint at the worker would just
// bounce. Fitting happens when the run is claimed again.
assert!(!retryable_run_error(
"ProviderResponseError: status 400 Bad Request: exceed_context_size_error"
));
}
}

View File

@ -1014,14 +1014,7 @@ async fn finalize(
let distilled = match bot {
Some(bot) => {
distill(
state,
actor,
&bot,
&row.id,
&row.goal,
&events,
&frames,
&dir,
state, actor, &bot, &row.id, &row.goal, &events, &frames, &dir,
)
.await
}

View File

@ -6,7 +6,7 @@ use lazyboy_contracts::{ModelProvider, catalog_models, default_model_id};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::db::Actor;
use crate::db::{Actor, SpaceModelProviderRow, SpaceRow};
use crate::state::AppState;
pub fn router() -> Router<AppState> {
@ -27,6 +27,9 @@ struct ProviderInfo {
needs_key: bool,
default_base_url: Option<&'static str>,
default_model: Option<&'static str>,
api_key_set: bool,
model_id: String,
base_url: String,
}
#[derive(Serialize)]
@ -36,7 +39,7 @@ struct ModelChoice {
name: String,
}
fn provider_info(provider: ModelProvider) -> ProviderInfo {
fn provider_catalog(provider: ModelProvider) -> ProviderInfo {
ProviderInfo {
id: provider.as_str(),
name: match provider {
@ -51,12 +54,93 @@ fn provider_info(provider: ModelProvider) -> ProviderInfo {
needs_key: provider.requires_api_key(),
default_base_url: provider.default_base_url(),
default_model: default_model_id(provider),
api_key_set: false,
model_id: default_model_id(provider).unwrap_or("").to_string(),
base_url: provider.default_base_url().unwrap_or("").to_string(),
}
}
/// The model settings for one workspace. `apiKeySet` is the whole story now:
/// a key exists only if this person pasted one here, and while it is false no
/// agent can run.
fn nonempty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|item| !item.is_empty())
}
/// Key and base URL for one provider: the row saved for it, or the workspace
/// default columns when that provider is the one currently selected.
pub(crate) fn credentials_for(
space: &SpaceRow,
stored: Option<&SpaceModelProviderRow>,
provider: &str,
) -> (Option<String>, Option<String>) {
let from_space = space.default_model_provider == provider;
let base_url = stored
.and_then(|row| nonempty(row.base_url.as_deref()).map(str::to_string))
.or_else(|| {
if from_space {
space.default_model_base_url.clone()
} else {
None
}
});
let api_key = stored
.and_then(|row| nonempty(row.api_key.as_deref()).map(str::to_string))
.or_else(|| {
if from_space {
space.default_model_api_key.clone()
} else {
None
}
});
(base_url, api_key)
}
pub(crate) async fn credentials_for_provider(
db: &crate::db::Db,
actor: &Actor,
space: &SpaceRow,
provider: &str,
) -> Result<(Option<String>, Option<String>), sqlx::Error> {
let stored = db.space_model_provider(actor, provider).await?;
Ok(credentials_for(space, stored.as_ref(), provider))
}
/// Keep a newly pasted key, otherwise the key already stored for this
/// provider. Switching provider no longer wipes a key that belongs to
/// another provider — that row is left untouched.
fn persist_api_key<'a>(
clear: bool,
supplied: Option<&'a str>,
stored: Option<&'a str>,
) -> Option<&'a str> {
if clear { None } else { supplied.or(stored) }
}
fn provider_status(
provider: ModelProvider,
space: &SpaceRow,
stored: Option<&SpaceModelProviderRow>,
) -> ProviderInfo {
let mut info = provider_catalog(provider);
let active = space.default_model_provider == provider.as_str();
let (base_url, api_key) = credentials_for(space, stored, provider.as_str());
info.api_key_set = nonempty(api_key.as_deref()).is_some();
info.model_id = if active {
space.default_model_id.clone()
} else {
stored
.map(|row| row.model_id.clone())
.filter(|value| !value.is_empty())
.unwrap_or(info.model_id)
};
info.base_url = if active {
space.default_model_base_url.clone().unwrap_or_default()
} else {
base_url.unwrap_or(info.base_url)
};
info
}
/// The model settings for one workspace. `apiKeySet` is a flag only: the
/// secret itself stays in the database and is never returned to the client.
async fn settings_payload(state: &AppState, actor: &Actor) -> Result<Json<Value>, StatusCode> {
let space = state
.db
@ -68,12 +152,25 @@ async fn settings_payload(state: &AppState, actor: &Actor) -> Result<Json<Value>
.default_model_provider
.parse::<ModelProvider>()
.unwrap_or(ModelProvider::Xai);
let stored = state
.db
.list_space_model_providers(actor)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let providers = ModelProvider::selectable()
.iter()
.copied()
.map(|item| {
let row = stored.iter().find(|row| row.provider == item.as_str());
provider_status(item, &space, row)
})
.collect::<Vec<_>>();
Ok(Json(json!({
"provider": provider.as_str(),
"modelId": space.default_model_id,
"baseUrl": space.default_model_base_url.unwrap_or_default(),
"apiKeySet": space.default_model_api_key.as_deref().is_some_and(|value| !value.is_empty()),
"providers": ModelProvider::selectable().iter().copied().map(provider_info).collect::<Vec<_>>(),
"baseUrl": space.default_model_base_url.clone().unwrap_or_default(),
"apiKeySet": nonempty(space.default_model_api_key.as_deref()).is_some(),
"providers": providers,
"models": catalog_models(provider).iter().map(|(id, name)| ModelChoice { id: (*id).into(), name: (*name).into() }).collect::<Vec<_>>(),
})))
}
@ -120,25 +217,28 @@ async fn update_settings(
.get_space(&actor)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let provider_changed = current
let stored = state
.db
.space_model_provider(&actor, provider.as_str())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let stored_key = stored
.as_ref()
.is_some_and(|space| space.default_model_provider != provider.as_str());
let supplied = input
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
// A stored key belongs to the provider it was entered for. Carrying one
// over to a different provider only guarantees an "Incorrect API key"
// error, so it is dropped unless a new key is supplied.
let api_key = if input.clear_api_key || (provider_changed && supplied.is_none()) {
Some(None)
} else {
supplied.map(Some)
};
.and_then(|row| nonempty(row.api_key.as_deref()))
.or_else(|| {
current.as_ref().and_then(|space| {
if space.default_model_provider == provider.as_str() {
nonempty(space.default_model_api_key.as_deref())
} else {
None
}
})
});
let supplied = nonempty(input.api_key.as_deref());
let api_key = persist_api_key(input.clear_api_key, supplied, stored_key);
state
.db
.update_workspace_model(&actor, provider.as_str(), model_id, base_url, api_key)
.update_workspace_model(&actor, provider.as_str(), model_id, base_url, Some(api_key))
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
settings_payload(&state, &actor).await
@ -169,6 +269,16 @@ async fn list_models(
.parse::<ModelProvider>()
.map_err(|_| StatusCode::BAD_REQUEST)?;
let space = state.db.get_space(&actor).await.ok().flatten();
let stored = state
.db
.space_model_provider(&actor, provider.as_str())
.await
.ok()
.flatten();
let (stored_base, stored_key) = match &space {
Some(space) => credentials_for(space, stored.as_ref(), provider.as_str()),
None => (None, None),
};
let fallback = catalog_models(provider)
.iter()
.map(|(id, name)| ModelChoice {
@ -178,12 +288,8 @@ async fn list_models(
.collect::<Vec<_>>();
let live = fetch_remote_models(
provider,
query.base_url.as_deref().or(space
.as_ref()
.and_then(|row| row.default_model_base_url.as_deref())),
space
.as_ref()
.and_then(|row| row.default_model_api_key.as_deref()),
query.base_url.as_deref().or(stored_base.as_deref()),
stored_key.as_deref(),
)
.await
.unwrap_or_default();
@ -235,3 +341,75 @@ async fn fetch_remote_models(
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn space(provider: &str, key: &str) -> SpaceRow {
SpaceRow {
id: "space-1".into(),
user_id: "user-1".into(),
name: "Workspace".into(),
default_model_provider: provider.into(),
default_model_id: "grok-4.6".into(),
default_model_base_url: None,
default_model_api_key: Some(key.into()),
voice_enabled: false,
voice_provider: None,
voice_model_id: None,
voice_id: None,
voice_api_key: None,
}
}
#[test]
fn persist_api_key_keeps_the_stored_secret_when_the_field_is_blank() {
assert_eq!(
persist_api_key(false, None, Some("stored-key")),
Some("stored-key")
);
assert_eq!(
persist_api_key(false, Some("new-key"), Some("stored-key")),
Some("new-key")
);
assert_eq!(
persist_api_key(true, Some("new-key"), Some("stored-key")),
None
);
assert_eq!(persist_api_key(false, None, None), None);
}
#[test]
fn credentials_for_uses_the_row_even_when_another_provider_is_active() {
let space = space("opencode-go", "go-key");
let stored = SpaceModelProviderRow {
space_id: space.id.clone(),
provider: "xai".into(),
model_id: "grok-4.6".into(),
base_url: Some("https://api.x.ai/v1".into()),
api_key: Some("xai-key".into()),
};
let (base, key) = credentials_for(&space, Some(&stored), "xai");
assert_eq!(key.as_deref(), Some("xai-key"));
assert_eq!(base.as_deref(), Some("https://api.x.ai/v1"));
}
#[test]
fn provider_status_never_echoes_the_secret() {
let space = space("xai", "super-secret-token");
let stored = SpaceModelProviderRow {
space_id: space.id.clone(),
provider: "xai".into(),
model_id: "grok-4.6".into(),
base_url: None,
api_key: Some("super-secret-token".into()),
};
let encoded =
serde_json::to_string(&provider_status(ModelProvider::Xai, &space, Some(&stored)))
.unwrap();
assert!(encoded.contains("\"apiKeySet\":true"));
assert!(!encoded.contains("super-secret-token"));
assert!(!encoded.contains("\"apiKey\":"));
}
}

View File

@ -317,8 +317,13 @@ mod tests {
.starts_with("lazyboy/")
);
// No conversation id is better than a made-up or malformed one.
assert!(!provider_headers(ModelProvider::OpencodeGo, " ").contains_key("x-opencode-session"));
assert!(!provider_headers(ModelProvider::OpencodeGo, "bad\nvalue").contains_key("x-opencode-session"));
assert!(
!provider_headers(ModelProvider::OpencodeGo, " ").contains_key("x-opencode-session")
);
assert!(
!provider_headers(ModelProvider::OpencodeGo, "bad\nvalue")
.contains_key("x-opencode-session")
);
assert!(provider_headers(ModelProvider::Xai, "thread-42").is_empty());
}

View File

@ -74,6 +74,8 @@ curl -N -b cookies.txt http://127.0.0.1:3101/api/sessions/<id>/events
# 另開一個終端機送訊息,看事件幾毫秒後出現在這條串流裡
```
長任務還有一層上下文保險絲:舊的網頁快照會收成摘要,最新畫面保持完整。這不是另開一個模型做壓縮,而是避免 70 輪 DOM 把 128k 窗口塞爆。細節見 [模型上下文](./operations.md#模型上下文)。
## 已知取捨
- 模型回覆還是「整個完成」才出現,沒有串流 token。思考中的狀態有顯示但要像 ChatGPT 那樣逐字

View File

@ -24,7 +24,7 @@
3. `SANDBOX_SUPERVISOR_TOKEN``LAZYBOY_VAULT_KEY` 必須使用不同的高熵值。
4. 模型仍可能看見任務所需的網頁內容與截圖密碼、token 與高敏感資料不要放進提示詞。
5. 簡單的 Cloudflare 連線驗證可嘗試一次正常點擊;未通過、其他 CAPTCHA 與 2FA 交由使用者接管。
6. 模型 API 金鑰只吃各 workspace 在「設定 → 模型」裡貼上的值,不再讀環境變數;沒設定金鑰的 workspace 跑任務會立刻失敗,並在畫面告訴你要去哪裡補。
6. 模型 API 金鑰只吃各 workspace 在「設定 → 模型」裡貼上的值,不再讀環境變數;沒設定金鑰的 workspace 跑任務會立刻失敗,並在畫面告訴你要去哪裡補。每個供應商各自記住金鑰、模型與端點,切換時不會清掉另一家的金鑰;畫面只顯示圓點,真正的 token 留在資料庫,不會再回傳給瀏覽器。
## 硬體與資源
@ -137,6 +137,26 @@ LAZYBOY_RUN_HARD_MINUTES=240 # 時間保險絲
真的很久的工作(大計畫、批量資料處理)不該塞在一個 run 裡,改用排程工作分段跑,比較容易驗證也比較省 token。
## 模型上下文
電腦任務沒有輪數額度,每一輪的瀏覽器快照與終端機輸出都會留在這次 run 的對話裡。模型真正需要的是**最新那一張畫面的 element id**;七十輪之前的整頁 DOM 只會把 128k 窗口塞滿,最後只超出幾十個 token 就整份任務失敗。
系統在每次呼叫模型前會:
1. 只留最新一張截圖(原本就這樣)。
2. 把較舊的網頁/桌面觀察收成幾行摘要(標題、網址、動作),最新幾輪維持完整。
3. 單次工具輸出超過約 24 KB 就截斷。
4. 還是太長就從最舊的輪次往下丟,直到估出來的長度低於 `LAZYBOY_MODEL_CONTEXT_CHARS`
5. 若模型仍回 `exceed_context_size`,再壓一次後重試同一輪,不重做已完成的步驟。
這不是把整段對話丟進另一個模型做摘要。舊的 element id 本來就不能再用,丟掉它們是對的;最新觀察不能壓掉,否則下一步點擊會失去目標。
```bash
LAZYBOY_MODEL_CONTEXT_CHARS=200000 # 一次請求的字元預算(含系統提示與工具結果)
```
數字是字元不是 token而且估得偏保守約 2 字元 / token所以會比模型窗口先開始摘要。不要把這個值開到比模型窗口還大那只會讓失敗發生在供應商那一側任務一樣中斷。
## 群組聊天:誰說話
群組不是廣播。訊息進來的當下就決定好誰要回覆,其他人不被叫醒,也就不會各打一輪模型。

View File

@ -0,0 +1,20 @@
-- Each provider keeps its own key, model, and base URL so switching
-- xAI → OpenCode Go (and back) does not wipe what was already saved.
-- The active choice still lives on spaces.default_model_*; this table is
-- the memory for the providers that are not currently selected.
-- API keys stay here and are never returned to the client.
CREATE TABLE IF NOT EXISTS space_model_providers (
space_id TEXT NOT NULL REFERENCES spaces (id) ON DELETE CASCADE,
provider TEXT NOT NULL,
model_id TEXT NOT NULL DEFAULT '',
base_url TEXT,
api_key TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (space_id, provider)
);
INSERT INTO space_model_providers (space_id, provider, model_id, base_url, api_key)
SELECT id, default_model_provider, default_model_id, default_model_base_url, default_model_api_key
FROM spaces
ON CONFLICT (space_id, provider) DO NOTHING;

View File

@ -305,7 +305,7 @@ const monitorBox={exports:{},require:name=>{
}};
vm.runInNewContext(monitorJs,monitorBox);
const {formatElapsed,shortDuration,errorActions,errorTitle,trailText}=monitorBox.exports;
const FAILURE_CODES=['interrupted','tool_timeout','model_key','model_key_missing','model_opt_in','model_quota','model_unknown','model_timeout','network','computer_gone','lease_lost','unknown'];
const FAILURE_CODES=['interrupted','tool_timeout','model_key','model_key_missing','model_opt_in','model_quota','model_unknown','model_timeout','network','computer_gone','lease_lost','context_overflow','unknown'];
test('run timings stay on one glanceable line',()=>{
assert.equal(formatElapsed(0),'0:00');
@ -332,6 +332,7 @@ test('every failure code offers at least one action, in the right order',()=>{
assert.equal(buttons('tool_timeout'),'screen,retry');
assert.equal(buttons('interrupted'),'screen,retry');
assert.equal(buttons('lease_lost'),'retry');
assert.equal(buttons('context_overflow'),'retry');
for(const code of [...FAILURE_CODES,'made_up',undefined,null]){
const actions=errorActions(code);
assert.ok(actions.length>0,String(code));
@ -779,3 +780,39 @@ test('messages split by calendar day and show only their clock time',()=>{
assert.equal(clockTime(new Date(2026,9,7,15,30),'en'),'15:30');
assert.equal(clockTime('garbage','en'),'');
});
const keyJs=ts.transpileModule(fs.readFileSync('apps/web/src/model-key.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
const keyBox={exports:{},require:()=>{throw new Error('unexpected import')}};
vm.runInNewContext(keyJs,keyBox);
const {STORED_API_KEY_MASK,storedApiKeyFieldValue,applyApiKeyInput,apiKeySavePayload,providerHasStoredKey,emptyProviderDraft}=keyBox.exports;
test('a stored API key is shown as dots and never sent back',()=>{
const stored=emptyProviderDraft();
assert.equal(storedApiKeyFieldValue(true,stored),STORED_API_KEY_MASK);
const keep=apiKeySavePayload(stored);
assert.equal(keep.apiKey,null);
assert.equal(keep.clearApiKey,false);
const typed=applyApiKeyInput(true,STORED_API_KEY_MASK+'sk-new');
assert.equal(typed.apiKey,'sk-new');
assert.equal(typed.keyEdited,true);
const replaced=apiKeySavePayload(typed);
assert.equal(replaced.apiKey,'sk-new');
assert.equal(replaced.clearApiKey,false);
const cleared=apiKeySavePayload({...stored,clearKey:true});
assert.equal(cleared.apiKey,'');
assert.equal(cleared.clearApiKey,true);
const masked=apiKeySavePayload({apiKey:STORED_API_KEY_MASK,keyEdited:true,clearKey:false});
assert.equal(masked.apiKey,null);
assert.equal(masked.clearApiKey,false);
});
test('each provider remembers whether it already has a key',()=>{
const settings={apiKeySet:true,provider:'xai',providers:[
{id:'xai',apiKeySet:true},
{id:'opencode-go',apiKeySet:false},
]};
assert.equal(providerHasStoredKey(settings,'xai'),true);
assert.equal(providerHasStoredKey(settings,'opencode-go'),false);
assert.equal(providerHasStoredKey({apiKeySet:true,provider:'xai'},'xai'),true);
assert.equal(providerHasStoredKey({apiKeySet:true,provider:'xai'},'opencode-go'),false);
});