MiniCPM5-2B-WebGPU-Pi-HTTP / app /src /agent.worker.mjs
Mike0021's picture
Add optional local-folder workspace with staged review and apply
e7c75bb verified
Raw
History Blame Contribute Delete
10.8 kB
import { Agent } from '@earendil-works/pi-agent-core';
import { createInference, localModel } from './inference.mjs';
import { createWorkspace, initialFiles, legacyProductsCsv, legacyProductsJson, legacyCostProductsJson, legacySalesCsv, legacyWorkspaceReadme, legacyProfitReadme } from './workspace.mjs';
import { openStorage } from './storage.mjs';
import { isModelCached } from './download.mjs';
import { LocalFolderFs, displayText } from './local-folder.mjs';
const emit = data => postMessage(data);
const inference = createInference(emit);
let workspace, agent, storage, busy = false, operation, turnCount = 0;
let demoWorkspace, demoMessages, savedLocal;
const systemPrompt = `You are Pi, a coding assistant powered by MiniCPM5-2B, a language model from OpenBMB.
The model, inference, tools, and workspace run entirely in the user's browser. Workspace files are stored in this browser, not on the user's operating-system filesystem.
Use your tools to inspect and change actual files. Never claim a command ran or a file changed without a successful tool result.
When the user explicitly asks you to use a tool, call that tool even if you already know the answer.
Working directory: /workspace. Use read, write, edit, and bash. Keep tool calls and answers concise.
The bash tool supports cat, ls, find, grep, sed, awk, jq, sort, wc, printf, pipes, redirects, and shell scripts.
Use jq for JSON and awk for CSV and calculations; python, python3, node, npm, and native binaries are unavailable.
Use curl -fsS for requests to any HTTPS URL; there is no host allowlist. To save a response, use curl -fsS URL -o file.json, then inspect it with jq or read. For pipelines use set -o pipefail so failed downloads are not mistaken for success. CORS and redirect errors are browser limitations: report them accurately; do not invent fetched content or repeatedly retry blocked requests.
You can create and edit text files, including HTML, CSS, JavaScript, and shell scripts. External URLs may be referenced without fetching them. Only claim an asset was fetched or tested after a successful tool result. HTTP requests send the requested URL and any explicit request data to that external server; model inference stays local.
Interpret short follow-ups using the conversation and inspect the relevant files when needed.
Inspect files before editing them. After changing files, verify the result with a tool. Finish with a short factual answer.`;
const localPrompt = systemPrompt.replace("Workspace files are stored in this browser, not on the user's operating-system filesystem.",
'The user selected a real local folder mounted at /workspace. Reads use that folder; all writes, edits and deletions are staged in this browser. Only the user can apply staged changes to disk with Review & apply. Say edits are staged, never claim they are saved to disk. Files outside /workspace are temporary virtual files. Files are limited to 1 MiB, 16 MiB total loaded; .git, node_modules, .venv and __pycache__ are excluded. Unix permissions, links and renaming/removing existing directories are unsupported.');
async function snapshot() { return { version: 1, demoVersion: 4, files: await workspace.snapshot(), entries: await workspace.serialize(), messages: agent.state.messages,
localFolder: workspace.localFolder ? await workspace.localFolder.status() : null }; }
async function persist() {
const state = await snapshot();
try {
if (workspace.localFolder) {
savedLocal = { handle: workspace.localFolder.handle, patches: await workspace.localFolder.patches(), messages: state.messages };
await storage?.save(savedLocal, 'local-folder');
} else await storage?.save(state);
} catch (e) { emit({ type: 'persistence_error', error: 'Changes are in memory, but browser storage failed: ' + e.message }); }
emit({ type: 'workspace', files: state.files, localFolder: state.localFolder });
return state;
}
function switchWorkspace(next, messages = []) {
workspace = next;
agent.reset(); agent.state.messages = messages; agent.state.tools = next.tools;
agent.state.systemPrompt = next.localFolder ? localPrompt : systemPrompt;
}
const initialized = (async () => {
let saved;
try { storage = await openStorage(); saved = await storage.load(); savedLocal = await storage.load('local-folder'); }
catch (e) { emit({ type: 'persistence_error', error: 'Workspace saving is unavailable: ' + e.message }); }
workspace = createWorkspace(saved?.files, saved?.entries);
await workspace.ready;
const upgradeDemo = saved && (saved.demoVersion ?? 0) < 4;
if (upgradeDemo) {
// Move prices and quantities together only for a recognized untouched demo.
// Never remove a price column if its companion file was edited or deleted.
const paths = workspace.bash.fs.getAllPaths();
const csv = '/workspace/products.csv', json = '/workspace/products.json', sales = '/workspace/sales.csv';
const untouched = async (file, versions) => paths.includes(file) && (await workspace.bash.fs.lstat(file)).isFile && versions.includes(await workspace.read(file));
if (await untouched(sales, [legacySalesCsv])) {
const oldJson = await untouched(json, [legacyProductsJson, legacyCostProductsJson]);
const oldCsv = !paths.includes(json) && await untouched(csv, [legacyProductsCsv]);
const originalDemo = (saved.demoVersion ?? 0) < 1 && !paths.includes(json) && !paths.includes(csv);
if (oldJson || oldCsv || originalDemo) {
await workspace.write(sales, initialFiles[sales]);
await workspace.write(json, initialFiles[json]);
if (oldCsv) await workspace.bash.fs.rm(csv);
}
}
const readme = '/workspace/README.md';
if (await untouched(readme, [legacyWorkspaceReadme, legacyProfitReadme])) {
await workspace.write(readme, initialFiles[readme]);
}
}
agent = new Agent({ initialState: { model: localModel, thinkingLevel: 'medium', systemPrompt, tools: workspace.tools, messages: saved?.messages ?? [] },
streamFn: inference.streamFn, toolExecution: 'sequential',
afterToolCall: ({ result, isError }) => ({ isError: isError || (result.details?.exitCode !== undefined && result.details.exitCode !== 0) }),
shouldStopAfterTurn: ({ toolResults }) => {
const stop = ++turnCount >= 12 && toolResults.length > 0;
if (stop) emit({ type: 'notice', text: 'Paused after 12 agent turns. Send a follow-up to continue.' });
return stop;
},
});
agent.subscribe(async event => {
emit({ type: 'agent_event', event });
if (event.type === 'tool_execution_end' || event.type === 'agent_end') await persist();
});
const state = await snapshot();
if (upgradeDemo) {
try { await storage?.save(state); }
catch (e) { emit({ type: 'persistence_error', error: 'Changes are in memory, but browser storage failed: ' + e.message }); }
}
emit({ type: 'initialized', ...state, savedLocal: savedLocal ? { handle: savedLocal.handle, count: savedLocal.patches.length } : null });
})();
onmessage = async ({ data }) => {
const { id, action, args = {} } = data;
const reply = value => emit({ type: 'reply', id, value });
try {
await initialized;
if (action === 'stop') { operation?.abort(); agent.abort(); inference.stop(); return reply(true); }
if (action === 'snapshot') return reply(await snapshot());
if (action === 'cache_status') return reply(await isModelCached());
if (action === 'preview') return reply(workspace.localFolder
? displayText(await workspace.bash.fs.readFileBuffer(args.path)) : await workspace.read(args.path));
if (busy) throw Error('Wait for the current operation or stop it first.');
busy = true; operation = new AbortController(); emit({ type: 'busy', action, cachedOnly: args.cachedOnly === true });
try {
let value;
if (action === 'load') { await inference.load(args, operation.signal); value = inference.device; }
else if (action === 'prompt') {
turnCount = 0; await agent.prompt(args.text); value = await snapshot();
} else if (action === 'shell') {
value = await workspace.bash.exec(args.command, { signal: operation.signal }); await persist();
} else if (action === 'write') { await workspace.write(args.path, args.content); value = await persist(); }
else if (action === 'new_chat') { agent.reset(); value = await persist(); }
else if (action === 'read') value = await workspace.read(args.path);
else if (action === 'local_open') {
if (workspace.localFolder) throw Error('Return to the demo before opening another local folder.');
const restore = args.restore && savedLocal && await args.handle.isSameEntry(savedLocal.handle);
const folder = await LocalFolderFs.open(args.handle, { signal: operation.signal, patches: restore ? savedLocal.patches : [] });
const next = createWorkspace({}, undefined, folder); await next.ready;
await persist(); demoWorkspace = workspace; demoMessages = agent.state.messages;
switchWorkspace(next, restore ? savedLocal.messages : []);
value = await persist();
} else if (action === 'local_close') {
if (!workspace.localFolder) throw Error('No local folder is open.');
await persist(); switchWorkspace(demoWorkspace, demoMessages);
demoWorkspace = demoMessages = undefined;
value = await persist();
} else if (action === 'local_changes') {
if (!workspace.localFolder) throw Error('No local folder is open.');
value = await workspace.localFolder.patches();
} else if (action === 'local_apply') {
if (!workspace.localFolder) throw Error('No local folder is open.');
try { value = { applied: await workspace.localFolder.apply(operation.signal) }; }
finally { await persist(); }
} else if (action === 'local_refresh' || action === 'local_discard') {
const folder = workspace.localFolder;
if (!folder) throw Error('No local folder is open.');
if (action === 'local_refresh' && (await folder.patches()).length) throw Error('Apply or discard staged edits before refreshing.');
const fresh = await LocalFolderFs.open(folder.handle, { signal: operation.signal });
const next = createWorkspace({}, undefined, fresh); await next.ready;
// A fresh conversation avoids stale tool results after external changes.
switchWorkspace(next); value = await persist();
}
else throw Error('Unknown action: ' + action);
reply(value);
} finally { busy = false; operation = undefined; emit({ type: 'idle' }); }
} catch (error) { emit({ type: 'reply', id, error: error?.name === 'AbortError' ? 'Stopped.' : String(error?.message ?? error), errorName: error?.name, errorCode: error?.code }); }
};