mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge studio into dependabot/electron-45.1.0
This commit is contained in:
commit
678cf19f03
98 files changed
+3722
-283
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 # 运行分析的信号轨说明:它是什么、为什么标记不能拖(浅/深 × 宽/手机)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Generated
+1679
-91
File diff suppressed because it is too large.
Load diff
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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" /></>}
|
||||
|
||||
@@ -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"> <img src=x onerror=alert(1)></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 };
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"},
|
||||
|
||||
Reference in new issue
Block a user