Merge studio into dependabot/electron-45.1.0

This commit is contained in:
esengine committed 2026-11-01 19:10:24 -08:00
commit 678cf19f03
98 files changed
+3722 -283

No files matched your search

+1 -1
View File
@@ -124,7 +124,7 @@ jobs:
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
- uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
distribution: temurin
java-version: "21"
+1
View File
@@ -188,6 +188,7 @@ jobs:
node perf/web-access.mjs
node perf/peers-settings.mjs
node perf/output-cap.mjs
node perf/tool-output-clip.mjs
node perf/agent-run-tab.mjs
node perf/changes-cap.mjs
node perf/session-menu-fit.mjs
+14 -10
View File
@@ -12,8 +12,8 @@
"census:verify": "node tools/ui-census/verify.mjs"
},
"dependencies": {
"@a2ui/react": "0.13.0",
"@a2ui/web_core": "0.13.0",
"@a2ui/react": "0.14.0",
"@a2ui/web_core": "0.14.0",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
@@ -30,17 +30,19 @@
"@codemirror/language": "^6.13.1",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.14",
"@codemirror/view": "^6.43.15",
"@lezer/highlight": "^1.2.5",
"@mantine/charts": "9.7.3",
"@mantine/core": "9.7.3",
"@mantine/hooks": "9.7.3",
"@mantine/charts": "9.7.4",
"@mantine/core": "9.7.4",
"@mantine/hooks": "9.7.4",
"@noble/curves": "2.4.0",
"codemirror": "^6.0.2",
"docx-preview": "0.4.1",
"dompurify": "^3.4.17",
"katex": "^0.19.0",
"lucide-react": "^1.61.0",
"maplibre-gl": "6.15.0",
"exceljs": "4.4.0",
"katex": "^0.19.1",
"lucide-react": "^1.64.0",
"maplibre-gl": "6.16.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"papaparse": "^5.7.0",
@@ -55,10 +57,11 @@
"remark-gemoji": "^8.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"ssf": "0.11.2",
"zod": "3.25.76"
},
"devDependencies": {
"@babel/parser": "^8.0.7",
"@babel/parser": "^8.0.8",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.8",
"@types/papaparse": "^5.5.2",
@@ -66,6 +69,7 @@
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"jsdom": "^30.1.3",
"jszip": "3.10.2",
"playwright": "^1.64.1",
"typescript": "^7.0.2",
"vite": "^8.3.5",
+1
View File
@@ -76,6 +76,7 @@ node perf/focus.mjs # 专注的守卫:拿走外围之后,退出时世
node perf/tooltip.mjs # 控件提示的守卫:文字不溢出提示框、不在半透明祖先之下、对比度达 AA(深浅×中英×宽窄)
node perf/navhint.mjs # Icon-rail hover hints: drawn, inside the window, clipped by no ancestor, painted over by nothing (light/dark × on/collapsed)
node perf/runningtool.mjs # 还没输出的运行中调用:行能展开、写明仍在运行与已运行多久、写出命令自带的超时
node perf/tool-output-clip.mjs # Clipped results: no painted shadows; expand/collapse, horizontal scroll, full copy and reopen state (light/dark × wide/phone); TOOL_OUTPUT_SHOTS saves screenshots
node perf/gutterhit.mjs # 分栏拖拽手柄不压在相邻滚动条上:滚动条拇指处按下命中的不是手柄,握把仍可拖开收起的栏
node perf/dock.mjs # 停靠的浏览器列从 pane 顶部起、与导航行并排,取消停靠后行归还(浅/深 × 宽/窄 × 首开/重载)
node perf/signals.mjs # 运行分析的信号轨说明:它是什么、为什么标记不能拖(浅/深 × 宽/手机)
+5 -3
View File
@@ -607,7 +607,7 @@ declare global {
__panes: () => number;
// Answer every completion request with this one, as the kernel would for a command typed in full.
__complete: (answer: import("../src/port/port").Completion) => void;
__previewFiles: (files: Record<string, { kind: PreviewInfo["kind"]; text: string }>) => void;
__previewFiles: (files: Record<string, { kind: PreviewInfo["kind"]; text?: string; base64?: string }>) => void;
// Sub-agent runs the kernel would list, each with the conversation it would return.
__subagents: (runs: { run: SubagentRun; messages: HistoryMessage[] }[]) => void;
// What folding a stored transcript into cards costs on its own, with no
@@ -628,13 +628,15 @@ window.__toast = pushToast;
window.__syntax = { apply: applyPack, mount: mountEditor };
window.__feed = (ev) => hub.feeds.forEach((p) => p.feed(ev));
window.__panes = () => hub.feeds.size;
const fixtureBytes = (f: { text?: string; base64?: string }) =>
f.base64 !== undefined ? Uint8Array.from(atob(f.base64), (c) => c.charCodeAt(0)) : new TextEncoder().encode(f.text ?? "");
window.__previewFiles = (files) => hub.feeds.forEach((port) => {
port.workspacePreviewInfo = async (ref) => ({
ref, kind: files[ref.path].kind, size: new TextEncoder().encode(files[ref.path].text).length,
ref, kind: files[ref.path].kind, size: fixtureBytes(files[ref.path]).length,
version: "fixture", limit: 32 << 20, readOnly: false,
});
port.workspacePreviewContent = async (ref) => ({
data: new TextEncoder().encode(files[ref.path].text).buffer, version: "fixture",
data: fixtureBytes(files[ref.path]).buffer, version: "fixture",
});
});
window.__subagents = (runs) => hub.feeds.forEach((port) => {
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { chromium } from "playwright";
// OFFICE_SAMPLES names a folder holding report.docx and sales.xlsx; OUT where the shots go.
const dir = process.env.OFFICE_SAMPLES;
const out = process.env.OUT ?? "/tmp";
assert.ok(dir, "set OFFICE_SAMPLES");
const files = {
"report.docx": { kind: "office", base64: readFileSync(join(dir, "report.docx")).toString("base64") },
"sales.xlsx": { kind: "office", base64: readFileSync(join(dir, "sales.xlsx")).toString("base64") },
};
const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
try {
for (const [scheme, width] of [["light", 1440], ["dark", 1440], ["light", 390]]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme });
const errors = [];
const requests = [];
page.on("pageerror", (err) => errors.push(err.message));
page.on("request", (req) => requests.push(req.url()));
await page.goto(process.env.PERF_URL ?? "http://127.0.0.1:4399/perf.html?pref=zh", { waitUntil: "networkidle" });
await page.waitForFunction(() => window.__panes?.() > 0);
await page.evaluate((files) => {
window.__previewFiles(files);
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "整理季度报告" });
window.__feed({ kind: "tool_dispatch", tool: { id: "present", name: "present_files", readOnly: true } });
window.__feed({ kind: "tool_result", tool: { id: "present", name: "present_files", readOnly: true,
output: "Presented files", deliveries: Object.keys(files).map((path) => ({ path, description: "季度材料" })) } });
window.__feed({ kind: "text", text: "报告和表格已经生成。" });
window.__feed({ kind: "turn_done" });
}, files);
for (const name of width < 600 ? ["sales.xlsx"] : ["report.docx", "sales.xlsx"]) {
await page.locator(".delivery-open").filter({ hasText: name }).click();
const frame = page.frameLocator('iframe[title="Office 文档预览"]');
await frame.locator(name.endsWith("docx") ? "section.docx" : "table").first().waitFor({ timeout: 15000 });
assert.equal(await page.locator('iframe[title="Office 文档预览"]').getAttribute("sandbox"), "");
await page.waitForTimeout(300);
await page.screenshot({ path: join(out, `office-${name.split(".")[1]}-${scheme}-${width}.png`) });
}
assert.deepEqual(errors, []);
assert.deepEqual(requests.filter((u) => !u.startsWith("http://127.0.0.1") && !u.startsWith("http://localhost") && !u.startsWith("data:") && !u.startsWith("blob:")), []);
await page.close();
}
} finally {
await browser.close();
}
+40
View File
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";
// A long run (a wait on a build) keeps the running indicators moving for minutes.
// Counts the style recalculations they cost over five seconds, by default and with
// the power-saving effects mode or the OS reduced-motion preference.
const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
const measure = async (motion, reduced) => {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, reducedMotion: motion });
const cdp = await page.context().newCDPSession(page);
await cdp.send("Performance.enable");
await page.goto(process.env.PERF_URL ?? "http://127.0.0.1:4399/perf.html?pref=zh", { waitUntil: "networkidle" });
await page.waitForFunction(() => window.__panes?.() > 0);
await page.evaluate((reduced) => {
if (reduced) document.documentElement.dataset.effects = "reduced";
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "wait for the build" });
window.__feed({ kind: "tool_dispatch", tool: { id: "w1", name: "wait", readOnly: true, args: "{}" } });
}, reduced);
await page.waitForTimeout(800);
const read = async () => Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map((m) => [m.name, m.value]));
const a = await read();
await page.waitForTimeout(5000);
const b = await read();
const running = await page.evaluate(() => document.getAnimations().filter((x) => x.playState === "running" && x.effect?.getTiming?.().iterations === Infinity).length);
await page.close();
return { style: b.RecalcStyleCount - a.RecalcStyleCount, loops: running };
};
try {
const normal = await measure("no-preference", false);
const saving = await measure("no-preference", true);
const motion = await measure("reduce", false);
console.log(JSON.stringify({ normal, saving, motion }));
assert.ok(normal.loops > 0, "the running state shows its indicators by default");
for (const [name, r] of Object.entries({ saving, motion })) {
assert.equal(r.loops, 0, `${name}: a looping animation still runs`);
assert.ok(r.style < normal.style / 5, `${name}: ${r.style} style recalcs against ${normal.style} by default`);
}
} finally {
await browser.close();
}
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const shots = process.env.TOOL_OUTPUT_SHOTS;
if (shots) await mkdir(shots, { recursive: true });
const browser = await chromium.launch();
const failures = [];
const errors = [];
const check = (name, ok) => {
console.log(`${ok ? "ok" : "FAIL"} ${name}`);
if (!ok) failures.push(name);
};
// The page's grain adds small channel differences between neighbouring pixels.
const sameGround = (a, b) => a.every((channel, i) => Math.abs(channel - b[i]) <= 2);
const search = Array.from({ length: 8 }, (_, i) =>
`- **Search result ${i + 1}: release notes and model information**\n <https://example.com/news/${"long-source-path-".repeat(12)}${i + 1}>`,
).join("\n");
const terminal = Array.from({ length: 20 }, (_, i) => `line ${i + 1}: ${"output ".repeat(40)}`).join("\n");
const calls = [
{ id: "clip-search", name: "web_search", args: '{"query":"release notes"}', output: search },
{ id: "clip-terminal", name: "bash", args: '{"command":"print results"}', output: terminal },
{ id: "clip-short", name: "bash", args: '{"command":"echo ok"}', output: "ok" },
];
try {
for (const scheme of ["light", "dark"]) for (const width of [1440, 390]) {
const context = await browser.newContext({ viewport: { width, height: 1000 }, colorScheme: scheme, reducedMotion: "reduce", permissions: ["clipboard-read", "clipboard-write"] });
await context.addInitScript((theme) => localStorage.setItem("rx-theme", theme), scheme);
const page = await context.newPage();
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(`${base}?pref=en&turns=0`, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
await page.evaluate((calls) => {
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "Inspect search and terminal results" });
for (const call of calls) {
const { output, ...tool } = call;
window.__feed({ kind: "tool_dispatch", tool });
window.__feed({ kind: "tool_result", tool: { ...tool, output, durationMs: 499 } });
}
window.__feed({ kind: "turn_done" });
}, calls);
const group = page.locator("details.activity-group");
await group.locator(":scope > summary").click();
const card = (id) => page.locator(`[data-call="${id}"]`);
for (const call of calls) await card(call.id).locator("summary.hl").click();
await page.evaluate(() => document.fonts.ready);
const searchCard = card("clip-search");
await searchCard.locator(".out-more").waitFor();
await searchCard.scrollIntoViewIfNeeded();
if (shots) await page.screenshot({ path: `${shots}/${scheme}-${width}.png`, animations: "disabled" });
const rect = await searchCard.locator(".out-clip").boundingBox();
const pixels = await page.evaluate(async ({ png, rect }) => {
const image = await createImageBitmap(new Blob([Uint8Array.from(atob(png), (c) => c.charCodeAt(0))], { type: "image/png" }));
const canvas = new OffscreenCanvas(image.width, image.height);
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0);
const pixel = (x, y) => [...ctx.getImageData(Math.floor(x), Math.floor(y), 1, 1).data];
return [
pixel(rect.x + rect.width - 3, rect.y + rect.height - 3),
pixel(rect.x + rect.width + 3, rect.y + rect.height - 3),
pixel(rect.x + rect.width - 3, rect.y + 2),
pixel(rect.x + rect.width + 3, rect.y + 2),
];
}, { png: (await page.screenshot({ animations: "disabled" })).toString("base64"), rect });
check(`${scheme} ${width}: no painted shadow beside Show all`, sameGround(pixels[0], pixels[1]));
check(`${scheme} ${width}: no right-edge shadow on the result ground`, sameGround(pixels[2], pixels[3]));
for (const call of calls.slice(0, 2)) {
const current = card(call.id);
const body = current.locator(".out-body");
const dimensions = () => body.evaluate((el) => ({ height: el.clientHeight, full: el.scrollHeight, width: el.clientWidth, fullWidth: el.scrollWidth, left: el.scrollLeft }));
const clipped = await dimensions();
assert.ok(clipped.full > clipped.height, `${call.id} starts clipped`);
await current.getByRole("button", { name: "Show all", exact: true }).click();
await page.waitForFunction((id) => {
const body = document.querySelector(`[data-call="${id}"] .out-body`);
return body.clientHeight >= body.scrollHeight - 1;
}, call.id);
if (call.name === "web_search") assert.equal(await body.locator(".hit-row").count(), 8);
if (call.name === "bash") {
assert.equal(await body.textContent(), terminal, "expansion preserves every terminal line");
assert.ok(clipped.fullWidth > clipped.width, "long lines overflow horizontally");
await body.evaluate((el) => { el.scrollLeft = el.scrollWidth; });
assert.ok((await dimensions()).left > 0, "long lines remain horizontally scrollable");
await body.evaluate((el) => { el.scrollLeft = 0; });
}
await current.locator("summary.hl").click();
await current.locator("summary.hl").click();
assert.equal(await current.locator(".out-clip").getAttribute("data-open"), "", "Show all survives closing and reopening the card");
await current.getByRole("button", { name: "Collapse", exact: true }).click();
await current.getByRole("button", { name: "Show all", exact: true }).waitFor();
await page.waitForFunction((id) => {
const body = document.querySelector(`[data-call="${id}"] .out-body`);
return body.scrollHeight > body.clientHeight + 1;
}, call.id);
console.log(`ok ${scheme} ${width}: ${call.name} expands, retains content and disclosure state, and collapses`);
}
await searchCard.getByRole("button", { name: "Copy", exact: true }).click();
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), search, "copy includes every result, not just the visible preview");
assert.equal(await card("clip-short").locator(".out-more").count(), 0, "short output does not acquire a Show all control");
assert.equal(await card("clip-short").locator(".term").textContent(), "ok");
console.log(`ok ${scheme} ${width}: full-result copy and short output`);
await context.close();
}
assert.deepEqual(errors, [], "no browser errors");
assert.deepEqual(failures, [], "clipping must not paint an unrelated surface over results or controls");
} finally {
await browser.close();
}
+1679 -91
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -110,6 +110,7 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.new-answer-marker", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.plan-progress", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.composer-hint", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.office-preview", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
{ id: "window.global-shortcut", kind: "shell-native", target: "none", proof: "browser" },
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
@@ -467,6 +468,7 @@ export const ACTIONS: UIAction[] = [
{ id: "provider.select", kind: "view", target: "entity", proof: "interaction" },
{ id: "provider.move", kind: "view", target: "entity", proof: "interaction" },
{ id: "provider.rename-start", kind: "view", target: "entity", proof: "interaction" },
{ id: "provider.health-setting", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "provider.rename", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "provider.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "roles.model", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -3,6 +3,7 @@ import type { UIAction } from "./actions";
export const DOCUMENT_ACTIONS: UIAction[] = [
{ id: "document.zoom", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.page", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.sheet", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.scroll", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.refresh", kind: "repeatable", target: "entity", proof: "interaction" },
{ id: "delivery.preview", kind: "navigation", target: "entity", proof: "interaction" },
+33 -2
View File
@@ -27,6 +27,33 @@ import { EN_COMMUNITY } from "./en_community";
import { EN_SPONSOR } from "./en_sponsor";
export const EN: Record<string, string> = {
"检测中": "Checking",
"鉴权失败": "Auth failed",
"缺少密钥": "Missing key",
"需要付费": "Payment required",
"配额耗尽": "Quota exhausted",
"暂时限流": "Rate limited",
"连接超时": "Timed out",
"连接异常": "Connection failed",
"跳转被拒": "Redirect refused",
"余额查询失败": "Balance check failed",
"连接正常": "Connected",
"连接待确认": "Unconfirmed",
"正在检测连接与余额…": "Checking connection and balance…",
"连接正常,模型调用未验证": "Connection reachable; model calls unverified",
"连接鉴权失败,请检查密钥或权限": "Authentication failed; check key or permissions",
"未配置 API 密钥": "API key not configured",
"服务商要求付费,请检查账户": "Payment required; check your account",
"可用配额已耗尽": "Available quota exhausted",
"请求暂时被限流,请稍后重试": "Temporarily rate limited; retry later",
"连接检测超时,请稍后重试": "Connection check timed out; retry later",
"连接异常,请检查网络或服务商": "Connection failed; check network or provider",
"连接跳转被拒绝,请检查地址": "Redirect refused; check the address",
"余额不足,请充值后重试": "Insufficient balance; top up and retry",
"余额查询鉴权失败": "Balance query authentication failed",
"余额未知(未配置查询接口)": "Balance unknown (no query endpoint configured)",
"余额暂时无法确认": "Balance could not be confirmed",
"连接状态尚未确认": "Connection status unconfirmed",
"部分历史摘要暂不可用,可继续打开会话": "Some history summaries are unavailable. You can still open conversations.",
"仍在搜索部分历史会话…": "Still searching some historical conversations…",
"正在读取历史摘要…": "Reading history summaries…",
@@ -764,7 +791,7 @@ export const EN: Record<string, string> = {
"等宽字体": "Monospace font",
"文字粗细": "Text weight",
"视觉效果": "Visual effects",
"窗口持续占用显卡时,改为「节能」:界面不变,只去掉磨砂玻璃、动态天空和颗粒这几层。": "If the window keeps your GPU busy, choose Reduced: the layout stays as it is, only frosted glass, the animated sky and the grain layer go.",
"窗口持续占用显卡时,改为「节能」:界面不变,只去掉磨砂玻璃、动态天空、颗粒和运行中的循环动画。": "If the window keeps your GPU busy, choose Reduced: the layout stays as it is, only frosted glass, the animated sky, the grain layer and the looping run animations go.",
"节能": "Reduced",
"背景模糊、动态天空与颗粒质感全部开启": "Background blur, the animated sky and the grain layer are all on",
"关闭背景模糊、动态天空与颗粒质感,集成显卡或老旧电脑上更省电": "Turns off background blur, the animated sky and the grain layer; lighter on integrated graphics and older machines",
@@ -1560,7 +1587,7 @@ export const EN: Record<string, string> = {
"模型将依据摘要继续工作;完整对话记录会保留。": "The model will continue from a summary. The full conversation will be preserved.",
"查看本轮费用": "View turn cost",
"查看钱包余额": "View wallet balance",
"余额不足": "Balance is low",
"余额不足": "Insufficient balance",
"隐藏金额": "Hide amounts",
"显示金额": "Show amounts",
"收起运行统计": "Collapse run statistics",
@@ -1599,6 +1626,10 @@ export const EN: Record<string, string> = {
"后续任务会继续使用这个分支": "Later tasks will continue on this branch",
"由模型自动选择": "Let the model choose",
"管理模型与连接": "Manage models and connections",
"自动检测连接与余额": "Automatically check connections and balances",
"当前服务器不支持自动检测,请升级服务器后重试。": "This server does not support automatic checks. Upgrade the server to use them.",
"无法读取检测设置,请检查连接或权限后重试。": "Cannot read check settings. Check your connection or permissions and try again.",
"默认关闭。开启后,打开模型列表会使用已保存密钥检查已授权的全局连接和余额,不发送生成请求。": "Off by default. When enabled, opening the model picker uses saved keys to check authorized global connections and balances, without generation requests.",
"服务商、API 与可用模型": "Providers, API access, and available models",
"更多操作": "More actions",
"执行权限": "Execution permissions",
@@ -39,4 +39,11 @@ export const EN_DOCUMENT: Record<string, string> = {
"文件不是有效的 UTF-8 文本": "The file is not valid UTF-8 text.",
"读取期间文件发生变化,请刷新": "The file changed while being read. Refresh to try again.",
"文件预览读取失败,请重试": "Could not read the file preview. Try again.",
"只读预览,宏、脚本和外部资源未加载": "Read-only preview. Macros, scripts and external resources are not loaded.",
"无法解析这个文件,可以用系统应用打开": "This file could not be read. Open it in the system app instead.",
"工作表": "Sheets",
"只显示前 {rows} 行、{cols} 列": "Showing the first {rows} rows and {cols} columns only.",
"这个工作簿没有可见的工作表": "This workbook has no visible sheets.",
"Office 文档预览": "Office document preview",
"Office 文档预览已在设置中关闭": "Office document preview is turned off in Settings.",
};
@@ -1159,6 +1159,7 @@ export const EN_SETTINGS: Record<string, string> = {
"收起时显示计划进度": "Show collapsed plan progress",
"在收起的计划卡片下方显示进度条,随已完成步骤更新。默认关闭,仅保存在本机。": "Show a progress bar below the collapsed plan card, updated as steps complete. Off by default; saved only on this machine.",
"输入框提示": "Composer hint",
"在工作台里只读预览 Word(.docx)和 Excel(.xlsx)。首次打开这类文件时才加载,在隔离的框中显示,不运行宏和脚本。关闭后只能用系统应用打开。仅保存在本机。": "Read-only preview of Word (.docx) and Excel (.xlsx) files in the workbench. It loads the first time such a file is opened and renders in an isolated frame without running macros or scripts. When off, these files open only in the system app. Saved on this device only.",
"在空白任务输入框中显示任务和排队提示。默认显示,仅保存在本机,不影响输入和发送。": "Show task and queue guidance in the empty composer. On by default; saved only on this machine. Does not affect typing or sending.",
"计划进度": "Plan progress",
"折叠预设": "Folding preset",
@@ -15,11 +15,18 @@ import type {
ProviderProbe,
} from "./port";
import { MockBoundary } from "./mock_boundary";
import type { ProviderHealth } from "./provider";
// The sources half of the fixture. Chained on for the reason the others are:
// MockPort satisfies AgentPort in one declaration, and each face keeps its own
// file.
export class MockProvider extends MockBoundary {
private healthEnabled = false;
async providerHealthSettings() { return { enabled: this.healthEnabled }; }
async saveProviderHealthSettings(enabled: boolean) { this.healthEnabled = enabled; return { enabled }; }
async providerHealth(_name: string): Promise<ProviderHealth> {
return { connection: "unknown", wallet: "unsupported", checkedAt: Date.now() };
}
// Whether this machine has been given a provider. The fixture opens without
// one so the connect card can be designed, and every port the same window
// builds reads the same answer: a key belongs to the machine, not to the
+5 -5
View File
@@ -33,9 +33,6 @@ import type { StudioCrash, StudioLogs, UpdateMode, UpdateProgress, VersionEntry,
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo, WorkspacePreviewPort } from "./workspace";
import type { SessionTailPort } from "./session_tail";
// The port is one contract; its subjects each keep their own file, the way the
// wire and the layers below already do. This is where a reader still finds
// them all.
export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, ApprovalVerdict, WorkspaceTrust, CapabilityScope,
Checkpoint, CompactionSettings, Completion, CompletionItem, ContextBreakdown, DeviceGrant, HistoryMessage, HostTodo, BrowserTab,
HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource, JobEntry, JobOutput, McpCatalog, McpDraft,
@@ -51,8 +48,8 @@ import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketP
import type { Appearance, SceneFolder, ThemeImport, ThemePack } from "./look";
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FeedbackLevelNotices, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, ToolSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
import type { ScheduleBoard, ScheduleDraft, SchedulePreview, ScheduleResultView, ScheduleView } from "./schedule";
import type { Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
export type { ModelEffort, ModelLimit, ModelRate, Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
import type { Protocol, ProviderHealth, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
export type { ModelEffort, ModelLimit, ModelRate, Protocol, ProviderHealth, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
import type { FeedbackBoard, FeedbackBoardMe, FeedbackEnv, FeedbackImage, FeedbackLinkedInstall, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
import type { PersonaDraft, PersonaSettings, PresentFilesSettings } from "./boundary";
@@ -434,6 +431,9 @@ export interface AgentPort extends WorkspacePreviewPort, SessionTailPort, Upload
// Re-probes what is already saved, so "is the key still good, and is this
// still the protocol we recorded" is one button rather than a re-add.
checkProvider(name: string, draft?: ProviderCheckDraft): Promise<ProviderCheck>;
providerHealth(name: string): Promise<ProviderHealth>;
providerHealthSettings(): Promise<import("./provider").ProviderHealthSettings>;
saveProviderHealthSettings(enabled: boolean): Promise<import("./provider").ProviderHealthSettings>;
checkProviderModel(request: ProviderModelCheckRequest): Promise<ProviderModelCheck>;
saveProvider(draft: ProviderDraft): Promise<void>;
// Changes only the fields the form owns. Saving a whole entry instead would
@@ -1,6 +1,16 @@
// provider.ts — a model source as the connection panel edits it: what was
// probed, what was confirmed, and the few fields no probe can answer.
export interface ProviderHealthSettings {
enabled: boolean;
}
export interface ProviderHealth {
connection: string;
wallet: string;
checkedAt: number;
}
export interface ProviderEntry {
name: string;
// The label a person gave this source. Only ever shown: every ref, the
@@ -1,9 +1,19 @@
import { SseBoundary } from "./sse_boundary";
import type { ProviderHealth, ProviderHealthSettings } from "./provider";
import type { Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./port";
// Where models come from: the accounts, the protocols their endpoints answer,
// and what probing one found.
export class SseProvider extends SseBoundary {
providerHealthSettings(): Promise<ProviderHealthSettings> {
return this.get<ProviderHealthSettings>("/providers/health/settings");
}
saveProviderHealthSettings(enabled: boolean): Promise<ProviderHealthSettings> {
return this.post0<ProviderHealthSettings>("/providers/health/settings", { enabled });
}
providerHealth(name: string): Promise<ProviderHealth> {
return this.post0<ProviderHealth>("/providers/health", { name });
}
providers() {
return this.get<ProviderEntry[]>("/providers");
}
+1 -1
View File
@@ -106,7 +106,7 @@ export interface FileRef {
export interface PreviewInfo {
ref: FileRef;
kind: "markdown" | "text" | "image" | "pdf" | "table" | "html" | "unsupported";
kind: "markdown" | "text" | "image" | "pdf" | "table" | "html" | "office" | "unsupported";
size: number;
version: string;
limit: number;
+27
View File
@@ -187,6 +187,33 @@ export function setShowsComposerHint(on: boolean): void {
composerHintListeners.forEach((fn) => fn());
}
const OFFICE_PREVIEW_KEY = "rx-office-preview";
const officePreviewListeners = new Set<() => void>();
export function previewsOffice(): boolean {
try {
return localStorage.getItem(OFFICE_PREVIEW_KEY) !== "off";
} catch {
return true;
}
}
export function onOfficePreviewChange(fn: () => void): () => void {
officePreviewListeners.add(fn);
return () => {
officePreviewListeners.delete(fn);
};
}
export function setPreviewsOffice(on: boolean): void {
try {
localStorage.setItem(OFFICE_PREVIEW_KEY, on ? "on" : "off");
} catch {
/* unavailable storage keeps the default */
}
officePreviewListeners.forEach((fn) => fn());
}
const PLAN_PROGRESS_KEY = "rx-plan-progress";
const planProgressListeners = new Set<() => void>();
@@ -116,6 +116,10 @@ grid to break.
listing are two things. One mix served both and stepped down from `--raised`
while the column actually lays `--surface`: measured, the step came to 2.7 L
in dark, just past the line where a layer is visible.
- Clipped tool output fades its own content with an alpha mask on `.out-body`.
Result lists and terminal blocks have different grounds, so no coloured
overlay may cover either one or the Show all control. Long lines retain
native horizontal scrolling without an edge overlay.
## Hues
+3 -19
View File
@@ -1221,10 +1221,7 @@
.peek button.row[data-bad] .n { color: var(--err-ink) }
.peek button.row:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: var(--r-xs) }
.out-clip { position: relative }
.call:not([data-running]):not(:has(> .c > .hl .fail, > .c > .tool-disclosure > .hl .fail)) .out-clip { --out-peek: 5.5rem }
.call:not([data-running]):not(:has(> .c > .hl .fail, > .c > .tool-disclosure > .hl .fail)) .out-clip[data-over]:not([data-open])::after { height: 2rem }
.call:not([data-running]):not(:has(> .c > .hl .fail, > .c > .tool-disclosure > .hl .fail)) .out-clip { --out-peek: 5.5rem; --out-fade: 2rem }
.out-body {
max-height: var(--out-peek, 16rem); overflow: auto;
transition: max-height var(--t-panel) var(--ease-out);
@@ -1233,24 +1230,11 @@
.out-clip > .out-body > .term { width: max-content; min-width: 100%; overflow: visible; box-sizing: border-box }
.out-clip[data-open] .out-body { max-height: max-content }
.out-clip[data-over]::after {
content: ""; position: absolute; inset-inline: 0; bottom: 0; height: 3rem; pointer-events: none;
background: linear-gradient(to bottom, transparent, var(--face-sunk));
transition: opacity var(--t-card) var(--ease-out);
.out-clip[data-over]:not([data-open]) > .out-body {
mask-image: linear-gradient(#000 calc(100% - var(--out-fade, 3rem)), transparent);
}
.out-clip[data-open][data-over]::after { opacity: 0 }
.out-clip { timeline-scope: --out-x }
.out-clip > .out-body { scroll-timeline: --out-x inline }
.out-clip::before {
content: ""; position: absolute; inset-block: 0; right: 0; width: 2.25rem; z-index: 1;
pointer-events: none; opacity: 0;
background: linear-gradient(to right, transparent, var(--face-sunk));
animation: out-x-more linear both; animation-timeline: --out-x;
}
@keyframes out-x-more { 0%, 88% { opacity: 1 } 100% { opacity: 0 } }
.out-more {
position: relative; z-index: 1;
margin-top: 5px; padding: 2px 7px; border: 0; border-radius: var(--r-xs); background: var(--overlay);
font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint); cursor: pointer;
}
@@ -10,3 +10,7 @@
.mchip[aria-pressed="true"] { color: var(--text); background: var(--overlay); border-color: var(--text); }
.mchip.clear { border-style: dashed; }
.mchip:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.studio-model-menu .menu-item-status { display: inline-flex; align-items: center; gap: 4px; flex: none; padding: 2px 5px; border: 1px solid transparent; border-radius: 5px; color: var(--muted); font: var(--w-med) calc(10.5px * var(--fs-scale, 1))/1.3 var(--ui); white-space: nowrap; }
.studio-model-menu .menu-item-status[data-tone="warning"] { color: var(--warn-ink); background: color-mix(in srgb, var(--warn) 8%, transparent); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
.studio-model-menu .menu-item-status[data-tone="positive"] svg { color: var(--ok-ink, var(--ok)); }
.studio-model-menu .menu-item-status svg { width: 12px; height: 12px; flex: none; }
@@ -4218,3 +4218,8 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
.other-field .studio-control-tip { left: auto; right: 0; }
.other-wrap .refine-card { margin: 8px 0 0; }
.keys-row .ds kbd { display: inline-block; min-width: 13em }
:root[data-effects="reduced"] .studio-runstate .rmark path { stroke-dasharray: 100; stroke-dashoffset: 0; animation: none; }
:root[data-effects="reduced"] .rail .sessrow[data-run]::after,
:root[data-effects="reduced"] .rail .sessrow[data-run="halt"],
:root[data-effects="reduced"] .tool-state[data-state="running"] .studio-icon,
:root[data-effects="reduced"] .ws-reading .studio-icon { animation: none; }
+3 -1
View File
@@ -20,6 +20,7 @@ import { LiveStreamBudgetSection } from "./LiveStreamBudgetSection";
import { NewAnswerSection } from "./NewAnswerSection";
import { PlanProgressSection } from "./PlanProgressSection";
import { ComposerHintSection } from "./ComposerHintSection";
import { OfficePreviewSection } from "./OfficePreviewSection";
import { SessionQuickSection } from "./SessionQuickSection";
import { NavEntriesSection } from "./NavEntriesSection";
import { NavRailSection } from "./NavRailSection";
@@ -576,6 +577,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
<GraphicsSection />
<SendShortcut />
<ComposerHintSection />
<OfficePreviewSection />
<RunningMessageSetting />
<EscStopSetting />
<GlobalShortcutSection />
@@ -634,7 +636,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
<div className="grp-hd">
<h3>{t("视觉效果")}</h3>
</div>
<p className="hint">{t("窗口持续占用显卡时,改为「节能」:界面不变,只去掉磨砂玻璃、动态天空和颗粒这几层。")}</p>
<p className="hint">{t("窗口持续占用显卡时,改为「节能」:界面不变,只去掉磨砂玻璃、动态天空、颗粒和运行中的循环动画。")}</p>
<div className="grp-items">
<div className="seg" data-text role="group" aria-label={t("视觉效果")}>
{EFFECTS.map(([id, name, why]) => (
@@ -0,0 +1,103 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import "./testkit";
import { Composer } from "./Composer";
import { MockPort } from "../port/mock";
import type { SessionStatus } from "../port/port";
import { t } from "../i18n";
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
it("keeps account headings, visible hosts and row metadata with checks off and on", async () => {
const port = new MockPort();
vi.spyOn(port, "models").mockResolvedValue([
{ ref: "a/current", provider: "a", model: "current", kind: "openai", displayName: "Gateway", vendor: "one.example" },
{ ref: "a/second", provider: "a", model: "second", kind: "openai", displayName: "Gateway", vendor: "one.example" },
{ ref: "b/third", provider: "b", model: "third", kind: "openai", displayName: "Gateway", vendor: "two.example" },
]);
const probe = vi.spyOn(port, "providerHealth").mockResolvedValue({ connection: "reachable", wallet: "available", checkedAt: 1 });
const props = { port, status: { modelRef: "a/current" } as SessionStatus, running: false, onSubmit: async () => true as const, onChanged: vi.fn(), onError: vi.fn() };
const view = render(<Composer {...props} pulse={0} />);
fireEvent.click(await screen.findByRole("button", { name: "current" }));
const menu = screen.getByRole("menu");
expect(menu.classList.contains("compact-model-menu")).toBe(false);
expect(within(menu).getByText("one.example")).toBeTruthy();
expect(within(menu).getByText("two.example")).toBeTruthy();
expect(within(menu).getAllByRole("menuitem")).toHaveLength(4);
expect(probe).not.toHaveBeenCalled();
fireEvent.keyDown(menu, { key: "Escape" });
await port.saveProviderHealthSettings(true);
view.rerender(<Composer {...props} pulse={1} />);
fireEvent.click(screen.getByRole("button", { name: "current" }));
await within(screen.getByRole("menu")).findAllByText(t("连接正常"));
expect(screen.getByRole("menu").querySelectorAll(".mi.head .rt")).toHaveLength(2);
expect(screen.getByRole("menuitem", { name: /second/ }).textContent).toContain(t("OpenAI 兼容"));
expect(screen.getByRole("menuitem", { name: /second/ }).textContent).not.toContain("Gateway");
});
it("checks on opening, shows wallet failures and keeps selection and management usable", async () => {
const port = new MockPort();
await port.saveProviderHealthSettings(true);
vi.spyOn(port, "models").mockResolvedValue([
{ ref: "one/alpha", provider: "one", model: "alpha", kind: "openai" },
{ ref: "one/beta", provider: "one", model: "beta", kind: "openai" },
{ ref: "two/gamma", provider: "two", model: "gamma", kind: "openai" },
]);
const check = vi.spyOn(port, "providerHealth").mockImplementation(async (name) => ({ connection: "reachable", wallet: name === "one" ? "insufficient" : "unsupported", checkedAt: Date.now() }));
const trial = vi.spyOn(port, "checkProviderModel");
const pick = vi.spyOn(port, "setModel").mockResolvedValue();
const settings = vi.fn();
render(<Composer port={port} status={{ modelRef: "one/alpha" } as SessionStatus} running={false} onSubmit={async () => true} onChanged={vi.fn()} onError={vi.fn()} onSettings={settings} />);
const trigger = await screen.findByRole("button", { name: "alpha" });
expect(check).not.toHaveBeenCalled();
fireEvent.click(trigger);
await screen.findAllByText(/Insufficient balance|余额不足/);
expect(check.mock.calls.map(([name]) => name).sort()).toEqual(["one", "two"]);
expect(trial).not.toHaveBeenCalled();
expect(screen.getByRole("menuitem", { name: /alpha/ }).hasAttribute("data-on")).toBe(true);
fireEvent.click(screen.getByRole("menuitem", { name: /gamma/ }));
await waitFor(() => expect(pick).toHaveBeenCalledWith("two/gamma"));
fireEvent.click(trigger);
fireEvent.click(screen.getByRole("menuitem", { name: /Manage models and connections|管理模型与连接/ }));
expect(settings).toHaveBeenCalledWith("providers");
});
it("ignores a late health result after switching runtime", async () => {
const oldPort = new MockPort(), port = new MockPort();
await oldPort.saveProviderHealthSettings(true);
await port.saveProviderHealthSettings(true);
const models = [{ ref: "one/alpha", provider: "one", model: "alpha" }];
vi.spyOn(oldPort, "models").mockResolvedValue(models);
vi.spyOn(port, "models").mockResolvedValue(models);
let finish!: (value: { connection: string; wallet: string; checkedAt: number }) => void;
vi.spyOn(oldPort, "providerHealth").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
vi.spyOn(port, "providerHealth").mockResolvedValue({ connection: "reachable", wallet: "available", checkedAt: Date.now() });
const props = { status: { modelRef: "one/alpha" } as SessionStatus, running: false, onSubmit: async () => true as const, onChanged: vi.fn(), onError: vi.fn() };
const view = render(<Composer {...props} port={oldPort} />);
fireEvent.click(await screen.findByRole("button", { name: "alpha" }));
await waitFor(() => expect(finish).toBeDefined());
view.rerender(<Composer {...props} port={port} />);
await act(async () => { finish({ connection: "auth", wallet: "insufficient", checkedAt: Date.now() }); });
expect(screen.queryByText(/Insufficient balance|余额不足/)).toBeNull();
});
it("limits background checks, tolerates older servers and excludes extension models", async () => {
const port = new MockPort();
await port.saveProviderHealthSettings(true);
vi.spyOn(port, "models").mockResolvedValue([
...["one", "two", "three", "four"].map((name) => ({ ref: `${name}/alpha`, provider: name, model: "alpha" })),
{ ref: "plugin/test/provider/alpha", provider: "plugin/test/provider", model: "extension", kind: "extension" },
]);
const pending: Array<() => void> = [];
const check = vi.spyOn(port, "providerHealth").mockImplementation(() => new Promise((_, reject) => { pending.push(() => reject(new Error("old server"))); }));
render(<Composer port={port} status={{ modelRef: "one/alpha" } as SessionStatus} running={false} onSubmit={async () => true} onChanged={vi.fn()} onError={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: "alpha" }));
await waitFor(() => expect(check).toHaveBeenCalledTimes(3));
await act(async () => { pending[0](); });
await waitFor(() => expect(check).toHaveBeenCalledTimes(4));
await act(async () => { pending.slice(1).forEach((finish) => finish()); });
expect(check.mock.calls.flat()).not.toContain("plugin/test/provider");
expect(screen.getByRole("menuitem", { name: /extension/ })).toBeTruthy();
expect(screen.getAllByText(/Unconfirmed|连接待确认/)).toHaveLength(4);
});
+6 -9
View File
@@ -3,7 +3,7 @@ import { createPortal } from "react-dom";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import { keepPasteAsFile, PasteKeep, type PasteChip } from "./pastekeep";
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit, WorkspaceInfo } from "../port/port";
import type { AgentPort, ChipCall, SessionStatus, WorkspaceGit, WorkspaceInfo } from "../port/port";
import { Picker } from "./Menu";
import { BranchChip } from "./BranchChip";
import { WorktreeChip } from "./WorktreeChip";
@@ -12,6 +12,7 @@ import { WorktreeSetupPage } from "./WorktreeSetupPage";
import { Policy } from "./Policy";
import { chosenGroups, groupHeadings, modelLabel, modelMenu, modelTooltip, narrowToGroups } from "./modelmenu";
import { ModelGroupChips } from "./ModelGroupChips";
import { useModelHealth } from "./modelhealth";
import { effortMenu, effortReading, effortsFor, forcesThinkingFor, routeEffortPick } from "./effort";
import { CompletionMenu, useCompletion } from "./Completion";
import { ChipMirror, useSkillChips } from "./ChipMirror";
@@ -102,8 +103,9 @@ export function Composer({ port, status, running, queued = false, quote, restore
const submittingRef = useRef(false);
const stoppingRef = useRef(false);
const picker = useRef<HTMLInputElement>(null);
const [models, setModels] = useState<ModelEntry[]>([]);
const menuItems = modelMenu(models, providerOrder, status?.modelRef);
const modelHealth = useModelHealth(port, pulse);
const { models, loadModels } = modelHealth;
const menuItems = modelMenu(models, providerOrder, status?.modelRef, modelHealth.readings);
const chosenModelGroups = chosenGroups(menuItems, modelGroups);
const [busy, setBusy] = useState<Record<string, boolean>>({});
const box = useRef<HTMLTextAreaElement>(null);
@@ -124,11 +126,6 @@ export function Composer({ port, status, running, queued = false, quote, restore
// of a programmatic value, which is wrong for anything accepted mid-line.
const pending = useRef<number | [number, number] | null>(null);
const loadModels = useCallback(() => {
port.models().then(setModels).catch(() => setModels([]));
}, [port]);
useEffect(loadModels, [loadModels, pulse]);
// A drop lands where the caret is, and the handler that receives it was built
// once — so the position it reads has to be a ref, not the render's copy.
const caretRef = useRef(0);
@@ -719,7 +716,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
searchAlways
searchPlaceholder={t("搜索模型或服务商…")}
menuTitle={<><b>{t("选择模型")}</b><small>{t("用于后续任务")}</small></>}
onOpen={loadModels}
onOpen={() => { void modelHealth.check(loadModels()); }}
pending={busy["model"]}
onPick={(ref) => ref === "__manage-models" ? onSettings("providers") : change("model", () => port.setModel(ref))}
label={<><span className="nm">{modelLb}</span><StudioIcon name="down" /></>}
+14
View File
@@ -3,12 +3,20 @@ import { createPortal } from "react-dom";
import { t } from "../i18n";
import { placeInViewport, useFollow, zoom } from "./place";
import { hasVendorMark, VendorMark } from "./vendormarks";
import { CircleAlert, CircleDot, LoaderCircle } from "lucide-react";
export interface MenuStatus {
label: string;
tone: "warning" | "positive" | "neutral" | "checking";
detail?: string;
}
export interface MenuItem {
value: string;
label: string;
desc?: string;
right?: string;
status?: MenuStatus;
// Dense choice menus sometimes need one short machine-facing label beside
// the human one (for example "深入 High") without pushing that label to
// the far edge of the row.
@@ -259,6 +267,8 @@ export function Picker({
role={it.toggle === undefined ? "menuitem" : "menuitemcheckbox"}
aria-checked={it.toggle}
aria-current={it.value === current ? true : undefined}
aria-description={it.status?.detail}
title={it.status?.detail}
disabled={it.disabled}
data-on={it.value === current ? "" : undefined}
data-lead={it.value === lead ? "" : undefined}
@@ -282,6 +292,10 @@ export function Picker({
)}
{it.toggle !== undefined && <span className="studio-item-switch" aria-hidden="true" />}
{it.right && <span className="rt">{it.right}</span>}
{it.status && <span className="menu-item-status" data-tone={it.status.tone}>
{it.status.tone === "warning" ? <CircleAlert aria-hidden="true" /> : it.status.tone === "checking" ? <LoaderCircle aria-hidden="true" /> : <CircleDot aria-hidden="true" />}
{it.status.label}
</span>}
</button>
)}
</Fragment>
@@ -0,0 +1,16 @@
import { useSyncExternalStore } from "react";
import { t } from "../i18n";
import { onOfficePreviewChange, previewsOffice, setPreviewsOffice } from "../state/prefs";
import { Group } from "./Group";
export function OfficePreviewSection() {
const on = useSyncExternalStore(onOfficePreviewChange, previewsOffice, previewsOffice);
return (
<Group id="office-preview" title={t("Office 文档预览")} hint={t("在工作台里只读预览 Word(.docx)和 Excel(.xlsx)。首次打开这类文件时才加载,在隔离的框中显示,不运行宏和脚本。关闭后只能用系统应用打开。仅保存在本机。")}>
<div className="seg" data-text role="group" aria-label={t("Office 文档预览")}>
<button data-action="appearance.office-preview" data-value="off" aria-pressed={!on} onClick={() => setPreviewsOffice(false)}>{t("关闭")}</button>
<button data-action="appearance.office-preview" data-value="on" aria-pressed={on} onClick={() => setPreviewsOffice(true)}>{t("预览")}</button>
</div>
</Group>
);
}
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { ProviderHealthSetting } from "./ProviderHealthSetting";
import { useModelHealth } from "./modelhealth";
import { MockPort } from "../port/mock";
import { HttpError } from "../port/port";
import { t } from "../i18n";
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
const models = [{ ref: "one/alpha", provider: "one", model: "alpha" }];
it.each([404, 405, 501])("explains an older server refusing health settings with %s", async (status) => {
const port = new MockPort();
vi.spyOn(port, "providerHealthSettings").mockRejectedValue(new HttpError(status, "unsupported"));
const save = vi.spyOn(port, "saveProviderHealthSettings");
render(<ProviderHealthSetting port={port} onChanged={vi.fn()} onFailed={vi.fn()} />);
await screen.findByText(t("当前服务器不支持自动检测,请升级服务器后重试。"));
expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("false");
expect(screen.getByRole("switch").hasAttribute("disabled")).toBe(true);
expect(save).not.toHaveBeenCalled();
});
it("explains a missing save capability and distinguishes a settings read failure", async () => {
const port = new MockPort();
const view = render(<ProviderHealthSetting port={{ providerHealthSettings: () => port.providerHealthSettings() }} onChanged={vi.fn()} onFailed={vi.fn()} />);
await screen.findByText(t("当前服务器不支持自动检测,请升级服务器后重试。"));
vi.spyOn(port, "providerHealthSettings").mockRejectedValue(new HttpError(403, "forbidden"));
view.rerender(<ProviderHealthSetting port={port} onChanged={vi.fn()} onFailed={vi.fn()} />);
await screen.findByText(t("无法读取检测设置,请检查连接或权限后重试。"));
expect(screen.getByRole("switch").hasAttribute("disabled")).toBe(true);
expect(screen.queryByText(t("当前服务器不支持自动检测,请升级服务器后重试。"))).toBeNull();
});
it("requires opt-in, persists both switch directions and stops probes after disabling", async () => {
const port = new MockPort(), changed = vi.fn(), failed = vi.fn();
const probe = vi.spyOn(port, "providerHealth");
const saved = vi.spyOn(port, "saveProviderHealthSettings");
const { result, rerender } = renderHook(({ pulse }) => useModelHealth(port, pulse), { initialProps: { pulse: 0 } });
render(<ProviderHealthSetting port={port} onChanged={changed} onFailed={failed} />);
const toggle = screen.getByRole("switch");
await waitFor(() => expect(toggle.hasAttribute("disabled")).toBe(false));
expect(toggle.getAttribute("aria-checked")).toBe("false");
await act(() => result.current.check(Promise.resolve(models)));
expect(probe).not.toHaveBeenCalled();
fireEvent.click(toggle);
await waitFor(() => expect(changed).toHaveBeenCalledTimes(1));
expect(saved).toHaveBeenLastCalledWith(true);
await act(() => result.current.check(Promise.resolve(models)));
expect(probe).toHaveBeenCalledTimes(1);
fireEvent.click(toggle);
await waitFor(() => expect(changed).toHaveBeenCalledTimes(2));
expect(saved).toHaveBeenLastCalledWith(false);
rerender({ pulse: 1 });
await act(() => result.current.check(Promise.resolve(models)));
expect(probe).toHaveBeenCalledTimes(1);
expect(result.current.readings).toEqual({});
expect(failed).not.toHaveBeenCalled();
});
it("fails closed on unavailable settings and ignores late consent after a runtime switch", async () => {
const old = new MockPort(), port = new MockPort();
let finish!: (value: { enabled: boolean }) => void;
vi.spyOn(old, "providerHealthSettings").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
vi.spyOn(port, "providerHealthSettings").mockRejectedValue(new Error("unsupported"));
const oldProbe = vi.spyOn(old, "providerHealth"), probe = vi.spyOn(port, "providerHealth");
const { result, rerender } = renderHook(({ port }) => useModelHealth(port, 0), { initialProps: { port: old } });
let pending!: Promise<void>;
act(() => { pending = result.current.check(Promise.resolve(models)); });
rerender({ port });
await act(async () => { finish({ enabled: true }); await pending; await result.current.check(Promise.resolve(models)); });
expect(oldProbe).not.toHaveBeenCalled();
expect(probe).not.toHaveBeenCalled();
expect(result.current.readings).toEqual({});
});
@@ -0,0 +1,46 @@
import { useEffect, useRef, useState } from "react";
import { HttpError, type AgentPort } from "../port/port";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import { Switch } from "./Switch";
import { Group } from "./Group";
export function ProviderHealthSetting({ port, onChanged, onFailed }: {
port: Partial<Pick<AgentPort, "providerHealthSettings" | "saveProviderHealthSettings">>;
onChanged: () => void; onFailed: (why: string) => void;
}) {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
const [unavailable, setUnavailable] = useState<"unsupported" | "failed" | null>(null);
const generation = useRef(0);
useEffect(() => {
let live = true;
generation.current++;
setEnabled(null);
setBusy(false);
setUnavailable(null);
if (!port.providerHealthSettings || !port.saveProviderHealthSettings) setUnavailable("unsupported");
else void port.providerHealthSettings().then((value) => { if (live) setEnabled(value.enabled); }).catch((e) => {
if (live) setUnavailable(e instanceof HttpError && [404, 405, 501].includes(e.status) ? "unsupported" : "failed");
});
return () => { live = false; generation.current++; };
}, [port]);
return <Group id="provider-health" title={t("自动检测连接与余额")}
hint={t("默认关闭。开启后,打开模型列表会使用已保存密钥检查已授权的全局连接和余额,不发送生成请求。")}
action={<Switch data-action="provider.health-setting" on={enabled === true}
busy={busy || enabled === null || !port.saveProviderHealthSettings}
label={t("自动检测连接与余额")} onClick={async () => {
if (!port.saveProviderHealthSettings) return;
const version = generation.current;
setBusy(true);
try {
const value = await port.saveProviderHealthSettings(!enabled);
if (version === generation.current) { setEnabled(value.enabled); onChanged(); }
} catch (e) { if (version === generation.current) onFailed(reason(e)); }
finally { if (version === generation.current) setBusy(false); }
}} />}>
{unavailable && <p className="note" role="status">{t(unavailable === "unsupported"
? "当前服务器不支持自动检测,请升级服务器后重试。"
: "无法读取检测设置,请检查连接或权限后重试。")}</p>}
</Group>;
}
@@ -33,6 +33,7 @@ import { Packages } from "./Packages";
import { ExtTabs, MarketGroup } from "./Market";
import { InstalledLocation } from "./InstalledLocation";
import { Switch } from "./Switch";
import { ProviderHealthSetting } from "./ProviderHealthSetting";
import { ServerRow } from "./ServerRow";
import { SkillGroups } from "./SkillRow";
import { Hooks } from "./Hooks";
@@ -491,6 +492,8 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
{at === "persona" && <AgentPresets port={port} focusPersona={landed === "persona"} />}
{at === "providers" && (
<>
<ProviderHealthSetting port={port} onChanged={onChanged} onFailed={setFailed} />
<Group id="providers" title={t("模型服务")}
hint={t("选择一个服务查看和修改;地址、密钥与模型列表保存后生效。自定义中转站的协议与模型会在连接后自动探测。")}>
<Providers port={port} onChanged={() => { loadModels(); onChanged(); }} onFailed={setFailed} protocol={protocol}
@@ -498,6 +501,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
declare={declare}
onProtocol={(a, kind) => switchProtocol(a.key, kind)} />
</Group>
</>
)}
{at === "model" && (
@@ -1,4 +1,5 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useState, useSyncExternalStore } from "react";
import { onOfficePreviewChange, previewsOffice } from "../../state/prefs";
import type { AgentPort } from "../../port/port";
import { t } from "../../i18n";
import { useDocument } from "./useDocument";
@@ -9,6 +10,7 @@ import type { DocumentViewState } from "./viewState";
const PdfPreview = lazy(() => import("./PdfPreview"));
const DelimitedPreview = lazy(() => import("./DelimitedPreview"));
const StaticHtmlPreview = lazy(() => import("./StaticHtmlPreview"));
const OfficePreview = lazy(() => import("./OfficePreview"));
function Picture({ data, path, view }: { data: ArrayBuffer; path: string; view: DocumentViewState }) {
const [url, setURL] = useState("");
@@ -38,11 +40,15 @@ export default function DocumentPreview({ port, path, shown, refresh, onOpenExte
view: DocumentViewState;
}) {
const { value, reload } = useDocument(port, path, shown, refresh);
const officeOn = useSyncExternalStore(onOfficePreviewChange, previewsOffice, previewsOffice);
const { info, data, error } = value ?? {};
const officeWaiting = officeOn && info?.kind === "office" && !data && !error;
useEffect(() => { if (officeWaiting) reload(); }, [officeWaiting, reload]);
const fallback = <p role="alert">{t("此文件暂不支持预览")}</p>;
let body = <p role="status">{t("正在读取…")}</p>;
if (error) body = <p role="alert">{error}</p>;
else if (info?.kind === "unsupported") body = fallback;
else if (info?.kind === "office" && !officeOn) body = <p role="status">{t("Office 文档预览已在设置中关闭")}</p>;
else if (info && info.size > info.limit) body = <p role="alert">{t("文件超过预览大小限制")}</p>;
else if (data && info) {
const text = () => new TextDecoder("utf-8", { fatal: true }).decode(data);
@@ -51,6 +57,7 @@ export default function DocumentPreview({ port, path, shown, refresh, onOpenExte
case "table": body = <DelimitedPreview path={path} text={text()} view={view} />; break;
case "html": body = <StaticHtmlPreview text={text()} />; break;
case "image": body = <Picture data={data} path={path} view={view} />; break;
case "office": body = <OfficePreview data={data} path={path} view={view} />; break;
default: body = fallback;
}
}
@@ -0,0 +1,69 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { t } from "../../i18n";
import { staticDocument } from "./staticDocument";
import { readWorkbook, SHEET_COLUMN_LIMIT, SHEET_ROW_LIMIT, type SheetView } from "./officeSheet";
import { fitZoom, readWordDocument, type WordView } from "./officeWord";
import type { DocumentViewState } from "./viewState";
type Parsed = { kind: "word"; word: WordView } | { kind: "sheets"; sheets: SheetView[] } | { kind: "failed" };
function useWidth() {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));
observer.observe(el);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
}
export default function OfficePreview({ data, path, view }: { data: ArrayBuffer; path: string; view: DocumentViewState }) {
const [parsed, setParsed] = useState<Parsed>();
const [sheet, updateSheet] = useState(view.sheet ?? 0);
const setSheet = (i: number) => { view.sheet = i; updateSheet(i); };
const [box, width] = useWidth();
const [chosen, updateZoom] = useState(view.zoom);
const setZoom = (value: number | undefined) => { view.zoom = value; updateZoom(value); };
useEffect(() => {
let live = true;
setParsed(undefined);
const read = /\.xlsx$/i.test(path)
? readWorkbook(data).then((sheets): Parsed => ({ kind: "sheets", sheets }))
: readWordDocument(data).then((word): Parsed => ({ kind: "word", word }));
read.catch((): Parsed => ({ kind: "failed" })).then((p) => { if (live) setParsed(p); });
return () => { live = false; };
}, [data, path]);
const current = parsed?.kind === "sheets" ? parsed.sheets[Math.min(sheet, parsed.sheets.length - 1)] : undefined;
const fit = parsed?.kind === "word" ? fitZoom(width, parsed.word.pageWidth) : 1;
const zoom = chosen ?? fit;
const html = parsed?.kind === "word" ? `<style>body{zoom:${zoom}}</style>${parsed.word.html}` : current?.html;
const doc = useMemo(() => html === undefined ? "" : staticDocument(html), [html]);
let body;
if (!parsed) body = <p role="status">{t("正在读取…")}</p>;
else if (parsed.kind === "failed") body = <p role="alert">{t("无法解析这个文件,可以用系统应用打开")}</p>;
else if (parsed.kind === "sheets" && !current) body = <p role="status">{t("这个工作簿没有可见的工作表")}</p>;
else body = <iframe className="document-html document-office" title={t("Office 文档预览")} sandbox="" srcDoc={doc} />;
return <div className="document-office-view" ref={box}>
{parsed && parsed.kind !== "failed" && <p className="document-notice">{t("只读预览,宏、脚本和外部资源未加载")}</p>}
{parsed?.kind === "word" && (
<div className="document-controls">
<button data-action="document.zoom" data-target={path} onClick={() => setZoom(Math.max(0.25, zoom - 0.1))} aria-label={t("缩小")}>−</button>
<button data-action="document.zoom" data-target={path} aria-pressed={chosen === undefined} onClick={() => setZoom(undefined)}>{t("适应宽度")} · {Math.round(zoom * 100)}%</button>
<button data-action="document.zoom" data-target={path} onClick={() => setZoom(Math.min(3, zoom + 0.1))} aria-label={t("放大")}>+</button>
</div>
)}
{parsed?.kind === "sheets" && parsed.sheets.length > 1 && (
<div className="document-controls" role="tablist" aria-label={t("工作表")}>
{parsed.sheets.map((s, i) => (
<button key={s.name} role="tab" aria-selected={s === current} data-action="document.sheet" data-target={s.name} onClick={() => setSheet(i)}>{s.name}</button>
))}
</div>
)}
{current?.clipped && <p className="document-notice">{t("只显示前 {rows} 行、{cols} 列", { rows: SHEET_ROW_LIMIT, cols: SHEET_COLUMN_LIMIT })}</p>}
{body}
</div>;
}
@@ -1,4 +1,4 @@
.document-preview, .document-pdf {
.document-preview, .document-pdf, .document-office-view {
display: flex;
flex: 1;
flex-direction: column;
@@ -23,6 +23,15 @@
.document-notice, .document-preview > p {
padding: 8px 12px;
}
.document-office-view > .document-notice {
margin: 0;
font-size: 12px;
font-weight: normal;
color: var(--muted);
}
.document-controls [role="tab"][aria-selected="true"] {
border-color: var(--text);
}
.document-html {
flex: 1;
width: 100%;
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import ExcelJS from "exceljs";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { MockPort } from "../../port/mock";
import { setPreviewsOffice } from "../../state/prefs";
import DocumentPreview from "./DocumentPreview";
import { previewKind } from "./registry";
beforeEach(() => localStorage.clear());
afterEach(() => cleanup());
async function sheetBytes(): Promise<ArrayBuffer> {
const book = new ExcelJS.Workbook();
book.addWorksheet("Q3").addRow(["区域", 1100]);
return new Uint8Array((await book.xlsx.writeBuffer()) as ArrayBuffer).slice().buffer;
}
function officePort(data: ArrayBuffer) {
const port = new MockPort();
vi.spyOn(port, "workspacePreviewInfo").mockResolvedValue({ ref: { path: "budget.xlsx" }, kind: "office", version: "v1", size: data.byteLength, limit: 32 << 20, readOnly: false });
const content = vi.spyOn(port, "workspacePreviewContent").mockResolvedValue({ data, version: "v1" });
return { port, content };
}
it("classes the formats it can read as office and leaves the rest unsupported", () => {
expect(previewKind("报告.docx")).toBe("office");
expect(previewKind("budget.XLSX")).toBe("office");
for (const name of ["old.doc", "old.xls", "deck.pptx", "old.ppt"]) expect(previewKind(name)).toBe("unsupported");
});
it("renders a workbook in a sandboxed frame", async () => {
const { port } = officePort(await sheetBytes());
render(<DocumentPreview port={port} path="budget.xlsx" shown refresh="" view={{}} />);
const frame = await screen.findByTitle("Office 文档预览", undefined, { timeout: 5000 });
expect(frame.getAttribute("sandbox")).toBe("");
expect(frame.getAttribute("srcdoc")).toContain("1100");
expect(frame.getAttribute("srcdoc")).toContain("Content-Security-Policy");
});
it("with the setting off reads no content, and turning it on loads the preview", async () => {
setPreviewsOffice(false);
const { port, content } = officePort(await sheetBytes());
render(<DocumentPreview port={port} path="budget.xlsx" shown refresh="" view={{}} />);
await screen.findByText("Office 文档预览已在设置中关闭");
expect(content).not.toHaveBeenCalled();
act(() => setPreviewsOffice(true));
await waitFor(() => expect(content).toHaveBeenCalled());
expect(await screen.findByTitle("Office 文档预览", undefined, { timeout: 5000 })).toBeTruthy();
});
it("says so instead of breaking when the file is not a real workbook", async () => {
const { port } = officePort(new TextEncoder().encode("not a zip").buffer);
render(<DocumentPreview port={port} path="budget.xlsx" shown refresh="" view={{}} />);
expect(await screen.findByText("无法解析这个文件,可以用系统应用打开", undefined, { timeout: 5000 })).toBeTruthy();
});
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
import ExcelJS from "exceljs";
import JSZip from "jszip";
import { expect, it } from "vitest";
import { displayText, readWorkbook, SHEET_ROW_LIMIT } from "./officeSheet";
import { fitZoom, readWordDocument } from "./officeWord";
import { staticDocument } from "./staticDocument";
async function workbook(build: (book: ExcelJS.Workbook) => void): Promise<ArrayBuffer> {
const book = new ExcelJS.Workbook();
build(book);
const buf = await book.xlsx.writeBuffer();
return new Uint8Array(buf as ArrayBuffer).slice().buffer;
}
const parse = (html: string) => new DOMParser().parseFromString(staticDocument(html), "text/html");
it("shows a cell the way its own number format writes it", () => {
expect(displayText(1100, "#,##0")).toBe("1,100");
expect(displayText(0.31, "0.0%")).toBe("31.0%");
expect(displayText(-0.04, "0.0%")).toBe("-4.0%");
expect(displayText(1100)).toBe("1100");
expect(displayText({ formula: "SUM(A1:A2)", result: 2500 } as ExcelJS.CellValue, "#,##0")).toBe("2,500");
expect(displayText({ richText: [{ text: "华" }, { text: "东" }] } as ExcelJS.CellValue)).toBe("华东");
expect(displayText(new Date(Date.UTC(2026, 9, 1)), "yyyy-mm-dd")).toBe("2026-10-01");
expect(displayText(12, "[bogus")).toBe("12");
});
it("renders sheets with merges, styles and escaped text, skipping hidden ones", async () => {
const data = await workbook((book) => {
const ws = book.addWorksheet("Q3");
ws.addRow(["区域", "7月"]);
ws.addRow(["<script>alert(1)</script>", 1100]);
ws.getCell("B2").numFmt = "#,##0";
ws.getCell("A1").font = { bold: true, color: { argb: "FFFFFFFF" } };
ws.getCell("A1").fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1F4E79" } };
ws.mergeCells("A3:B3");
ws.getCell("A3").value = "备注";
book.addWorksheet("secret", { state: "hidden" }).addRow(["hidden"]);
});
const sheets = await readWorkbook(data);
expect(sheets.map((s) => s.name)).toEqual(["Q3"]);
const doc = parse(sheets[0].html);
expect(doc.querySelector("script")).toBeNull();
expect(doc.querySelector('[data-cell="A2"]')?.textContent).toBe("<script>alert(1)</script>");
expect(doc.querySelector('[data-cell="B2"]')?.textContent).toBe("1,100");
const header = doc.querySelector<HTMLElement>('[data-cell="A1"]')!;
expect(header.style.background).toContain("rgb(31, 78, 121)");
expect(header.style.fontWeight).toBe("bold");
expect(doc.querySelector('[data-cell="A3"]')?.getAttribute("colspan")).toBe("2");
expect(doc.querySelector('[data-cell="B3"]')).toBeNull();
});
it("clips a sheet past the row limit and says so", async () => {
const data = await workbook((book) => {
const ws = book.addWorksheet("big");
for (let i = 0; i < SHEET_ROW_LIMIT + 5; i++) ws.addRow([i]);
});
const [sheet] = await readWorkbook(data);
expect(sheet.clipped).toBe(true);
expect(sheet.rows).toBe(SHEET_ROW_LIMIT + 5);
expect(parse(sheet.html).querySelectorAll("tbody tr")).toHaveLength(SHEET_ROW_LIMIT);
});
async function wordFile(body: string): Promise<ArrayBuffer> {
const zip = new JSZip();
zip.file("[Content_Types].xml", '<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>');
zip.file("_rels/.rels", '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>');
zip.file("word/document.xml", `<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}</w:body></w:document>`);
return zip.generateAsync({ type: "arraybuffer" });
}
it("renders a Word body to static HTML that keeps its text and runs nothing", async () => {
const data = await wordFile('<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>季度报告</w:t></w:r><w:r><w:t xml:space="preserve"> &lt;img src=x onerror=alert(1)&gt;</w:t></w:r></w:p>');
const word = await readWordDocument(data);
const doc = parse(word.html);
expect(doc.body.textContent).toContain("季度报告");
expect(doc.body.textContent).toContain("<img src=x onerror=alert(1)>");
expect(doc.querySelector("img,script,[onerror]")).toBeNull();
expect(doc.querySelector('meta[http-equiv="Content-Security-Policy"]')).not.toBeNull();
});
it("refuses something that is not an Office package", async () => {
await expect(readWorkbook(new TextEncoder().encode("not a zip").buffer)).rejects.toThrow();
await expect(readWordDocument(new TextEncoder().encode("not a zip").buffer)).rejects.toThrow();
});
it("draws Symbol-font bullets as the glyphs they stand for", async () => {
const data = await wordFile('<w:p><w:r><w:t>\uF0B7 one \uF0A7 two</w:t></w:r></w:p>');
const { html } = await readWordDocument(data);
expect(html).toContain("• one ▪ two");
expect(html).not.toMatch(/[\uF020-\uF0FF]/);
});
it("fits a page to the frame in 5% steps and never past 100%", () => {
expect(fitZoom(848, 816)).toBe(1);
expect(fitZoom(2000, 816)).toBe(1);
expect(fitZoom(520, 816)).toBe(0.55);
expect(fitZoom(100, 816)).toBe(0.25);
expect(fitZoom(520, 0)).toBe(1);
expect(fitZoom(0, 816)).toBe(1);
});
@@ -0,0 +1,114 @@
import type { Cell, CellValue, Workbook, Worksheet } from "exceljs";
import * as SSF from "ssf";
export const SHEET_ROW_LIMIT = 2000;
export const SHEET_COLUMN_LIMIT = 200;
export interface SheetView {
name: string;
html: string;
rows: number;
columns: number;
clipped: boolean;
}
const escape = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
const color = (argb: unknown) => typeof argb === "string" && /^[0-9a-f]{8}$/i.test(argb) ? `#${argb.slice(2)}` : "";
const EXCEL_EPOCH = Date.UTC(1899, 11, 30);
function raw(value: CellValue): unknown {
if (value === null || value === undefined) return "";
if (value instanceof Date) return value;
if (typeof value !== "object") return value;
if ("richText" in value) return value.richText.map((part) => part.text).join("");
if ("result" in value) return value.result === undefined ? "" : raw(value.result as CellValue);
if ("error" in value) return value.error;
if ("text" in value) return value.text;
return "";
}
/** The text Excel would show: the cell's own number format applied to its value. */
export function displayText(value: CellValue, numFmt?: string): string {
const v = raw(value);
const format = numFmt && numFmt !== "General" ? numFmt : undefined;
try {
if (v instanceof Date) return SSF.format(format ?? "yyyy-mm-dd", (v.getTime() - EXCEL_EPOCH) / 864e5);
if (typeof v === "number" && format) return SSF.format(format, v);
} catch {
// An unparseable format falls back to the plain value rather than failing the sheet.
}
if (v instanceof Date) return v.toISOString().slice(0, 10);
return String(v);
}
function cellStyle(cell: Cell): string {
const parts: string[] = [];
const fill = cell.fill?.type === "pattern" ? color(cell.fill.fgColor?.argb) : "";
if (fill) parts.push(`background:${fill}`);
const font = cell.font;
if (font?.bold) parts.push("font-weight:bold");
if (font?.italic) parts.push("font-style:italic");
const fg = color(font?.color?.argb);
if (fg) parts.push(`color:${fg}`);
if (typeof font?.size === "number" && font.size > 0 && font.size < 200) parts.push(`font-size:${font.size}pt`);
const align = cell.alignment?.horizontal;
if (align === "left" || align === "center" || align === "right") parts.push(`text-align:${align}`);
else if (typeof raw(cell.value) === "number") parts.push("text-align:right");
return parts.join(";");
}
function decodeRange(range: string): { top: number; left: number; bottom: number; right: number } | undefined {
const m = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/.exec(range);
if (!m) return undefined;
const col = (s: string) => [...s].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0);
return { top: Number(m[2]), left: col(m[1]), bottom: Number(m[4]), right: col(m[3]) };
}
export function sheetView(ws: Worksheet): SheetView {
const rows = Math.min(ws.rowCount, SHEET_ROW_LIMIT);
const columns = Math.min(ws.columnCount, SHEET_COLUMN_LIMIT);
const span = new Map<string, { rows: number; cols: number }>();
const covered = new Set<string>();
for (const range of (ws.model as { merges?: string[] }).merges ?? []) {
const r = decodeRange(range);
if (!r) continue;
span.set(`${r.top}:${r.left}`, { rows: r.bottom - r.top + 1, cols: r.right - r.left + 1 });
for (let y = r.top; y <= r.bottom; y++) for (let x = r.left; x <= r.right; x++) if (y !== r.top || x !== r.left) covered.add(`${y}:${x}`);
}
const widths: string[] = [];
for (let x = 1; x <= columns; x++) {
const w = ws.getColumn(x).width;
widths.push(`<col style="width:${Math.round(Math.min(Math.max(typeof w === "number" ? w : 9, 2), 120) * 7 + 10)}px">`);
}
let body = "";
for (let y = 1; y <= rows; y++) {
const row = ws.getRow(y);
let cells = `<th>${y}</th>`;
for (let x = 1; x <= columns; x++) {
if (covered.has(`${y}:${x}`)) continue;
const cell = row.getCell(x);
const merge = span.get(`${y}:${x}`);
const attrs = merge ? `${merge.rows > 1 ? ` rowspan="${merge.rows}"` : ""}${merge.cols > 1 ? ` colspan="${merge.cols}"` : ""}` : "";
const style = cellStyle(cell);
cells += `<td data-cell="${escape(cell.address)}"${attrs}${style ? ` style="${style}"` : ""}>${escape(displayText(cell.value, cell.numFmt))}</td>`;
}
body += `<tr>${cells}</tr>`;
}
let head = "<th></th>";
for (let x = 1; x <= columns; x++) head += `<th>${escape(ws.getColumn(x).letter)}</th>`;
const html = `<style>${SHEET_CSS}</style><table><colgroup><col style="width:40px">${widths.join("")}</colgroup><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
return { name: ws.name, html, rows: ws.rowCount, columns: ws.columnCount, clipped: ws.rowCount > rows || ws.columnCount > columns };
}
const SHEET_CSS = "body{margin:0;font:13px system-ui,sans-serif;color:#1f1f1f;background:#fff}"
+ "table{border-collapse:collapse;table-layout:fixed}"
+ "td,th{border:1px solid #d9d9d9;padding:2px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"
+ "th{background:#f3f3f3;color:#666;font-weight:normal;position:sticky;top:0}"
+ "tbody th{position:sticky;left:0}";
export async function readWorkbook(data: ArrayBuffer): Promise<SheetView[]> {
const { default: ExcelJS } = await import("exceljs");
const book: Workbook = new ExcelJS.Workbook();
await book.xlsx.load(data);
return book.worksheets.filter((ws) => ws.state !== "hidden" && ws.state !== "veryHidden").map(sheetView);
}
@@ -0,0 +1,49 @@
const WORD_CSS = "body{margin:0;background:#e8e8e8}"
+ ".docx-wrapper{background:#e8e8e8;padding:16px 0}"
+ ".docx-wrapper>section.docx{margin:0 auto 16px;box-shadow:0 1px 4px rgba(0,0,0,.2)}";
// Word draws bullets with private-use code points from the Symbol and Wingdings
// fonts, which the preview cannot load; these are the Unicode glyphs they stand for.
const SYMBOL_BULLETS: Record<string, string> = {
"": "•", "": "▪", "": "□", "": "●", "": "■",
"": "➢", "": "❖", "": "✓", "": "➔", "": "◇",
};
export interface WordView {
html: string;
/** The first page's width in CSS pixels, 0 when the file declares none. */
pageWidth: number;
}
function pixels(width: string): number {
const m = /^([\d.]+)(pt|px|in|cm|mm)$/.exec(width.trim());
if (!m) return 0;
const per: Record<string, number> = { pt: 4 / 3, px: 1, in: 96, cm: 96 / 2.54, mm: 96 / 25.4 };
return Number(m[1]) * per[m[2]];
}
/** A Word file as static HTML: images inlined as data URLs, nothing fetched later. */
export async function readWordDocument(data: ArrayBuffer): Promise<WordView> {
const { renderAsync } = await import("docx-preview");
const body = document.createElement("div");
const styles = document.createElement("div");
await renderAsync(data, body, styles, {
inWrapper: true,
breakPages: true,
ignoreLastRenderedPageBreak: true,
useBase64URL: true,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
});
const page = body.querySelector<HTMLElement>("section.docx");
const html = `<style>${WORD_CSS}</style>${styles.innerHTML}${body.innerHTML}`
.replace(/[-]/g, (c) => SYMBOL_BULLETS[c] ?? c);
return { html, pageWidth: page ? pixels(page.style.width) : 0 };
}
/** The zoom that fits a page of pageWidth into a frame of frameWidth, in 5% steps. */
export function fitZoom(frameWidth: number, pageWidth: number): number {
if (pageWidth <= 0 || frameWidth <= 0) return 1;
return Math.max(0.25, Math.min(1, Math.floor(((frameWidth - 32) / pageWidth) * 20) / 20));
}
@@ -7,7 +7,8 @@ export const renderers: ReadonlyArray<{ kind: Kind; extensions: RegExp; source:
{ kind: "pdf", extensions: /\.pdf$/i, source: false },
{ kind: "table", extensions: /\.(csv|tsv)$/i, source: true },
{ kind: "html", extensions: /\.html?$/i, source: true },
{ kind: "unsupported", extensions: /\.(docx?|xlsx?|pptx?|zip|gz|7z|rar|exe|dll|bin)$/i, source: false },
{ kind: "office", extensions: /\.(docx|xlsx)$/i, source: false },
{ kind: "unsupported", extensions: /\.(doc|xls|pptx?|zip|gz|7z|rar|exe|dll|bin)$/i, source: false },
];
export function previewKind(path: string): Kind {
@@ -3,6 +3,7 @@ import type { AgentPort } from "../../port/port";
import type { PreviewInfo } from "../../port/workspace";
import { parseGrantedFile } from "../grantedfiles";
import { reason } from "../../i18n/kernel";
import { previewsOffice } from "../../state/prefs";
type Loaded = { info: PreviewInfo; data?: ArrayBuffer; error?: string };
@@ -31,7 +32,7 @@ export function useDocument(port: AgentPort, path: string, shown: boolean, refre
observed = info;
if (controller.signal.aborted) return;
let value: Loaded;
if (info.kind === "unsupported" || info.size > info.limit) value = { info };
if (info.kind === "unsupported" || info.size > info.limit || (info.kind === "office" && !previewsOffice())) value = { info };
else if (!force && cache.current?.data && cache.current.info.version === info.version) value = { ...cache.current, info, error: undefined };
else {
const loaded = await port.workspacePreviewContent(ref, controller.signal);
@@ -3,6 +3,7 @@ export interface DocumentViewState {
left?: number;
page?: number;
zoom?: number;
sheet?: number;
}
export function documentState(states: Map<string, DocumentViewState>, path: string): DocumentViewState {
@@ -0,0 +1,12 @@
import { expect, it } from "vitest";
import { modelHealthNote } from "./modelhealth";
import { t } from "../i18n";
it("never promotes a listing into a verified model or an unknown wallet into zero balance", () => {
expect(modelHealthNote({ connection: "reachable", wallet: "available", checkedAt: 1 })).toBe(t("连接正常,模型调用未验证"));
for (const wallet of ["unknown", "unsupported"]) {
expect(modelHealthNote({ connection: "unknown", wallet, checkedAt: 1 })).not.toContain(t("余额不足,请充值后重试"));
}
expect(modelHealthNote({ connection: "rate_limited", wallet: "unsupported", checkedAt: 1 })).toContain(t("请求暂时被限流,请稍后重试"));
expect(modelHealthNote({ connection: "future_state", wallet: "future_state", checkedAt: 1 })).toBe(t("连接状态尚未确认"));
});
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { AgentPort, ModelEntry, ProviderHealth } from "../port/port";
import { t } from "../i18n";
import type { MenuStatus } from "./Menu";
export type ModelHealth = Readonly<Record<string, ProviderHealth | "checking">>;
export function useModelHealth(port: AgentPort, pulse: number) {
const [models, setModels] = useState<ModelEntry[]>([]);
const modelLoad = useRef(0);
const loadModels = useCallback(() => {
const version = ++modelLoad.current;
return port.models().catch(() => []).then((next) => {
if (version === modelLoad.current) setModels(next);
return next;
});
}, [port]);
useEffect(() => {
void loadModels();
return () => { modelLoad.current++; };
}, [loadModels, pulse]);
const [readings, setReadings] = useState<ModelHealth>({});
const generation = useRef(0);
useEffect(() => {
generation.current++;
setReadings({});
return () => { generation.current++; };
}, [port, pulse]);
const check = useCallback(async (listing: Promise<ModelEntry[]>) => {
const version = ++generation.current;
setReadings({});
const [models, settings] = await Promise.all([listing, port.providerHealthSettings().catch(() => null)]);
if (generation.current !== version) return;
if (!settings?.enabled) return;
const names = [...new Set(models.filter((m) => m.kind !== "extension" && !m.provider.startsWith("plugin/")).map((m) => m.provider))];
setReadings(Object.fromEntries(names.map((name) => [name, "checking"])));
let next = 0;
async function worker() {
while (next < names.length && generation.current === version) {
const name = names[next++];
let result: ProviderHealth;
try { result = await port.providerHealth(name); }
catch { result = { connection: "unknown", wallet: "unknown", checkedAt: 0 }; }
if (generation.current === version) setReadings((prior) => ({ ...prior, [name]: result }));
}
}
await Promise.all(Array.from({ length: Math.min(3, names.length) }, worker));
}, [port]);
return { models, loadModels, readings, check };
}
export function modelHealthNote(health: ProviderHealth | "checking" | undefined): string | undefined {
if (!health || health !== "checking" && health.connection === "disabled") return undefined;
if (health === "checking") return t("正在检测连接与余额…");
const connection: Record<string, string> = {
reachable: "连接正常,模型调用未验证",
auth: "连接鉴权失败,请检查密钥或权限",
missing_key: "未配置 API 密钥",
payment_required: "服务商要求付费,请检查账户",
quota_exhausted: "可用配额已耗尽",
rate_limited: "请求暂时被限流,请稍后重试",
timeout: "连接检测超时,请稍后重试",
unreachable: "连接异常,请检查网络或服务商",
redirect_refused: "连接跳转被拒绝,请检查地址",
};
const wallet: Record<string, string> = {
insufficient: "余额不足,请充值后重试",
auth: "余额查询鉴权失败",
unsupported: "余额未知(未配置查询接口)",
unknown: "余额暂时无法确认",
};
const issue = wallet[health.wallet];
const status = t(connection[health.connection] ?? "连接状态尚未确认");
return issue ? `${t(issue)} · ${status}` : status;
}
export function modelHealthStatus(health: ProviderHealth | "checking" | undefined): MenuStatus | undefined {
if (!health || health !== "checking" && health.connection === "disabled") return undefined;
const detail = modelHealthNote(health);
if (health === "checking") return { label: t("检测中"), tone: "checking", detail };
if (health.wallet === "insufficient") return { label: t("余额不足"), tone: "warning", detail };
const failures: Record<string, string> = {
auth: "鉴权失败", missing_key: "缺少密钥", payment_required: "需要付费",
quota_exhausted: "配额耗尽", rate_limited: "暂时限流", timeout: "连接超时",
unreachable: "连接异常", redirect_refused: "跳转被拒",
};
if (failures[health.connection]) return { label: t(failures[health.connection]), tone: "warning", detail };
if (health.wallet === "auth") return { label: t("余额查询失败"), tone: "warning", detail };
if (health.connection === "reachable") return { label: t("连接正常"), tone: "positive", detail };
return { label: t("连接待确认"), tone: "neutral", detail };
}
+3 -1
View File
@@ -6,6 +6,7 @@ import { t } from "../i18n";
import { UNCONFIGURED_REF } from "./ConnectNote";
import { KIND_LABEL } from "./vendors";
import { orderAccounts } from "../state/providerorder";
import { modelHealthStatus, type ModelHealth } from "./modelhealth";
// modelTooltip names the active model with the picker's canonical account label.
export function modelTooltip(models: ModelEntry[], current: string | undefined, label: string): string {
@@ -25,7 +26,7 @@ export function modelLabel(models: ModelEntry[], status: { modelRef?: string; la
// Every account gets a heading with its endpoint: the provider name is the
// config's word for an entry, not the user's for an endpoint, and two doors onto
// one account share it. Rows carry the wire format beside the model name.
export function modelMenu(models: ModelEntry[], order: readonly string[] = [], current?: string): MenuItem[] {
export function modelMenu(models: ModelEntry[], order: readonly string[] = [], current?: string, health: ModelHealth = {}): MenuItem[] {
const accounts = orderAccounts(groupVendors(models), order);
const out: MenuItem[] = [];
for (const [i, a] of accounts.entries()) {
@@ -38,6 +39,7 @@ export function modelMenu(models: ModelEntry[], order: readonly string[] = [], c
value: m.ref,
label: m.modelAlias?.trim() || m.modelShort || m.model,
desc: m.modelAlias?.trim() ? m.model : undefined,
status: modelHealthStatus(health[m.provider]),
meta: t(KIND_LABEL[kind] ?? kind),
badge: m.vision ? t("读图") : undefined,
mono,
@@ -248,6 +248,7 @@ export const SETTINGS: SettingEntry[] = [
// is what the row promises, because the weaker one would be a promise this
// block cannot keep.
{ section: "providers", anchor: "providers", title: "模型服务", scope: "machine", apply: "runtime-rebuild", keywords: ["模型来源", "连接", "提供商", "供应商", "api key", "密钥", "协议", "地址"] },
{ section: "providers", anchor: "provider-health", title: "自动检测连接与余额", scope: "machine", apply: "immediate", keywords: ["检测", "余额", "连接", "health", "balance"] },
// Takes effect on this session at once and is also persisted as the default
// every later session starts from. Machine is the wider of the two answers
@@ -329,6 +330,7 @@ export const SETTINGS: SettingEntry[] = [
{ section: "appearance", anchor: "folding", title: "会话折叠", scope: "machine", apply: "immediate", keywords: ["展开", "收起", "思考", "执行过程", "步骤", "输出", "简报", "fold"] },
{ section: "appearance", anchor: "plan-progress", title: "收起时显示计划进度", scope: "machine", apply: "immediate", keywords: ["计划", "进度条", "待办", "收起", "todo", "plan", "progress", "collapsed"] },
{ section: "appearance", anchor: "composer-hint", title: "输入框提示", scope: "machine", apply: "immediate", keywords: ["占位", "任务", "提示", "输入框", "placeholder", "composer", "hint"] },
{ section: "appearance", anchor: "office-preview", title: "Office 文档预览", scope: "machine", apply: "immediate", keywords: ["office", "word", "excel", "docx", "xlsx", "工作台", "预览", "文档", "表格"] },
{ section: "appearance", anchor: "window", title: "窗口", scope: "machine", apply: "immediate", keywords: ["托盘", "关闭行为", "休眠", "睡眠"] },
// Immediate because the endpoint hands the holder every running sink reads,
// not because the file was written: writing it is what a restart would need.
+1 -1
View File
@@ -16,7 +16,7 @@ export const PAGES: Partial<Record<Section, readonly SettingPage[]>> = {
appearance: [
{ id: "look", label: "外观与主题", anchors: ["mode", "scheme", "font", "size", "weight", "contrast", "effects", "wallpaper", "language", "advanced-appearance"] },
{ id: "navigation", label: "导航与侧栏", anchors: ["navrail", "naventries", "session-quick-actions", "legacy-sessions", "live-grace", "open-session-limit", "live-stream-budget", "session-tabs"] },
{ id: "conversation", label: "会话显示", anchors: ["new-answer-marker", "composer-hint", "folding", "plan-progress"] },
{ id: "conversation", label: "会话显示", anchors: ["new-answer-marker", "composer-hint", "folding", "plan-progress", "office-preview"] },
{ id: "shortcuts", label: "快捷键", anchors: ["send-shortcut", "running-message-intent", "esc-stop", "global-shortcut", "keys"] },
{ id: "window", label: "窗口与性能", anchors: ["window", "notify", "page-sleep", "graphics"] },
],
@@ -11,6 +11,8 @@ import { MockPort } from "../port/mock";
import { MockHub } from "../port/mock_hub";
import type { AgentPort, SessionStatus } from "../port/port";
vi.mock("./storageTally", () => ({ STORAGE_TALLY_DELAY: 1500, cachedStorageTotal: () => 0, storageTotal: vi.fn() }));
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.setItem(STORAGE, "zh"); boot(); });
const language = (lang: string) => { localStorage.setItem(STORAGE, lang); boot(); };
const block = (anchor: string) => document.querySelector<HTMLElement>(`[data-setting="${anchor}"]`);
@@ -26,9 +26,10 @@ it("maps field ids to safe bindings and returns typed values from the library da
it("does not expose executable components or library functions through the Studio catalogue", () => {
const runtime = createSurfaceRuntime([{ kind: "text", value: '<script>bad</script> {"path":"/secret"}' }]);
try {
expect(runtime.surface.defaultCatalog.functions.size).toBe(0);
expect(runtime.surface.defaultCatalog.components.has("Image")).toBe(false);
expect(runtime.surface.defaultCatalog.components.has("Video")).toBe(false);
expect(runtime.surface.defaultCatalog.components.has("Modal")).toBe(false);
const catalog = runtime.surface.defaultCatalog;
expect(catalog?.functions.size).toBe(0);
expect(catalog?.components.has("Image")).toBe(false);
expect(catalog?.components.has("Video")).toBe(false);
expect(catalog?.components.has("Modal")).toBe(false);
} finally { runtime.dispose(); }
});
@@ -63,6 +63,10 @@ const endpointOf = (n, path) => endpointsOf(n, path)[0] ?? null;
// against the tree it belongs to — an entry that excuses nothing fails.
const DECLARED_NON_MUTATION = {
src: {
"POST /providers/health": { class: "repeatable-external-probe", why:
"control.ProviderHealth reads the saved connection and calls providerhealth.Checker, which " +
"fetches model listings and configured wallets with GET requests. Only an in-memory TTL " +
"cache changes; no configuration, session or model selection is written. External network effect: yes." },
"POST /providers/probe": { class: "repeatable-external-probe", why:
"config.ProbeEndpoint asks the endpoint what models it has and the answer is written back to the " +
"caller; no canonical host state is written. External network effect: yes — this set decides " +
+1 -1
View File
@@ -20,7 +20,7 @@ require (
require (
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.3 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.4 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/godbus/dbus/v5 v5.2.4 // indirect
+2 -2
View File
@@ -4,8 +4,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/bmatcuk/doublestar/v4 v4.10.3 h1:1UIv1+fqH3++ViVkpnaPTB+Pi1UHycnPFAq14zgK+gI=
github.com/bmatcuk/doublestar/v4 v4.10.3/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.10.4 h1:oTKkRmRuOsKMqFe1AQazd9gGBFNEpOmgKp/xzB97dnA=
github.com/bmatcuk/doublestar/v4 v4.10.4/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
+6 -1
View File
@@ -403,8 +403,13 @@ token out of request URLs, browser history, referrers, and access logs. Pass `--
`[serve].token` to reuse a stable token. Password mode requires either
`--password` at startup or a stored bcrypt hash:
- Prefer `--password-file` over `--password`: argv is visible to every local
user through `ps` and `/proc/<pid>/cmdline`.
- The file holds a single line and is readable only by its owner (`chmod 600`).
```bash
reasonix serve --hash-password --password 'strong-password'
reasonix serve --auth password --password-file ~/.reasonix-serve.password
reasonix serve --hash-password --password-file ~/.reasonix-serve.password
# <Reasonix home>/config.toml
[serve]
+5 -1
View File
@@ -324,8 +324,12 @@ HttpOnly Cookie,再启动 API 与 SSE 请求,从而避免 Token 进入请求
Referrer 和访问日志。可通过 `--token` 或 `[serve].token`
复用固定 token。Password 模式必须在启动时传 `--password`,或在配置里保存 bcrypt hash:
- 优先用 `--password-file` 而不是 `--password`:命令行参数对本机所有用户可见(`ps`、`/proc/<pid>/cmdline`)。
- 密码文件只能有一行,且只允许所有者读取(`chmod 600`)。
```bash
reasonix serve --hash-password --password 'strong-password'
reasonix serve --auth password --password-file ~/.reasonix-serve.password
reasonix serve --hash-password --password-file ~/.reasonix-serve.password
# <Reasonix home>/config.toml
[serve]
+81
View File
@@ -0,0 +1,81 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-28
---
# Model connection checks / 模型连接检测
Automatic checks are **off by default**. Enable **Automatically check connections
and balances** in model connection settings to opt in. Opening Studio's chat
model picker then checks authorized user-global connections in the background.
Rows show connection/authentication failures, payment or quota refusals, temporary
rate limits, and insufficient balance reported by a configured wallet endpoint.
Checks keep every model and the current selection; use **Manage models and
connections** to repair a connection or run an explicit model trial.
The picker keeps its existing account groups, visible endpoint hosts, row density
and current-selection behavior. Opting in adds one status chip to each model row;
hovering the row or reading its accessible description reveals the full diagnostic.
The switch applies to every authorized global connection and sits outside the
individual service editor. Older servers show an upgrade hint with a disabled
switch; a failed settings read shows a connection/permission hint and runs no probes.
The automatic check reads the model listing and configured wallet only. It sends
no completion requests. A reachable listing does not verify that a particular
model can generate; an unsupported, unreadable, or unconfigured wallet stays
unknown.
Resolver-backed and extension catalogs are not checked against local
credentials. Servers without provider-edit permission do not run these probes.
Project configuration cannot opt in or change probe destinations or proxies;
project-only connections must first be saved globally. All redirects are refused,
including same-origin redirects.
Cached entries contain only a credential/config
digest, status classes and timestamps, never raw keys, responses or balances.
Checks run up to three connections at a time in the picker and share an in-flight
request within one runtime. Results are cached for one minute; reopening the
picker refreshes expired results. Changing credentials, connection settings, or
proxy configuration invalidates the cache. Results are kept in memory only.
自动检测**默认关闭**。在模型连接设置中开启「自动检测连接与余额」后,打开模型
列表才会在后台检查已授权的全局连接。模型行会提示
连接或鉴权失败、付费要求、配额耗尽、临时限流,以及余额接口报告的余额不足。
检测保留所有模型与当前选择;可通过「管理模型与连接」修复配置或主动试调用模型。
模型列表保留原账户分组、可见接口域名、行密度和当前选择行为。开启检测后仅在
模型行增加一个状态标签,悬停或读屏说明提供完整诊断。
开关作用于全部已授权全局连接,位于单个服务编辑器之外。旧服务器显示升级提示
并禁用开关;设置读取失败时提示检查连接或权限,且不执行探测。
自动检测只读取模型列表和已配置的余额接口,不发送生成请求。列表连通不等于
每个模型都能调用;不支持、未配置或无法读取的余额保持未知。远程解析器和扩展
模型不会拿本机凭据进行检测;没有服务商编辑权限的服务端也不会执行这些探测。
项目配置无法开启检测或更改检测地址及代理;项目专属连接需先保存到全局。
所有跳转(含同源跳转)均被拒绝;缓存仅保留配置及凭据摘要、状态和时间戳,
不保留原始密钥、响应内容或余额数值。
菜单最多同时检查三个连接,同一运行时复用正在进行的检测。结果缓存一分钟,
过期后重新打开菜单会再次检查;修改密钥、连接或代理配置会使旧缓存失效。
检测结果只保留在内存中,不写入配置,也不自动切换模型。
## Compatibility / 兼容性
| Contract / 合约 | Older client / 旧客户端 | New client / 新客户端 |
| --- | --- | --- |
| Health settings API / 检测设置 API | Missing route / 无路由 | Missing/error means no automatic probes / 缺失或错误时不发自动检测 |
| `desktop.provider_health_checks` | Absent means off / 缺省关闭 | User-global opt-in only / 仅用户全局配置可开启 |
Requirements and scope decisions / 需求与范围记录:
[Discussion #14617](https://github.com/esengine/DeepSeek-Reasonix/issues/14617).
The grouped layout is retained per maintainer feedback / 按维护者反馈保留分组布局:
[Review](https://github.com/esengine/DeepSeek-Reasonix/pull/14634#issuecomment-6449927590).
+1
View File
@@ -100,6 +100,7 @@ Checked against `studio` at `d779d89bc` on 2026-10-08.
| Feature | Status | Studio location |
| --- | --- | --- |
| Per-model display aliases | Have | Model service settings and chat picker; [configuration and scope](MODEL_DISPLAY_ALIASES.md); #11774 |
| Model connection health | Have | Opt-in status chips in the existing grouped picker; [scope and limits](MODEL_CONNECTION_HEALTH.md). 默认关闭,保留账户分组与接口域名。 |
| Icon rail with three display modes | Have | #12377, #12428; sidebar footer entries appear only when icon navigation is hidden (#12478) |
| Usage, feedback, account and settings footer / 用量、反馈、账号与设置底部入口 | Have differently | `ui/Nav.tsx` when navigation is visible; `ui/Sidebar.tsx` otherwise, including the narrow-window drawer. 导航可见时使用图标栏入口,隐藏时保留侧栏入口 (#12478) |
| Completed-unread marker on sessions | Have | #12218 |
+4 -4
View File
@@ -7,14 +7,14 @@ toolchain go1.26.10
require (
aead.dev/minisign v0.3.0
charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.1.0
charm.land/bubbletea/v2 v2.1.1
charm.land/lipgloss/v2 v2.0.6
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3
github.com/BurntSushi/toml v1.6.0
github.com/alecthomas/chroma/v2 v2.27.0
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
github.com/bmatcuk/doublestar/v4 v4.10.3
github.com/bmatcuk/doublestar/v4 v4.10.4
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.9
github.com/godbus/dbus/v5 v5.2.4
@@ -23,12 +23,12 @@ require (
github.com/kevinburke/ssh_config v1.6.0
github.com/klauspost/compress v1.20.3
github.com/mattn/go-runewidth v0.0.31
github.com/pkg/sftp v1.13.11
github.com/pkg/sftp v1.13.12
github.com/rivo/uniseg v0.4.7
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
github.com/spf13/pflag v1.0.10
github.com/tdewolff/parse/v2 v2.8.19
github.com/tdewolff/parse/v2 v2.8.20
github.com/tree-sitter/go-tree-sitter v0.25.0
github.com/tree-sitter/tree-sitter-javascript v0.25.0
github.com/tree-sitter/tree-sitter-python v0.25.0
+8 -8
View File
@@ -2,8 +2,8 @@ aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
charm.land/bubbletea/v2 v2.1.1 h1:S1S9l37pCZGrj1JexBcr9x4Nr3MVeM42InqCYM/tDpU=
charm.land/bubbletea/v2 v2.1.1/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
@@ -22,8 +22,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/bmatcuk/doublestar/v4 v4.10.3 h1:1UIv1+fqH3++ViVkpnaPTB+Pi1UHycnPFAq14zgK+gI=
github.com/bmatcuk/doublestar/v4 v4.10.3/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.10.4 h1:oTKkRmRuOsKMqFe1AQazd9gGBFNEpOmgKp/xzB97dnA=
github.com/bmatcuk/doublestar/v4 v4.10.4/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
@@ -95,8 +95,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
github.com/pkg/sftp v1.13.12 h1:Myhzy/ZzLPnCtE2F2WtDHsgs8RfgMnH2gfbHuCJhkMU=
github.com/pkg/sftp v1.13.12/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -117,8 +117,8 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tdewolff/parse/v2 v2.8.19 h1:p+JoZI06kApwHWyOe/BAsl7DC06UqyDDbAKU68/9I4w=
github.com/tdewolff/parse/v2 v2.8.19/go.mod h1:OemXEPll2+Sn7pQ6xFxC9cEm1wDnNt1hC2q3jIh+OoI=
github.com/tdewolff/parse/v2 v2.8.20 h1:eU+UStwuoeHnXdaSBIoEfNnj33URx24gJDdNViHHYwM=
github.com/tdewolff/parse/v2 v2.8.20/go.mod h1:OemXEPll2+Sn7pQ6xFxC9cEm1wDnNt1hC2q3jIh+OoI=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12krCJF5xJ2sftRU=
@@ -119,8 +119,8 @@ func TestEffectGrantedWorkspaceRootIsWritableAndTheOtherIsNot(t *testing.T) {
if strings.Contains(userMessages(without[0]), "Additional folders") {
t.Fatal("a workspace with no grants carried the extra-folder line")
}
if extraRootPrefix(with[0]) != extraRootPrefix(without[0]) {
t.Fatalf("an extra root moved the cache-stable prefix")
if a, b := extraRootPrefix(with[0]), extraRootPrefix(without[0]); a != b {
t.Fatalf("an extra root moved the cache-stable prefix: %s", firstDifference(a, b))
}
}
+173
View File
@@ -0,0 +1,173 @@
package boot
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/base/netclient"
"reasonix/internal/contract/config"
"reasonix/internal/contract/provider"
_ "reasonix/internal/model/anthropic"
_ "reasonix/internal/model/openai"
)
func TestMiniMaxPresetsReachHTTP(t *testing.T) {
for _, id := range []string{"minimax-cn-api", "minimax-global-api", "minimax-cn-anthropic", "minimax-global-anthropic"} {
for _, model := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3"} {
for _, effort := range []string{"", "low", "medium", "high", "xhigh", "max", "adaptive", "disabled"} {
if model == "MiniMax-M3" && effort != "" && effort != "adaptive" && effort != "disabled" {
continue
}
if model == "MiniMax-M3.1-Flash" && (effort == "adaptive" || effort == "disabled") {
continue
}
t.Run(id+"/"+model+"/"+effort, func(t *testing.T) {
preset, ok := config.CuratedProviderPreset(id)
if !ok {
t.Fatal("missing preset")
}
cfg := config.Config{Providers: preset.Entries}
entry, ok := cfg.ResolveModel(id + "/" + model)
if !ok {
t.Fatalf("model %s is absent", model)
}
entry.Effort = effort
wire := miniMaxHTTPBody(t, entry)
if wire["model"] != model || wire["service_tier"] != nil {
t.Fatalf("unexpected model/tier: %v", wire)
}
thinking, _ := wire["thinking"].(map[string]any)
wantThinking := "adaptive"
if model == "MiniMax-M3" && (effort == "disabled" || (entry.Kind == "anthropic" && effort == "")) {
wantThinking = "disabled"
}
if thinking["type"] != wantThinking || thinking["display"] != nil {
t.Fatalf("thinking = %v, want %s without display", thinking, wantThinking)
}
gotEffort := wire["reasoning_effort"]
if entry.Kind == "anthropic" {
output, _ := wire["output_config"].(map[string]any)
gotEffort = output["effort"]
}
if model == "MiniMax-M3.1-Flash" {
want := effort
if want == "" {
want = "max"
}
if gotEffort != want {
t.Fatalf("effort = %v, want %s", gotEffort, want)
}
} else if gotEffort != nil {
t.Fatalf("M3 received depth: %v", gotEffort)
}
body, _ := json.Marshal(wire)
for _, marker := range []string{"reasoning-marker", "tool-marker", "image", "aGVsbG8="} {
if !strings.Contains(string(body), marker) {
t.Errorf("request lost %s", marker)
}
}
})
}
}
}
}
func miniMaxHTTPBody(t *testing.T, entry *config.ProviderEntry) map[string]any {
t.Helper()
captured := make(chan map[string]any, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
}
captured <- body
w.Header().Set("Content-Type", "text/event-stream")
if entry.Kind == "anthropic" {
_, _ = io.WriteString(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
} else {
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
}
}))
defer srv.Close()
keyed := entry.WithAPIKeyForProbe("test-key")
entry = &keyed
entry.ChatURL = srv.URL + "/v1/chat/completions"
entry.RequestURL = srv.URL + "/v1/messages"
p, err := NewProviderWithProxy(entry, netclient.ProxySpec{})
if err != nil {
t.Fatal(err)
}
stream, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{
{Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/png;base64,aGVsbG8="}},
{Role: provider.RoleAssistant, Content: "checking", ReasoningContent: "reasoning-marker", ToolCalls: []provider.ToolCall{{ID: "tool-marker", Name: "inspect", Arguments: `{}`}}},
{Role: provider.RoleTool, ToolCallID: "tool-marker", Content: "done"},
}})
if err != nil {
t.Fatal(err)
}
for chunk := range stream {
if chunk.Type == provider.ChunkError {
t.Fatal(chunk.Err)
}
}
select {
case body := <-captured:
return body
default:
t.Fatal("request never reached HTTP")
return nil
}
}
func TestMiniMaxOpenRouterFlashReachesHTTP(t *testing.T) {
preset, _ := config.CuratedProviderPreset("openrouter")
cfg := config.Config{Providers: preset.Entries}
for _, effort := range []string{"", "low", "medium", "high", "xhigh", "max"} {
entry, ok := cfg.ResolveModel("openrouter/minimax/minimax-m3.1-flash:free")
if !ok {
t.Fatal("gateway Flash did not resolve")
}
entry.Effort = effort
wire := miniMaxHTTPBody(t, entry)
if effort == "" {
effort = "max"
}
if wire["model"] != "minimax/minimax-m3.1-flash:free" || wire["reasoning_effort"] != effort {
t.Fatalf("gateway request = %v", wire)
}
}
}
func TestMiniMaxFlashRejectsDisablingThinking(t *testing.T) {
for _, kind := range []string{"openai", "anthropic"} {
for _, level := range []string{"none", "disabled", "off", "adaptive"} {
_, err := provider.New(kind, provider.Config{BaseURL: "https://api.minimax.io", Model: "MiniMax-M3.1-Flash", APIKey: "test", Extra: map[string]any{"effort": level}})
if err == nil {
t.Errorf("%s accepted invalid effort %s", kind, level)
}
}
_, err := provider.New(kind, provider.Config{BaseURL: "https://api.minimax.io", Model: "MiniMax-M3.1-Flash", APIKey: "test", Extra: map[string]any{"thinking": "disabled"}})
if err == nil {
t.Errorf("%s accepted disabled thinking", kind)
}
}
}
func TestMiniMaxSavedAndCustomModelsReachHTTPUnchanged(t *testing.T) {
for _, kind := range []string{"openai", "anthropic"} {
for _, model := range []string{"MiniMax-M2.7", "custom-model"} {
t.Run(kind+"/"+model, func(t *testing.T) {
entry := &config.ProviderEntry{Name: "saved", Kind: kind, BaseURL: "https://api.minimax.io/anthropic", Model: model}
wire := miniMaxHTTPBody(t, entry)
if wire["model"] != model {
t.Fatalf("saved model changed: %v", wire["model"])
}
})
}
}
}
+8 -5
View File
@@ -15,9 +15,10 @@ import (
)
const (
TextLimit = 2 << 20
ImageLimit = 10 << 20
PDFLimit = 32 << 20
TextLimit = 2 << 20
ImageLimit = 10 << 20
PDFLimit = 32 << 20
OfficeLimit = 32 << 20
)
var (
@@ -208,7 +209,7 @@ func (s Service) content(ctx context.Context, ref Ref, textOnly bool) ([]byte, I
if metadata(ref, after).Version != info.Version {
return nil, info, ErrChanged
}
if info.Kind != "pdf" && info.Kind != "image" && (!utf8.Valid(data) || strings.IndexByte(string(data), 0) >= 0) {
if info.Kind != "pdf" && info.Kind != "image" && info.Kind != "office" && (!utf8.Valid(data) || strings.IndexByte(string(data), 0) >= 0) {
return nil, info, ErrNotText
}
return data, info, nil
@@ -226,7 +227,9 @@ func Format(path string) (string, int64) {
return "pdf", PDFLimit
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".bmp", ".ico", ".svg":
return "image", ImageLimit
case ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".zip", ".gz", ".7z", ".rar", ".exe", ".dll", ".bin":
case ".docx", ".xlsx":
return "office", OfficeLimit
case ".doc", ".xls", ".ppt", ".pptx", ".zip", ".gz", ".7z", ".rar", ".exe", ".dll", ".bin":
return "unsupported", 0
default:
return "text", TextLimit
+20
View File
@@ -87,3 +87,23 @@ func TestGrantedRootAndCancellation(t *testing.T) {
t.Fatalf("canceled read = %v", err)
}
}
func TestOfficeDocumentsPreviewAsBinaryWithinTheirLimit(t *testing.T) {
root := t.TempDir()
zip := []byte("PK\x03\x04\x00binary office package")
s := Service{Primary: root}
for _, name := range []string{"报告.docx", "budget.XLSX"} {
if err := os.WriteFile(filepath.Join(root, name), zip, 0o600); err != nil {
t.Fatal(err)
}
got, info, err := s.Content(context.Background(), Ref{Path: name})
if err != nil || info.Kind != "office" || info.Limit != OfficeLimit || string(got) != string(zip) {
t.Fatalf("%s: kind=%q limit=%d err=%v", name, info.Kind, info.Limit, err)
}
}
for _, name := range []string{"old.doc", "old.xls", "slides.pptx", "old.ppt"} {
if kind, _ := Format(name); kind != "unsupported" {
t.Fatalf("%s: kind %q, want unsupported", name, kind)
}
}
}
+15 -27
View File
@@ -66,6 +66,9 @@ func EffortCapabilityForEntry(e *ProviderEntry) EffortCapability {
if explicitProtocol == ReasoningProtocolKimiK3 {
return kimiK3EffortCapability()
}
if isMiniMaxFlashEntry(e) {
return EffortCapability{Supported: true, Levels: append([]string{"auto"}, provider.MiniMaxFlashEfforts()...), Default: "max"}
}
supported := normalizedSupportedEfforts(e)
if len(supported) > 0 {
levels := make([]string, 0, len(supported)+1)
@@ -88,12 +91,11 @@ func EffortCapabilityForEntry(e *ProviderEntry) EffortCapability {
}
switch {
case isMiniMaxEntry(e):
// MiniMax-M3 only exposes a binary thinking knob (adaptive|disabled)
// on its OpenAI-compatible endpoint, so /effort mirrors the API
// vocabulary verbatim. Default is "adaptive" because the M3 model
// runs with thinking on out of the box; "auto" means "don't override
// the model default" (== adaptive for M3).
return EffortCapability{Supported: true, Levels: []string{"auto", "adaptive", "disabled"}, Default: "adaptive"}
def := "adaptive"
if e.Kind == "anthropic" {
def = "disabled"
}
return EffortCapability{Supported: true, Levels: []string{"auto", "adaptive", "disabled"}, Default: def}
case isZhipuEntry(e):
return zhipuEffortCapability(e)
case isLongCatEntry(e):
@@ -199,6 +201,9 @@ func normalizeEffort(e *ProviderEntry, raw string) (string, error) {
if explicitProtocol == ReasoningProtocolKimiK3 {
return normalizeKimiK3ReasoningEffort(level)
}
if isMiniMaxFlashEntry(e) {
return normalizeMiniMaxFlashEffort(level)
}
supported := normalizedSupportedEfforts(e)
if len(supported) > 0 {
if containsString(supported, level) {
@@ -234,23 +239,7 @@ func normalizeEffort(e *ProviderEntry, raw string) (string, error) {
}
switch {
case isMiniMaxEntry(e):
// The M3 knob is binary; map Anthropic / OpenAI-style levels onto the
// nearest valid value so a stale /effort high|low still works. "off"
// is a retired DeepSeek level meaning "no thinking" — on M3 that maps
// to "disabled" rather than the model default, since M3 actually
// supports a "thinking off" mode and "off" is the natural request.
switch level {
case "adaptive", "disabled":
return level, nil
case "off":
return "disabled", nil
case "low", "medium", "high":
return "adaptive", nil
case "xhigh", "max":
return "disabled", nil
default:
return "", fmt.Errorf("usage: /effort auto|adaptive|disabled")
}
return normalizeMiniMaxBinaryEffort(level)
case isZhipuEntry(e):
return normalizeZhipuEffort(e, level)
case isLongCatEntry(e):
@@ -443,11 +432,10 @@ func isDeepSeekEntry(e *ProviderEntry) bool {
return e != nil && e.Kind == "openai" && provider.IsDeepSeekEndpoint(e.BaseURL)
}
// isMiniMaxEntry reports whether the entry points at MiniMax's OpenAI-compatible
// endpoint. See provider.IsMiniMaxEndpoint for the host-matching rule; the entry-wrapper
// just gates on the openai kind.
// isMiniMaxEntry recognizes the direct Chat Completions and Messages routes.
// Gateway effort vocabularies come from their own declarations.
func isMiniMaxEntry(e *ProviderEntry) bool {
return e != nil && e.Kind == "openai" && provider.IsMiniMaxEndpoint(e.BaseURL)
return e != nil && (e.Kind == "openai" || e.Kind == "anthropic") && provider.IsMiniMaxEndpoint(e.BaseURL)
}
// isZhipuEntry reports whether the entry points at Zhipu's OpenAI-compatible
+8 -1
View File
@@ -1,6 +1,10 @@
package config
import "slices"
import (
"slices"
"reasonix/internal/contract/provider"
)
// EffortFieldForEntry is where a chosen effort level lands in this entry's
// request body, or "" when the entry's resolved reasoning protocol reshapes the
@@ -17,6 +21,9 @@ func EffortFieldForEntry(e *ProviderEntry) string {
if resolved == ReasoningProtocolNone {
return ""
}
if explicitReasoningProtocol(e) == "" && isMiniMaxEntry(e) && provider.IsMiniMaxFlash(e.Model) {
return p.EffortField
}
if len(p.EffortUnder) == 0 {
return p.EffortField
}
+7 -2
View File
@@ -5,11 +5,11 @@ var (
openRouterModels = []string{
"deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro",
"openai/gpt-6-sol", "google/gemini-3.8-flash",
"moonshotai/kimi-k3", "z-ai/glm-5.2", "qwen/qwen3.7-max", "minimax/minimax-m3",
"moonshotai/kimi-k3", "z-ai/glm-5.2", "qwen/qwen3.7-max", "minimax/minimax-m3.1-flash:free", "minimax/minimax-m3",
}
openRouterVisionModels = []string{
"openai/gpt-6-sol", "google/gemini-3.8-flash",
"moonshotai/kimi-k3", "minimax/minimax-m3",
"moonshotai/kimi-k3", "minimax/minimax-m3.1-flash:free", "minimax/minimax-m3",
}
openAIModels = []string{"gpt-6-sol", "gpt-6-astra", "gpt-6-luna"}
openAIWindow = 1_050_000
@@ -46,6 +46,11 @@ var globalProviderPresets = []ProviderPreset{
BalanceURL: "https://openrouter.ai/api/v1/credits",
ContextWindow: 1_000_000,
Headers: openRouterAttributionHeaders(),
ModelOverrides: map[string]ProviderModelOverride{
"minimax/minimax-m3.1-flash:free": {
ContextWindow: 1000000, SupportedEfforts: []string{"low", "medium", "high", "xhigh", "max"}, DefaultEffort: "max",
},
},
}},
},
{
+32
View File
@@ -0,0 +1,32 @@
package config
import (
"fmt"
"reasonix/internal/contract/provider"
)
func isMiniMaxFlashEntry(e *ProviderEntry) bool {
return explicitReasoningProtocol(e) == "" && isMiniMaxEntry(e) && provider.IsMiniMaxFlash(e.Model)
}
// normalizeMiniMaxBinaryEffort preserves the legacy M3 alias mappings.
func normalizeMiniMaxBinaryEffort(level string) (string, error) {
switch level {
case "adaptive", "disabled":
return level, nil
case "off", "xhigh", "max":
return "disabled", nil
case "low", "medium", "high":
return "adaptive", nil
default:
return "", fmt.Errorf("usage: /effort auto|adaptive|disabled")
}
}
func normalizeMiniMaxFlashEffort(level string) (string, error) {
if containsString(provider.MiniMaxFlashEfforts(), level) {
return level, nil
}
return "", fmt.Errorf("usage: /effort auto|low|medium|high|xhigh|max")
}
@@ -0,0 +1,68 @@
package config
import (
"reflect"
"strings"
"testing"
)
func TestMiniMaxFlashCatalogAndEffort(t *testing.T) {
for _, preset := range CuratedProviderPresets() {
for _, entry := range preset.Entries {
for _, model := range entry.ModelList() {
if strings.Contains(strings.ToLower(model), "minimax-m2") {
t.Errorf("fresh preset %s still offers retired model %s", preset.ID, model)
}
}
}
}
for _, id := range []string{"minimax-cn-api", "minimax-global-api", "minimax-cn-anthropic", "minimax-global-anthropic"} {
preset, _ := CuratedProviderPreset(id)
entry := preset.Entries[0]
if !reflect.DeepEqual(entry.ModelList(), []string{"MiniMax-M3.1-Flash", "MiniMax-M3"}) || entry.DefaultModel() != "MiniMax-M3.1-Flash" {
t.Fatalf("%s: models/default = %v/%s", id, entry.ModelList(), entry.DefaultModel())
}
cfg := Config{Providers: preset.Entries}
flash, ok := cfg.ResolveModel(id + "/MiniMax-M3.1-Flash")
if !ok || flash.ContextWindow != 1000000 || !EffectiveVision(flash) {
t.Fatalf("%s: resolved Flash = %+v", id, flash)
}
cap := EffortCapabilityForEntry(flash)
if !reflect.DeepEqual(cap.Levels, []string{"auto", "low", "medium", "high", "xhigh", "max"}) || cap.Default != "max" {
t.Fatalf("%s: Flash effort capability = %+v", id, cap)
}
for _, level := range cap.Levels[1:] {
if got, err := NormalizeEffort(flash, level); err != nil || got != level {
t.Errorf("%s/%s: %s, %v", id, level, got, err)
}
}
for _, level := range []string{"none", "disabled", "off", "adaptive"} {
if _, err := NormalizeEffort(flash, level); err == nil {
t.Errorf("%s accepted invalid depth %s", id, level)
}
}
if EffortFieldForEntry(flash) == "" {
t.Errorf("%s: missing effort field", id)
}
}
}
func TestMiniMaxSavedChoicesSurvivePresetBackfill(t *testing.T) {
preset, _ := CuratedProviderPreset("minimax-cn-api")
entry := preset.Entries[0]
entry.Models = []string{"MiniMax-M2.7", "user-model"}
entry.Default = "MiniMax-M2.7"
entry.ContextWindow = 123456
entry.ExtraBody = map[string]any{"user_flag": true}
cfg := Config{Providers: []ProviderEntry{entry}}
upgradeShippedPresets(&cfg)
for _, model := range []string{"MiniMax-M2.7", "user-model"} {
resolved, ok := cfg.ResolveModel(entry.Name + "/" + model)
if !ok || resolved.Model != model || resolved.ContextWindow != 123456 || resolved.ExtraBody["user_flag"] != true {
t.Fatalf("saved model changed: %+v", resolved)
}
}
if cfg.Providers[0].DefaultModel() != "MiniMax-M2.7" {
t.Fatal("saved default was silently replaced")
}
}
+13 -13
View File
@@ -91,23 +91,23 @@ var (
longCat20Models = []string{"LongCat-2.0"}
tokenRhythmModels = []string{
"deepseek-v4-flash", "deepseek-v4-pro", "glm-5", "glm-5.1",
"minimax-m2.7", "kimi-k2.5", "kimi-k2.6", "minimax-m2.5",
"kimi-k2.5", "kimi-k2.6",
"mimo-v2.5-pro", "qwen3.7-max", "kimi-k2.7-code", "glm-5.2",
"qwen3.8-max", "deepseek-v4-flash-0731",
}
tokenRhythmVisionModels = []string{"kimi-k2.5", "kimi-k2.6", "kimi-k2.7-code"}
minimaxMSeriesModels = []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"}
minimaxMSeriesVisionModels = []string{"MiniMax-M3"}
minimaxMSeriesModels = []string{"MiniMax-M3.1-Flash", "MiniMax-M3"}
minimaxMSeriesVisionModels = []string{"MiniMax-M3.1-Flash", "MiniMax-M3"}
glmAPIModels = []string{"glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7", "glm-4.7-flash", "glm-4.7-flashx", "glm-4.6", "glm-4.5", "glm-4.5-air", "glm-4.5-flash"}
glmAPIVisionModels = []string{"glm-5v-turbo"}
glmCodingModels = []string{"glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5", "glm-4.7"}
glmAnthropicModels = []string{"glm-5.2[1m]", "glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5-air"}
qwenAPIModels = []string{"qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "MiniMax-M2.5", "glm-5", "glm-4.7", "kimi-k2.5"}
qwenAPIModels = []string{"qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "glm-5", "glm-4.7", "kimi-k2.5"}
qwenAPIVisionModels = []string{"qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"}
qwenPlanModels = []string{"qwen3.7-plus", "qwen3.6-plus", "kimi-k2.5", "glm-5", "MiniMax-M2.5", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "glm-4.7"}
qwenPlanModels = []string{"qwen3.7-plus", "qwen3.6-plus", "kimi-k2.5", "glm-5", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "glm-4.7"}
qwenPlanVisionModels = []string{"qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"}
stepfunPlanModels = []string{"step-3.7-flash", "step-3.5-flash", "step-3.5-flash-2603"}
@@ -330,9 +330,9 @@ var curatedProviderPresets = slices.Concat([]ProviderPreset{
BaseURL: "https://api.minimaxi.com/v1",
Models: minimaxMSeriesModels,
VisionModels: minimaxMSeriesVisionModels,
Default: "MiniMax-M3",
Default: "MiniMax-M3.1-Flash",
APIKeyEnv: "MINIMAX_API_KEY",
ContextWindow: 1048576,
ContextWindow: 1000000,
ExtraBody: map[string]any{"reasoning_split": true},
}},
},
@@ -348,9 +348,9 @@ var curatedProviderPresets = slices.Concat([]ProviderPreset{
BaseURL: "https://api.minimax.io/v1",
Models: minimaxMSeriesModels,
VisionModels: minimaxMSeriesVisionModels,
Default: "MiniMax-M3",
Default: "MiniMax-M3.1-Flash",
APIKeyEnv: "MINIMAX_API_KEY",
ContextWindow: 1048576,
ContextWindow: 1000000,
ExtraBody: map[string]any{"reasoning_split": true},
}},
},
@@ -366,10 +366,10 @@ var curatedProviderPresets = slices.Concat([]ProviderPreset{
BaseURL: "https://api.minimaxi.com/anthropic",
Models: minimaxMSeriesModels,
VisionModels: minimaxMSeriesVisionModels,
Default: "MiniMax-M3",
Default: "MiniMax-M3.1-Flash",
APIKeyEnv: "MINIMAX_PLAN_API_KEY",
AuthHeader: true,
ContextWindow: 1048576,
ContextWindow: 1000000,
}},
},
{
@@ -384,10 +384,10 @@ var curatedProviderPresets = slices.Concat([]ProviderPreset{
BaseURL: "https://api.minimax.io/anthropic",
Models: minimaxMSeriesModels,
VisionModels: minimaxMSeriesVisionModels,
Default: "MiniMax-M3",
Default: "MiniMax-M3.1-Flash",
APIKeyEnv: "MINIMAX_API_KEY",
AuthHeader: true,
ContextWindow: 1048576,
ContextWindow: 1000000,
}},
},
{
@@ -11,7 +11,7 @@ var opencodeGoAnthropicPreset = ProviderPreset{
Name: "opencode-go-anthropic",
Kind: "anthropic",
BaseURL: "https://opencode.ai/zen/go",
Models: []string{"qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "minimax-m3", "minimax-m2.7", "minimax-m2.5"},
Models: []string{"qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "minimax-m3"},
VisionModels: []string{"qwen3.7-plus", "qwen3.6-plus"},
Default: "qwen3.7-plus",
APIKeyEnv: "OPENCODE_GO_API_KEY",
@@ -329,8 +329,8 @@ func TestCuratedProviderPresetReturnsDeepCopy(t *testing.T) {
if !ok {
t.Fatal("missing fresh minimax-cn-api preset")
}
if got := fresh.Entries[0].Models[0]; got != "MiniMax-M3" {
t.Fatalf("fresh minimax first model = %q, want MiniMax-M3", got)
if got := fresh.Entries[0].Models[0]; got != "MiniMax-M3.1-Flash" {
t.Fatalf("fresh minimax first model = %q, want MiniMax-M3.1-Flash", got)
}
if got, _ := fresh.Entries[0].ExtraBody["reasoning_split"].(bool); !got {
t.Fatalf("fresh minimax reasoning_split = %v, want true", fresh.Entries[0].ExtraBody["reasoning_split"])
@@ -483,14 +483,14 @@ func TestCuratedProviderPresetCapabilities(t *testing.T) {
if !ok {
t.Fatal("minimax-global-api provider missing")
}
if minimaxGlobalAPI.BaseURL != "https://api.minimax.io/v1" || !minimaxGlobalAPI.HasModel("MiniMax-M2.7-highspeed") {
if minimaxGlobalAPI.BaseURL != "https://api.minimax.io/v1" || !minimaxGlobalAPI.HasModel("MiniMax-M3.1-Flash") || minimaxGlobalAPI.HasModel("MiniMax-M2.7-highspeed") {
t.Fatalf("minimax-global-api capability mismatch: %+v", minimaxGlobalAPI)
}
minimaxPlan, ok := cfg.Provider("minimax-cn-anthropic")
if !ok {
t.Fatal("minimax-cn-anthropic provider missing")
}
if minimaxPlan.Kind != "anthropic" || !minimaxPlan.AuthHeader || !minimaxPlan.HasVisionModel("MiniMax-M3") || !minimaxPlan.HasModel("MiniMax-M2.7-highspeed") {
if minimaxPlan.Kind != "anthropic" || !minimaxPlan.AuthHeader || !minimaxPlan.HasVisionModel("MiniMax-M3") || !minimaxPlan.HasModel("MiniMax-M3.1-Flash") || minimaxPlan.HasModel("MiniMax-M2.7-highspeed") {
t.Fatalf("minimax-cn-anthropic capability mismatch: %+v", minimaxPlan)
}
minimaxGlobal, ok := cfg.Provider("minimax-global-anthropic")
@@ -665,7 +665,6 @@ func TestCuratedProviderPresetCapabilities(t *testing.T) {
"qwen3-coder-plus": 1_000_000,
"qwen3-max-2026-01-23": 262_144,
"qwen3-coder-next": 262_144,
"MiniMax-M2.5": 196_608,
"glm-5": 202_752,
"glm-4.7": 202_752,
"kimi-k2.5": 262_144,
+14 -6
View File
@@ -69,13 +69,21 @@ func IsGeminiEndpoint(baseURL string) bool {
return strings.EqualFold(u.Hostname(), "generativelanguage.googleapis.com")
}
// IsMiniMaxEndpoint reports whether baseURL points at MiniMax's OpenAI-compatible
// endpoint (api.minimaxi.com or any *.minimaxi.com subdomain).
//
// The host string is matched exactly — the spelling is `minimaxi`, not
// `minimax` — to avoid clashing with any future minimax-branded gateway.
// IsMiniMaxEndpoint reports whether baseURL points at MiniMax's China or
// international endpoint, including their subdomains.
func IsMiniMaxEndpoint(baseURL string) bool {
return matchVendorHost(baseURL, "minimaxi.com", "api.minimaxi.com")
return matchVendorHost(baseURL, "minimaxi.com", "api.minimaxi.com") ||
matchVendorHost(baseURL, "minimax.io", "api.minimax.io")
}
// IsMiniMaxFlash identifies the direct API model with always-on, tunable thinking.
func IsMiniMaxFlash(model string) bool {
return strings.EqualFold(model, "MiniMax-M3.1-Flash")
}
// MiniMaxFlashEfforts are the supported depth levels, in ascending order.
func MiniMaxFlashEfforts() []string {
return []string{"low", "medium", "high", "xhigh", "max"}
}
// IsZhipuEndpoint reports whether baseURL is Zhipu's endpoint for GLM models,
+1 -1
View File
@@ -126,7 +126,7 @@ func TestServePasswordAuthRequiresPasswordMaterial(t *testing.T) {
t.Fatalf("serve --auth password without password rc = %d, want 1", rc)
}
})
if !strings.Contains(errOut, "auth mode password requires --password or serve.password_hash") {
if !strings.Contains(errOut, "auth mode password requires --password-file, --password or serve.password_hash") {
t.Fatalf("serve --auth password stderr = %q, want password material validation", errOut)
}
}
+22 -2
View File
@@ -10,6 +10,7 @@ import (
"reasonix/internal/base/fileutil"
"reasonix/internal/base/i18n"
"reasonix/internal/base/secrets"
"reasonix/internal/frontend/serve"
)
@@ -24,6 +25,20 @@ func readServeTokenFile(path string) (string, error) {
return lines[0], nil
}
// servePassword is --password, or the single line of --password-file, which
// wins and is registered as a host secret so the password never sits in argv.
func servePassword(password, file string) (string, error) {
if file == "" {
return password, nil
}
pw, err := readServeTokenFile(file)
if err != nil {
return "", err
}
secrets.RegisterHostSecretPath(file)
return pw, nil
}
// readPrivateLines reads a file this serve's owner alone may read, holding
// exactly want non-empty lines. A secret kept in a file is only as private as
// the file, and a line count checked here is one no caller re-derives.
@@ -85,12 +100,17 @@ func writeServePidFile(path string) error {
// printPasswordHash answers --hash-password: print a bcrypt hash of password
// and stop, rather than starting a server. The second return says whether the
// caller is done; the first is its exit code.
func printPasswordHash(asked bool, password string) (int, bool) {
func printPasswordHash(asked bool, password, file string) (int, bool) {
if !asked {
return 0, false
}
password, err := servePassword(password, file)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1, true
}
if password == "" {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "--hash-password requires --password")
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "--hash-password requires --password-file or --password")
return 1, true
}
h, err := serve.HashPassword(password)
+17 -10
View File
@@ -262,7 +262,8 @@ func runServeWithOptions(args []string, opts serveRunOptions) int {
auth := fs.String("auth", "", authHelp)
token := fs.String("token", "", "pre-shared token for auth=token (auto-generated if empty)")
password := fs.String("password", "", "password for auth=password (use --hash-password to store a hash instead)")
hashPassword := fs.Bool("hash-password", false, "print a bcrypt hash of --password and exit")
passwordFile := fs.String("password-file", "", "read the auth=password password from this file (overrides --password; keeps the secret out of argv)")
hashPassword := fs.Bool("hash-password", false, "print a bcrypt hash of --password or --password-file and exit")
allowOpenNetwork := fs.Bool("allow-open-network", false, "allow --auth none on an address other machines can reach (anyone on the network can then read sessions and history)")
behindProxy := fs.Bool("behind-proxy", false, "trust X-Forwarded-For / X-Forwarded-Proto headers from a reverse proxy")
publicURL := fs.String("public-url", "", "the address a phone opens to reach this server, e.g. https://agent.example.com behind a TLS proxy; used for the share link and its QR code")
@@ -312,7 +313,7 @@ func runServeWithOptions(args []string, opts serveRunOptions) int {
return 2
}
if code, done := printPasswordHash(*hashPassword, *password); done {
if code, done := printPasswordHash(*hashPassword, *password, *passwordFile); done {
return code
}
@@ -332,7 +333,7 @@ func runServeWithOptions(args []string, opts serveRunOptions) int {
// Build serve config, merging CLI flags over config file.
serveCfg, code := serveConfigFromFlags(opts.command, authExplicit, cfg.Serve, serveFlagValues{
auth: *auth, token: *token, tokenFile: *tokenFile,
password: *password, behindProxy: *behindProxy,
password: *password, passwordFile: *passwordFile, behindProxy: *behindProxy,
})
if code != 0 {
return code
@@ -437,7 +438,7 @@ func applyServePassword(serveCfg *config.ServeConfig, password string) int {
serveCfg.PasswordHash = h
}
if serveCfg.AuthMode == "password" && strings.TrimSpace(serveCfg.PasswordHash) == "" {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "auth mode password requires --password or serve.password_hash")
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "auth mode password requires --password-file, --password or serve.password_hash")
return 1
}
return 0
@@ -446,11 +447,12 @@ func applyServePassword(serveCfg *config.ServeConfig, password string) int {
// serveFlagValues are the serve flags whose effect lands on the serve config
// rather than on the frontend.
type serveFlagValues struct {
auth string
token string
tokenFile string
password string
behindProxy bool
auth string
token string
tokenFile string
password string
passwordFile string
behindProxy bool
}
// serveConfigFromFlags merges the serve flags over the config file. Both
@@ -483,7 +485,12 @@ func serveConfigFromFlags(command string, authExplicit bool, base config.ServeCo
return serveCfg, 1
}
serveCfg.AuthMode = mode
if code := applyServePassword(&serveCfg, flags.password); code != 0 {
password, err := servePassword(flags.password, flags.passwordFile)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return serveCfg, 1
}
if code := applyServePassword(&serveCfg, password); code != 0 {
return serveCfg, code
}
return serveCfg, 0
@@ -0,0 +1,67 @@
package cli
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
)
func writeSecret(t *testing.T, body string, mode os.FileMode) string {
t.Helper()
p := filepath.Join(testenv.TempDir(t), "serve.password")
if err := os.WriteFile(p, []byte(body), mode); err != nil {
t.Fatal(err)
}
return p
}
// --hash-password reads --password-file, so producing the stored hash never
// puts the password in argv either.
func TestHashPasswordReadsThePasswordFile(t *testing.T) {
p := writeSecret(t, "MySecret123\n", 0o600)
var code int
var out string
captureStderr(t, func() {
out = captureStdout(t, func() {
code = runServeWithOptions([]string{"--hash-password", "--password-file", p}, serveRunOptions{command: "serve"})
})
})
if code != 0 {
t.Fatalf("exit %d", code)
}
if err := bcrypt.CompareHashAndPassword([]byte(strings.TrimSpace(out)), []byte("MySecret123")); err != nil {
t.Fatalf("printed hash does not match the file's password: %v", err)
}
}
// A password file stands in for --password at startup, wins over it, and is
// held to the same privacy rule as a token file.
func TestServePasswordFileSetsThePasswordHash(t *testing.T) {
p := writeSecret(t, "MySecret123\n", 0o600)
cfg, code := serveConfigFromFlags("serve", true, config.ServeConfig{}, serveFlagValues{
auth: "password", password: "from-argv", passwordFile: p,
})
if code != 0 {
t.Fatalf("exit %d", code)
}
if err := bcrypt.CompareHashAndPassword([]byte(cfg.PasswordHash), []byte("MySecret123")); err != nil {
t.Fatalf("hash is not the file's password: %v", err)
}
if runtime.GOOS == "windows" {
return
}
loose := writeSecret(t, "MySecret123\n", 0o644)
stderr := captureStderr(t, func() {
_, code = serveConfigFromFlags("serve", true, config.ServeConfig{}, serveFlagValues{auth: "password", passwordFile: loose})
})
if code == 0 || !strings.Contains(stderr, "chmod 600") {
t.Fatalf("a group-readable password file was accepted: code=%d stderr=%q", code, stderr)
}
}
+1 -1
View File
@@ -300,7 +300,7 @@ func cliServeCompletionFlags(model, profile, help cliCompletionFlag) []cliComple
// Serve/Web resume accepts file paths, not branch IDs.
completionFlag("--resume", cliCompletionPathValue),
completionFlag("--auth", cliCompletionStaticValue, "none", "token", "password"),
completionFlag("--token --password --port-file --token-file --pid-file", cliCompletionStaticValue),
completionFlag("--token --password --password-file --port-file --token-file --pid-file", cliCompletionStaticValue),
completionFlag("--hash-password --behind-proxy --open --no-open", cliCompletionNoValue), help,
}
}
@@ -12,6 +12,7 @@ import (
"reasonix/internal/base/testenv"
"reasonix/internal/state/sessionstore"
"reasonix/internal/state/store"
)
// seedAged writes n conversations whose age rises with their number, so
@@ -24,8 +25,15 @@ func seedAged(t *testing.T, root string, n int) []string {
p := filepath.Join(SessionDirFor(root), fmt.Sprintf("session-%03d.jsonl", i))
writeSessionAt(t, p)
at := base.Add(time.Duration(i) * time.Hour)
for _, f := range []string{p, p + ".meta"} {
if _, err := os.Stat(f); err == nil {
// The listing reads activity from the branch meta before any file time.
if err := sessionstore.UpdateBranchMeta(p, false, func(m *sessionstore.BranchMeta) error {
m.UpdatedAt = at
return nil
}); err != nil {
t.Fatalf("age session meta: %v", err)
}
for _, f := range []string{p, p + ".meta", store.SessionEventLog(p)} {
if _, err := os.Stat(f); f != "" && err == nil {
_ = os.Chtimes(f, at, at)
}
}
+5 -1
View File
@@ -97,6 +97,10 @@ func New(cfg provider.Config) (provider.Provider, error) {
keyEnv, _ := cfg.Extra["api_key_env"].(string) // for actionable auth errors
keySource, _ := cfg.Extra["api_key_source"].(string)
thinking, effort := resolveReasoning(root, cfg.Extra)
thinking, effort, err := resolveMiniMaxReasoning(cfg, thinking, effort)
if err != nil {
return nil, err
}
vision, _ := cfg.Extra["vision"].(bool)
// DeepSeek's Anthropic-compatible endpoint documents image source blocks, so
// the constraint is the model's rather than the endpoint's. Defense in depth,
@@ -480,7 +484,7 @@ func (c *client) buildRequest(_ context.Context, req provider.Request) anthReque
}
switch t {
case "adaptive":
r.Thinking = &thinkingConfig{Type: "adaptive", Display: "summarized"}
r.Thinking = c.adaptiveThinkingConfig()
if c.effort != "" {
r.OutputConfig = &outputConfig{Effort: c.effort}
}
+43
View File
@@ -0,0 +1,43 @@
package anthropic
import (
"strings"
"reasonix/internal/contract/provider"
"reasonix/internal/model/openai"
)
func resolveMiniMaxReasoning(cfg provider.Config, thinking, effort string) (string, string, error) {
protocol, _ := cfg.Extra["reasoning_protocol"].(string)
if !provider.IsMiniMaxEndpoint(cfg.BaseURL) || (protocol != "" && protocol != "auto") {
return thinking, effort, nil
}
rawEffort, _ := cfg.Extra["effort"].(string)
if provider.IsMiniMaxFlash(cfg.Model) {
depth, err := openai.MiniMaxFlashEffort(rawEffort, cfg.Extra)
return "adaptive", depth, err
}
if strings.EqualFold(cfg.Model, "MiniMax-M3") {
mode, _ := cfg.Extra["thinking"].(string)
if rawEffort == "adaptive" || rawEffort == "disabled" {
mode = rawEffort
}
if mode == "adaptive" || mode == "enabled" {
return "adaptive", "", nil
}
return "disabled", "", nil
}
return thinking, effort, nil
}
func (c *client) RequiresReasoningRoundTrip() bool {
return c != nil && provider.IsMiniMaxEndpoint(c.baseURL)
}
func (c *client) adaptiveThinkingConfig() *thinkingConfig {
out := &thinkingConfig{Type: "adaptive", Display: "summarized"}
if provider.IsMiniMaxEndpoint(c.baseURL) {
out.Display = ""
}
return out
}
+2
View File
@@ -31,6 +31,8 @@ func speaksDeepSeekContract(root string, extra map[string]any) bool {
// or nil when this endpoint's contract has no place for it.
func (c *client) replayThinking(m provider.Message) *contentBlock {
switch {
case provider.IsMiniMaxEndpoint(c.baseURL) && m.ReasoningContent != "":
return &contentBlock{Type: "thinking", Thinking: m.ReasoningContent, Signature: m.ReasoningSignature}
case c.deepseek && len(m.ToolCalls) > 0 && (m.ReasoningContent != "" || c.deepSeekThinkingEnabled()):
// DeepSeek wants a tool-call turn's reasoning in every later request,
// even once this one declares no tools or thinking has been turned off.
+30
View File
@@ -0,0 +1,30 @@
package catalog
import (
"context"
"io"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"reasonix/internal/contract/config"
)
func TestMiniMaxDiscoveryPassesUnknownModelsThrough(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":[{"id":"MiniMax-M3"},{"id":"MiniMax-M3.1-Flash"},{"id":"MiniMax-M3.2"},{"id":"MiniMax-M4"}]}`)
}))
defer srv.Close()
entry := &config.ProviderEntry{Name: "fixture", Kind: "openai", BaseURL: srv.URL}
entryWithKey := entry.WithAPIKeyForProbe("test-key")
got, err := FetchModelsVia(context.Background(), &entryWithKey, srv.Client())
if err != nil {
t.Fatal(err)
}
want := []string{"MiniMax-M3", "MiniMax-M3.1-Flash", "MiniMax-M3.2", "MiniMax-M4"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("discovered models = %v, want %v", got, want)
}
}
+2 -2
View File
@@ -80,7 +80,7 @@ func TestProbeIdentifiesAnAnthropicEndpoint(t *testing.T) {
// list cannot separate them. The probe must say it is unsure rather than
// quietly calling every such endpoint OpenAI.
func TestProbeAdmitsWhenBothProtocolsAnswer(t *testing.T) {
srv := modelsServer(t, bearer, "MiniMax-M2")
srv := modelsServer(t, bearer, "MiniMax-M3.1-Flash")
got, err := ProbeEndpoint(context.Background(), ProbeOptions{BaseURL: srv.URL, APIKey: "k"})
if err != nil {
t.Fatalf("ProbeEndpoint: %v", err)
@@ -105,7 +105,7 @@ func TestProbeAdmitsWhenBothProtocolsAnswer(t *testing.T) {
// strongest evidence available, and it is the same knowledge the model-fetch
// compat suffixes already encode.
func TestProbePrefersAnthropicWhenThePathSaysSo(t *testing.T) {
srv := modelsServer(t, bearer, "MiniMax-M2")
srv := modelsServer(t, bearer, "MiniMax-M3.1-Flash")
got, err := ProbeEndpoint(context.Background(), ProbeOptions{BaseURL: srv.URL + "/anthropic", APIKey: "k"})
if err != nil {
t.Fatalf("ProbeEndpoint: %v", err)
+59
View File
@@ -0,0 +1,59 @@
package openai
import (
"fmt"
"slices"
"strings"
"reasonix/internal/contract/provider"
)
func resolveMiniMaxEffort(cfg provider.Config, effort string) (string, error) {
if provider.IsMiniMaxFlash(cfg.Model) {
return MiniMaxFlashEffort(effort, cfg.Extra)
}
effort = strings.ToLower(strings.TrimSpace(effort))
switch effort {
case "", "adaptive", "disabled":
return effort, nil
default:
return "", fmt.Errorf("openai: provider %q uses MiniMax thinking; effort must be adaptive or disabled", cfg.Name)
}
}
// MiniMaxFlashEffort validates the shared MiniMax depth contract for both API wires.
func MiniMaxFlashEffort(effort string, extra map[string]any) (string, error) {
effort = strings.ToLower(strings.TrimSpace(effort))
thinking, _ := extra["thinking"].(string)
thinking = strings.ToLower(strings.TrimSpace(thinking))
if thinking == "disabled" || thinking == "off" || thinking == "none" {
return "", fmt.Errorf("MiniMax-M3.1-Flash cannot disable thinking: %w", provider.ErrEffortRefused)
}
if effort == "" || effort == "auto" {
return "max", nil
}
if !slices.Contains(provider.MiniMaxFlashEfforts(), effort) {
return "", fmt.Errorf("MiniMax-M3.1-Flash effort must be low, medium, high, xhigh, or max: %w", provider.ErrEffortRefused)
}
return effort, nil
}
func (c *client) applyMiniMaxThinking(out *chatRequest, req provider.Request) {
if provider.IsMiniMaxFlash(c.model) {
out.Thinking = &thinkingMode{Type: "adaptive"}
out.ReasoningEffort = c.requestEffort(req)
out.ExtraBody = omitExtraBodyFields(out.ExtraBody, "thinking", "reasoning_effort", "service_tier", "reasoning_split")
if out.ExtraBody == nil {
out.ExtraBody = map[string]any{}
}
out.ExtraBody["reasoning_split"] = true
return
}
// M3 exposes only a binary thinking knob; saved older models keep that path.
mode := c.effort
if mode == "" {
mode = "adaptive"
}
out.Thinking = &thinkingMode{Type: mode}
out.ReasoningEffort = ""
}
+11 -23
View File
@@ -4,8 +4,8 @@
// code. Each instance picks the wire shape from its base URL:
// - api.deepseek.com → emits thinking.type=enabled (DeepSeek-flavor CoT) plus
// reasoning_effort as a depth hint.
// - api.minimaxi.com → emits thinking.type=adaptive|disabled (M3's binary
// knob) instead of reasoning_effort, since M3 has no level scale.
// - api.minimaxi.com / api.minimax.io → M3 exposes binary thinking;
// M3.1 Flash always thinks and accepts reasoning_effort depth.
// - open.bigmodel.cn / api.z.ai or the glm protocol (Zhipu GLM) → emits
// thinking.type; the documented depth models also accept reasoning_effort
// (provider.ZhipuEffortContract). Older GLM keeps thinking.type only.
@@ -154,17 +154,10 @@ func New(cfg provider.Config) (provider.Provider, error) {
}
}
case minimax:
// M3's knob is binary. The config effort layer normalises user input
// to "adaptive", "disabled", or "" (== auto). We keep "high"/"max"
// (legacy DeepSeek) and "low"/"medium" (Anthropic) out — config-level
// NormalizeEffort remaps them to "adaptive" already, so anything
// reaching here is expected to be one of: "", "adaptive", "disabled".
effort = strings.ToLower(strings.TrimSpace(effort))
switch effort {
case "": // auto — leave empty so the wire emits thinking.type=adaptive
case "adaptive", "disabled":
default:
return nil, fmt.Errorf("openai: provider %q uses MiniMax thinking; effort must be adaptive or disabled", name)
var err error
effort, err = resolveMiniMaxEffort(cfg, effort)
if err != nil {
return nil, err
}
case zhipu:
resolved, err := resolveZhipuEffort(name, zhipuDepth, effort)
@@ -220,6 +213,9 @@ func New(cfg provider.Config) (provider.Provider, error) {
minimax: minimax, zhipu: zhipu, longcat: longcat, ollamaCloud: ollamaCloud,
zhipuDepth: zhipuDepth,
explicit: hasExplicitEfforts, supported: supportedEfforts})
if minimax && provider.IsMiniMaxFlash(cfg.Model) {
requestEfforts = provider.MiniMaxFlashEfforts()
}
// max_output_tokens=0 means automatic (not unlimited). DeepSeek reasoning
// uses 32K / high-max 64K; thinking-disabled stays ordinary 16K. 128K is
// never automatic — users must set it explicitly after length truncations.
@@ -315,7 +311,7 @@ func (c *client) RequiresToolCallReasoning() bool {
}
func (c *client) RequiresReasoningRoundTrip() bool {
return c != nil && (c.kimiK3 || c.glmThinkingEnabled())
return c != nil && (c.minimax || c.kimiK3 || c.glmThinkingEnabled())
}
func (c *client) WarnOnMissingToolCallReasoning() bool {
@@ -770,15 +766,7 @@ func (c *client) buildRequest(req provider.Request) chatRequest {
out.Thinking = &thinkingMode{Type: "enabled"}
}
case c.minimax:
// M3 uses a single `thinking.type` field with two valid values:
// "adaptive" (default, thinking on) and "disabled" (off). Reasoning
// depth is not a knob on M3, so reasoning_effort is omitted entirely.
t := c.effort
if t == "" {
t = "adaptive" // /effort auto == the M3 model default
}
out.Thinking = &thinkingMode{Type: t}
out.ReasoningEffort = ""
c.applyMiniMaxThinking(&out, req)
case c.zhipu:
c.applyZhipuEffort(&out, req)
case c.longcat:
@@ -9,6 +9,8 @@ func (c *client) toolCallReasoning(m provider.Message) *string {
return nil
}
switch {
case c.minimax && m.ReasoningContent != "":
return &m.ReasoningContent
case c.kimiK3 && (m.ReasoningContent != "" || len(m.ToolCalls) > 0):
// Kimi K3 requires the complete assistant message on multi-turn and
// tool-call requests, including provider-issued reasoning.
+9 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"strings"
"time"
"unicode/utf8"
)
const maxEvalResult = 64 << 10
@@ -93,7 +94,14 @@ func (s *Session) Evaluate(ctx context.Context, tabID, script, approvedOrigin st
}
}
if len(value) > maxEvalResult {
value = value[:maxEvalResult] + "\n… result truncated"
// The page caps the result in UTF-16 units, so a result of non-ASCII text
// can still be longer than maxEvalResult bytes; cut it before the
// character that crosses the limit, not inside it.
cut := maxEvalResult
for cut > 0 && !utf8.RuneStart(value[cut]) {
cut--
}
value = value[:cut] + "\n… result truncated"
}
return value, t.info(true), nil
}
+26
View File
@@ -11,6 +11,7 @@ import (
"strings"
"testing"
"time"
"unicode/utf8"
)
func TestEvalStepRunsPageJavaScriptAndReturnsItsValue(t *testing.T) {
@@ -226,3 +227,28 @@ func TestEvaluateKeepsTheCallersOwnCancellationDistinctFromTheScriptLimit(t *tes
t.Fatalf("Evaluate error = %v; a stop by the caller is not the script limit", err)
}
}
func TestEvaluateTruncatesALongResultOnACharacterBoundary(t *testing.T) {
// 30,000 three-byte characters stay under the in-page limit (counted in UTF-16
// units) but exceed maxEvalResult bytes, and byte maxEvalResult falls inside a
// character.
long := strings.Repeat("测", 30000)
page := &fakePage{evalRemote: map[string]any{"type": "string", "value": long}}
s, tb := newFakeSession(t, page)
tb.url, tb.mainFrame = "https://example.com", "f1"
tb.runtime.contexts["f1"] = "context-1"
got, _, err := s.Evaluate(context.Background(), "", `"x".repeat(1)`, "https://example.com")
if err != nil {
t.Fatal(err)
}
if !utf8.ValidString(got) {
t.Fatalf("Evaluate split a character: %q", got[len(got)-40:])
}
kept, ok := strings.CutSuffix(got, "\n… result truncated")
if !ok || !strings.HasPrefix(long, kept) {
t.Fatalf("Evaluate = …%q, want a prefix of the result and the truncation note", got[len(got)-40:])
}
if len(kept) > maxEvalResult || len(kept) < maxEvalResult-3 {
t.Fatalf("Evaluate kept %d bytes, want just under %d", len(kept), maxEvalResult)
}
}
+6 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
"reasonix/internal/base/textutil"
)
@@ -58,7 +59,11 @@ func checkDriverResult(result DriverResult) error {
}
if reason != "" {
if len(reason) > 2048 {
reason = reason[:2048] + "…"
cut := 2048
for cut > 0 && !utf8.RuneStart(reason[cut]) {
cut--
}
reason = reason[:cut] + "…"
}
bounded, _ := textutil.BoundLiteral(reason, textutil.PreviewLimit{Graphemes: 2048, Lines: 1})
detail += ": " + fmt.Sprintf("%q", strings.TrimSpace(bounded))
@@ -3,6 +3,7 @@ package computer
import (
"strings"
"testing"
"unicode/utf8"
)
func TestDriverRefusalReasonIsQuotedAndBounded(t *testing.T) {
@@ -14,3 +15,15 @@ func TestDriverRefusalReasonIsQuotedAndBounded(t *testing.T) {
t.Fatalf("unsafe diagnostic of %d bytes", len(err.Error()))
}
}
func TestDriverRefusalReasonIsCutOnACharacterBoundary(t *testing.T) {
// 1,000 three-byte characters: byte 2048 falls inside the 683rd one.
reason := strings.Repeat("测", 1000)
err := checkDriverResult(DriverResult{IsError: true, Content: []DriverContent{{Type: "text", Text: reason}}})
if CodeOf(err) != CodeDriverRefused {
t.Fatal(err)
}
if msg := err.Error(); strings.ContainsRune(msg, utf8.RuneError) || !strings.Contains(msg, strings.Repeat("测", 682)+"…") {
t.Fatalf("refusal reason was not cut cleanly: %q", msg)
}
}
@@ -0,0 +1,29 @@
package recovery
import (
"encoding/json"
"strings"
"testing"
"unicode/utf8"
)
func TestArgsSummaryCutsOnRuneBoundary(t *testing.T) {
// 20 bytes into this command falls inside a three-byte character.
cmd := "echo 'a" + strings.Repeat("测试", 10) + "'"
args, _ := json.Marshal(map[string]string{"command": cmd})
got := ArgsSummary(args, 20)
if !utf8.ValidString(got) {
t.Fatalf("ArgsSummary split a character: %q", got)
}
kept := strings.TrimSuffix(got, "…")
if kept == got || !strings.HasPrefix(cmd, kept) || len(kept) > 20 || len(kept) < 18 {
t.Fatalf("ArgsSummary = %q, want a prefix of the command just under 20 bytes ending in …", got)
}
}
func TestArgsSummaryKeepsShortCommand(t *testing.T) {
args, _ := json.Marshal(map[string]string{"command": " go test ./... "})
if got := ArgsSummary(args, 20); got != "go test ./..." {
t.Fatalf("ArgsSummary = %q", got)
}
}
+6 -1
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
)
// CallFingerprint builds a one-shot authorization fingerprint from the
@@ -60,7 +61,11 @@ func ArgsSummary(args json.RawMessage, max int) string {
}
s = strings.Join(strings.Fields(s), " ")
if len(s) > max {
return s[:max] + "…"
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "…"
}
return s
}
@@ -0,0 +1,29 @@
package evidence
import (
"strings"
"testing"
"unicode/utf8"
)
func TestBoundedSegmentCutsOnRuneBoundary(t *testing.T) {
// 120 bytes into this segment falls inside a three-byte character.
segment := `python3 -c "print('` + strings.Repeat("测试", 30) + `')"`
got := boundedSegment(segment)
if !utf8.ValidString(got) {
t.Fatalf("boundedSegment split a character: %q", got)
}
kept := strings.TrimSuffix(got, "…")
if kept == got || !strings.HasPrefix(segment, kept) {
t.Fatalf("boundedSegment = %q, want a prefix of the segment ending in …", got)
}
if len(kept) > unprovenSegmentLimit || len(kept) < unprovenSegmentLimit-3 {
t.Fatalf("boundedSegment kept %d bytes, want just under %d", len(kept), unprovenSegmentLimit)
}
}
func TestBoundedSegmentKeepsShortSegment(t *testing.T) {
if got := boundedSegment(" python -c 'print(1)' "); got != "python -c 'print(1)'" {
t.Fatalf("boundedSegment = %q", got)
}
}
+6 -1
View File
@@ -3,6 +3,7 @@ package evidence
import (
"encoding/json"
"strings"
"unicode/utf8"
"reasonix/internal/base/shellparse"
)
@@ -113,7 +114,11 @@ func namedInlineNonTerminalPair(args json.RawMessage) (string, string) {
func boundedSegment(segment string) string {
segment = strings.TrimSpace(segment)
if len(segment) > unprovenSegmentLimit {
segment = segment[:unprovenSegmentLimit] + "…"
cut := unprovenSegmentLimit
for cut > 0 && !utf8.RuneStart(segment[cut]) {
cut--
}
segment = segment[:cut] + "…"
}
return segment
}
+33
View File
@@ -0,0 +1,33 @@
本版新增工作台里的 Word 和 Excel 只读预览;修复 `serve` 密码出现在进程命令行里的问题,并改进长时间等待后台任务时模型的表现和功耗。
## 新增
- 工作台可只读预览 Word(.docx)和 Excel(.xlsx),含多工作表、数字格式和合并单元格;在隔离框中显示、不运行宏和脚本,可在设置里关闭。关联 #14622。
## 修复
- 安全:`reasonix serve` 新增 `--password-file`,密码从仅所有者可读的文件读取,不再出现在进程命令行里;`--hash-password` 同样支持。修复 #14913。
- 让模型盯后台长任务(如打包)时,它不再轮询几次就写「未完成」收尾;工具结果会告诉它任务结束后会自动开一轮继续。修复 #14879。
- 视觉效果设为「节能」后,运行中的循环动画(状态标志描线、会话行流光、工具旋转图标)也会停下,长时间等待任务时不再让显卡持续工作。修复 #14919。
- 运行中发送带技能卡片的消息时,按钮和提示如实显示「排队发送」,不再写着「插话」却悄悄排队。关联 #14889。
- 重启后出现在待发送区、需要你决定的后台任务完成通知,标签写明「后台 · 状态不明」或「后台 · 受阻」,不再和你自己发的消息混在一起。关联 #14885。
- 文件大小在进位时显示为下一单位(如 1024 KB 显示为 1.0 MB)。关联 #14890。
- 浏览器脚本结果、电脑操控拒绝原因、插件启动失败摘要等处截断的中文不再出现半个字符。关联 #14891、#14894、#14907、#14908、#14911、#14912。
---
<!-- lang:en -->
## New
- The workbench previews Word (.docx) and Excel (.xlsx) read-only in an isolated frame with no macros or scripts; it can be turned off in Settings. Refs #14622.
## Fixed
- Security: `reasonix serve` takes `--password-file`, reading the password from an owner-only file instead of argv where `ps` shows it; `--hash-password` accepts it too. Fixes #14913.
- A model watching a long background job such as a build no longer gives up and reports it unfinished; it is told a new turn starts when the job ends. Fixes #14879.
- Reduced visual effects now also stop the looping run animations (status mark, session-row sweep, tool spinner), so a long wait no longer keeps the GPU busy. Fixes #14919.
- While a turn runs, a message with a skill card shows "Queue" on the button and hint instead of "Steer", since steering carries plain text only. Refs #14889.
- A background job's follow-on that needs a decision in the pending panel is labelled "Background · uncertain" or "Background · blocked", apart from your own lines. Refs #14885.
- Byte sizes move to the next unit when rounding carries over: 1024 KB shows as 1.0 MB. Refs #14890.
- Multi-byte text cut in browser results, computer refusals and plugin failure summaries no longer ends in half a character. Refs #14891, #14894, #14907, #14908, #14911, #14912.
@@ -1,2 +0,0 @@
- 修复:让模型盯后台长任务(如打包)时,它不再轮询几次就写「未完成」收尾;工具结果会告诉它任务结束后会自动开一轮继续。关联 #14879。
- Fixed: a model watching a long background job such as a build no longer gives up and reports it unfinished; it is told a new turn starts when the job ends. Refs #14879.
@@ -1,2 +0,0 @@
- 修复:重启后出现在待发送区、需要你决定的后台任务完成通知,标签写明「后台 · 状态不明」或「后台 · 受阻」,不再和你自己发的消息混在一起。关联 #14885。
- Fixed: a background job's follow-on that needs a decision in the pending panel is now labelled "Background · uncertain" or "Background · blocked", apart from your own lines. Refs #14885.
@@ -1,2 +0,0 @@
- 修复:运行中发送带技能卡片的消息时,按钮和提示如实显示「排队发送」,不再写着「插话」却悄悄排队(插话只能带纯文字)。关联 #14889。
- Fixed: while a turn runs, a message with a skill card shows "Queue" on the button and hint instead of "Steer", since steering carries plain text only. Refs #14889.
+2
View File
@@ -270,6 +270,8 @@ var mirroredWireTypesHead = []wireMirror{
{"internal/frontend/serve/providers.go", "protocolView", tsProviderFile, "Protocol"},
{"internal/frontend/serve/provider_presets.go", "providerPresetView", tsProviderFile, "ProviderPreset"},
{"internal/frontend/serve/provider_check.go", "providerCheck", tsProviderFile, "ProviderCheck"},
{"internal/model/providerhealth/check.go", "Reading", tsProviderFile, "ProviderHealth"},
{"internal/session/control/provider_health.go", "ProviderHealthSettings", tsProviderFile, "ProviderHealthSettings"},
{"internal/frontend/serve/providers.go", "providerRemovalView", tsProviderFile, "ProviderRemoval"},
{"internal/frontend/serve/provider_check.go", "providerModelCheck", tsProviderFile, "ProviderModelCheck"},
{"internal/frontend/serve/settings.go", "modelEntry", tsModelFile, "ModelEntry"},