Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions packages/webui/server/lib/mcode-acp.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,137 @@ import { loadSessions, saveSessions } from "./sessions.js";
// If cs.permissions is set to anything other than "Full access", the
// acp protocol layer does not expose permission push — fall back to
// mcode-exec (which honours --permission ask/full/auto/off).

/**
* Push the recorded pre-session model pick to a brand-new engine session.
*
* Called from `runMcodeAcp` immediately after `session/new` returns, while
* the new `McodeAcpClient` is still in scope but not yet registered as the
* cid's active child (so going through `setConfigOption` in
* `server/lib/mcode-rpc.js` would miss the dispatch — `clientForCid`
* would fall back to the singleton, which is a different acp subprocess).
*
* Resolution: `cs.model.name` carries the id the user picked, which can be
* - the engine's own option.value (`minimax_api:MiniMax-M3`,
* `:` separator) — direct match, no rewrite;
* - the builtin-catalogue form (`minimax_api/MiniMax-M3`,
* `/` separator) — matched against option.name, retargeted to
* option.value;
* - a stale engine-encoded form for an option no longer listed
* (`currentValue` already advanced) — no change, the engine's
* `currentValue` is what runs.
*
* A successful apply updates `cs.configOptions` with the new currentValue
* so the next `/api/models` reads the same model the engine is running.
*
* Errors are swallowed: a fresh session with the engine's default is
* better than a failed session start; the user can re-pick on the chip.
*/
async function applyRecordedModel(client, sid, cs, cid) {
const recorded = cs && cs.model && typeof cs.model.name === "string"
? cs.model.name.trim()
: "";
if (!recorded) return;
const modelOption = findModelOption(cs);
if (!modelOption) return; // engine hasn't reported its model option yet
const engineCurrent = modelOption.currentValue;
if (matchesModelId(recorded, engineCurrent, modelOption)) return;

const resolved = resolveModelId(recorded, modelOption);
if (!resolved) {
console.warn(
`[webui] applyRecordedModel: recorded id "${recorded}" does not match any engine option; skipping`,
);
return;
}
await client.request("session/set_config_option", {
sessionId: sid,
configId: "model",
value: resolved,
});
// Reflect the apply on the local config-options snapshot so a follow-up
// /api/models reads the engine's new currentValue instead of the
// session-boot default. The engine pushes a `config_option_update`
// notification when it processes the apply; this local update is the
// synchronous mirror that keeps the chip and the engine in lockstep
// before the next SSE flush lands.
const opts = Array.isArray(cs.configOptions) ? cs.configOptions : [];
for (const o of opts) {
if (o && o.id === "model" && typeof o === "object") {
o.currentValue = resolved;
}
}
if (cid) pushStateFor(cid);
}

/** Locate the engine's `model` config option, or null if none was reported yet. */
function findModelOption(cs) {
if (!cs || !Array.isArray(cs.configOptions)) return null;
return cs.configOptions.find((o) => o && o.id === "model") || null;
}

/**
* True when `recorded` already represents what the engine is running.
*
* Two ways to match: engine-encoded `option.value` (exact), or the bare
* model name (`option.name`) regardless of provider prefix. The latter
* covers the case where the chip recorded `minimax_api/MiniMax-M3`
* (builtin-catalogue form) while the engine's `currentValue` is
* `minimax_api:MiniMax-M3` (engine form).
*/
function matchesModelId(recorded, engineCurrent, modelOption) {
if (typeof engineCurrent === "string" && engineCurrent === recorded) return true;
if (!modelOption || !Array.isArray(modelOption.options)) return false;
for (const opt of modelOption.options) {
if (!opt || typeof opt !== "object") continue;
if (typeof opt.value === "string" && opt.value === recorded) return true;
}
return false;
}

/**
* Resolve the recorded id to one of the engine's option.values.
*
* - exact `option.value` match → return as-is;
* - bare model name (`MiniMax-M3`, the `option.name` or the suffix
* after the last separator in the recorded id) matching exactly one
* option → return that option's `value`;
* - multiple matches or none → null (caller skips).
*/
function resolveModelId(recorded, modelOption) {
if (!modelOption || !Array.isArray(modelOption.options)) return null;
const options = modelOption.options.filter(
(o) => o && typeof o === "object" && typeof o.value === "string",
);
// Direct value match wins.
for (const o of options) {
if (o.value === recorded) return o.value;
}
const bareName = lastSegment(recorded);
const matches = options.filter((o) => o.name === bareName);
if (matches.length === 1) return matches[0].value;
return null;
}

/** Last segment after `/` or `:` — `minimax_api/MiniMax-M3` → `MiniMax-M3`. */
function lastSegment(id) {
const i = Math.max(id.lastIndexOf("/"), id.lastIndexOf(":"));
return i >= 0 ? id.slice(i + 1) : id;
}

// Exported for unit tests (test/lib/mcode-acp-note.test.js extends to
// cover applyRecordedModel's resolution logic). The pre-session model
// apply needs to handle three input forms without regressing, so the
// pure helpers are tested in isolation; the integration with the
// `McodeAcpClient` is exercised by `runMcodeAcp` itself.
export {
applyRecordedModel,
findModelOption,
matchesModelId,
resolveModelId,
lastSegment,
};

export async function runMcodeAcp(content, opts = {}) {
const label = opts.label || "prompt";
const existingSid = opts.sessionId || null;
Expand Down Expand Up @@ -83,6 +214,23 @@ export async function runMcodeAcp(content, opts = {}) {
if (control && Array.isArray(control.configOptions)) {
cs.configOptions = control.configOptions;
}
// Pre-session model pick — the chip shows whatever the user picked
// (`cs.model.name`), but a brand-new engine session boots its own
// default. Without this apply step the engine would run on its default
// while the chip claimed something else, surfacing as "engine ran
// glm-5.3 while the chip showed M2.5". We resolve the recorded id
// against the engine's model option (same `value`/`name` matching the
// `/api/set-model` route uses) and push it through
// `session/set_config_option` directly on the in-scope client — the
// active-child registry is not yet wired here, so going through
// `setConfigOption` from `mcode-rpc.js` would always miss.
if (sid && !existingSid) {
try {
await applyRecordedModel(client, sid, cs, cid);
} catch (e) {
console.warn(`[webui] applyRecordedModel: ${e.message}`);
}
}
// qa (两条记录): 草稿→引擎身份的绑定在 session 创建时立即执行,不再
// 等到 finalize。之前长任务全程草稿是 uuid 孤儿 —— sidebar 同时显示
// uuid 草稿和 mvs_ 引擎条目两条;此时点 mvs_ 条目会走 new_from_mcode
Expand Down
109 changes: 97 additions & 12 deletions packages/webui/server/lib/models.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,114 @@
// webui/server/lib/models.js
// Context-window fallback for the webui's own display.
// Builtin-model extraction from mcode's cli.js bundle, plus a context-window
// fallback used by the webui's own display until the engine reports a real
// limit on `usage_update`.

// Used only until a session has reported its real size: the engine sends
// `usage_update` with `size` = contextWindowTokens (packages/tui/src/acp/
// control-state.ts#usageUpdate), which the running session prefers.
// Fallback only: the engine does not report a limit until a session exists, and
// these values are what mcode itself ships for the same models.
// Input 'minimax_api/MiniMax-M3' or 'MiniMax-M3'; output 512000 / 200000 / 0.
import { dirname, join } from "node:path";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { MCODE_CMD, PACKAGE_ROOT } from "./config.js";

// v0.5.bj: extract the hardcoded MiniMax-M* list from mcode's cli.js bundle
// at startup. The TUI surfaces the same list as its `/models` candidates, so
// the webui catalogue should follow mcode itself rather than carry its own
// hardcoded copy — a future mcode release that adds or renames a model would
// otherwise leave the webui out of sync until the next webui release.
let CACHED_BUILTIN_MODELS = null;

function findCliEntry() {
// v2.1: MCODE_CMD resolves to the repo-built CLI entry (dist/cli.js) —
// read that bundle directly. Legacy plugin layouts shipped a mcode.ps1
// shim whose cliEntry line pointed at the sibling cli.js.
if (/\.(js|mjs)$/i.test(MCODE_CMD) && existsSync(MCODE_CMD)) return MCODE_CMD;
const ps1Dir = /[\\/]/.test(MCODE_CMD) ? dirname(MCODE_CMD) : PACKAGE_ROOT;
const mcodePs1 = join(ps1Dir, "mcode.ps1");
if (!existsSync(mcodePs1)) return null;
try {
const ps1 = readFileSync(mcodePs1, "utf-8");
const m = ps1.match(/cliEntry\s*=\s*Join-Path\s+\$basedir\s+"([^"]+)"/);
if (m) return join(ps1Dir, m[1]);
} catch {}
return null;
}

/**
* Read the hardcoded MiniMax-M* list from mcode's own cli.js bundle.
*
* mcode's TUI /models surfaces this list verbatim; the webui catalogue
* merges it with the engine session's `model` config option (when one
* exists) and an optional `models.json` providers config. Extracting
* rather than hardcoding means a future mcode release lands its new
* model ids in the webui catalogue without a coordinated webui bump.
*
* Scans the entry plus every sibling chunk (bounded) so the build's
* code-split layout — `dist/cli.js` is now a thin loader — does not
* hide the model list in `dist/chunks/*.js`.
*
* Returns `[]` when the bundle is missing or unreadable: this is a
* convenience fallback, not a hard requirement.
*/
export function getBuiltinModelsFromMcode() {
if (CACHED_BUILTIN_MODELS !== null) return CACHED_BUILTIN_MODELS;
try {
let cliEntry = findCliEntry();
if (!cliEntry || !existsSync(cliEntry)) {
CACHED_BUILTIN_MODELS = [];
return [];
}
const re = /MiniMax-M[0-9][a-z0-9.-]*/g;
const found = new Set();
const collect = (content) => {
let m;
while ((m = re.exec(content)) !== null) found.add(m[0]);
};
collect(readFileSync(cliEntry, "utf-8"));
const chunksDir = join(dirname(cliEntry), "chunks");
let budget = 48 * 1024 * 1024; // scan cap; one chunk at >48MB is suspicious
if (existsSync(chunksDir)) {
for (const name of readdirSync(chunksDir)) {
if (!name.endsWith(".js")) continue;
const p = join(chunksDir, name);
let stat;
try {
stat = statSync(p);
} catch {
continue;
}
if (stat.size > budget) continue;
budget -= stat.size;
try {
collect(readFileSync(p, "utf-8"));
} catch {}
}
}
// M3 first so the most capable model lands at the top of any UI list
// built from this catalogue.
CACHED_BUILTIN_MODELS = [...found].sort().reverse();
return CACHED_BUILTIN_MODELS;
} catch {
CACHED_BUILTIN_MODELS = [];
return [];
}
}

// v0.5.bx-10: real context limits — extracted from mcode's cli.js bundle.
// Source: cli.js `h7i={"MiniMax-M3":{limit:{context:512e3,...}}, ...}`
// Input: 'minimax_api/MiniMax-M3' or 'MiniMax-M3'. Output: 512000 /
// 200000 / 0 (unknown). Used as a fallback only: the engine reports a
// real `usage_update.size` once a session exists, which the running
// session prefers over this table.
const MCODE_MODEL_LIMITS = {
"MiniMax-M3": 512000,
"MiniMax-M2.7": 200000,
"MiniMax-M2.7-highspeed": 200000,
// 兜底: 128k, 200k, 512k 几个常见值
};
export function getMcodeModelLimit(modelFullName) {
if (!modelFullName) return 0;
// 'minimax_api/MiniMax-M3' → 'MiniMax-M3'
const short = modelFullName.includes("/")
? modelFullName.split("/").pop()
: modelFullName;
if (MCODE_MODEL_LIMITS[short]) return MCODE_MODEL_LIMITS[short];
// 模糊匹配: MiniMax-M2.7-highspeed 应该匹配 M2.7 的 200k
for (const k of Object.keys(MCODE_MODEL_LIMITS)) {
if (short.startsWith(k) || k.startsWith(short))
return MCODE_MODEL_LIMITS[k];
if (short.startsWith(k) || k.startsWith(short)) return MCODE_MODEL_LIMITS[k];
}
return 0;
}
12 changes: 11 additions & 1 deletion packages/webui/server/lib/workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,17 @@ export function browseWorkspace(rawPath) {
}
try {
if (ent.isDirectory()) {
dirs.push({ name: ent.name, path: join(target, ent.name) });
dirs.push({
name: ent.name,
path: join(target, ent.name),
// browseWorkspace only enumerates directories (the filter
// above) — files are deliberately omitted so the picker
// navigates workspaces rather than reads them. `isDir: true`
// is therefore a constant on every emitted child, but it is
// set explicitly so the wire shape and the BrowseEntry type
// agree without a client-side assumption.
isDir: true,
});
}
} catch {
skipped++;
Expand Down
Loading
Loading