Compare commits
17 Commits
0815d64802
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f43e0af918 | ||
|
|
46c9fe9fac | ||
|
|
eb3f58b2e6 | ||
|
|
6e8a336143 | ||
|
|
839cea7fe6 | ||
|
|
131a45e305 | ||
|
|
7fb261a3b7 | ||
|
|
7ad5d10378 | ||
|
|
3c6bf7184c | ||
|
|
9974a78394 | ||
|
|
421b47355b | ||
|
|
657b6af993 | ||
|
|
df85e5e603 | ||
|
|
b49c798fc7 | ||
|
|
83ac8709b7 | ||
|
|
58d8c352f3 | ||
|
|
14305859de |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node"
|
||||
"image": "devpit:latest"
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
.env
|
||||
tmp
|
||||
tmp
|
||||
root
|
||||
17
cli.js
17
cli.js
@@ -10,6 +10,16 @@ import { promises as fs, unwatchFile } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
if (!Array.fromAsync) {
|
||||
Array.fromAsync = async function fromAsync(asyncIterable) {
|
||||
const array = [];
|
||||
for await (const item of asyncIterable) {
|
||||
array.push(item);
|
||||
}
|
||||
return array;
|
||||
};
|
||||
}
|
||||
|
||||
function renderUsage(usage) {
|
||||
const inputTokens = usage.input_tokens - usage.input_tokens_details.cached_tokens;
|
||||
const cacheTokens = usage.input_tokens_details.cached_tokens;
|
||||
@@ -74,6 +84,7 @@ while(true){
|
||||
// Block for user input before kicking off the LLM loop
|
||||
const userText = await askUserForInput();
|
||||
await streamOnce(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }), userText );
|
||||
//await streamOnce(new OpenAI({ baseURL: "https://api.cerebras.ai/v1",apiKey: "csk-8jftdte6r6vf8fdvp9xkyek5t3jnc6jfhh93d3ewfcwxxvh9" }), userText );
|
||||
async function streamOnce(openai, userText) {
|
||||
const toolsByFile = await loadTools();
|
||||
|
||||
@@ -96,10 +107,10 @@ websearch - eine Google Suche machen mit Schlüsselwörtern
|
||||
do{
|
||||
|
||||
const call = {
|
||||
model: 'gpt-5-mini',
|
||||
model: 'gpt-4.1-nano',
|
||||
input: counter == 0 ? [systemprompt,...structuredClone(input)] : structuredClone(input),
|
||||
text: { format: { type: 'text' }, verbosity: 'low' },
|
||||
reasoning: { effort: 'minimal', summary: 'detailed' },
|
||||
text: { format: { type: 'text' }/*, verbosity: 'low' */},
|
||||
//reasoning: { effort: 'minimal', summary: 'detailed' },
|
||||
tools: Object.values(toolsByFile).map(t => t.def),
|
||||
store: true,
|
||||
}
|
||||
|
||||
109
cli2.js
Normal file
109
cli2.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import ModelDialog from './modelDialog.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const modelDialog = new ModelDialog({model: 'gpt-5-mini'});
|
||||
|
||||
modelDialog.on('outputUpdate', (output) => {
|
||||
//console.log(chalk.blue('output event'),output);
|
||||
});
|
||||
modelDialog.on('reasoningUpdate', (output) => {
|
||||
//console.log(chalk.blue('reasoning event'),output);
|
||||
});
|
||||
|
||||
// $ / 1million tokens
|
||||
const price = {
|
||||
'gpt-5-2025-08-07': {
|
||||
input: 1.25,
|
||||
cached: 0.125,
|
||||
output: 10
|
||||
},
|
||||
'gpt-5-mini-2025-08-07': {
|
||||
input: 0.25,
|
||||
cached: 0.025,
|
||||
output: 2
|
||||
},
|
||||
'gpt-5-nano-2025-08-07': {
|
||||
input: 0.05,
|
||||
cached: 0.005,
|
||||
output: 0.4
|
||||
},
|
||||
'gpt-4.1-2025-04-14': {
|
||||
input: 2,
|
||||
cached: 0.5,
|
||||
output: 8
|
||||
},
|
||||
'gpt-4.1-mini-2025-04-14': {
|
||||
input: 0.4,
|
||||
cached: 0.1,
|
||||
output: 1.6
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
(async ()=>{
|
||||
//const output = await modelDialog.interrogate('Can you remember "seven" ?');
|
||||
//console.log(output.output,JSON.stringify(output.reasoning,null,2));
|
||||
//const output2 = await modelDialog.interrogate('read a file that is what you remebered plus 1 as a word with txt ending, check that file.');
|
||||
|
||||
|
||||
|
||||
const output2 = await modelDialog.interrogate('schau dich mal um und wenn du html dateien findest, dann invertiere den gradient.');
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
console.log('final output:',output2.output);
|
||||
console.log('reasoning:',output2.reasoning);
|
||||
//Ti: { 'gpt-5-2025-08-07': 3019 } Tc: { 'gpt-5-2025-08-07': 0 } To: { 'gpt-5-2025-08-07': 751 }
|
||||
console.log('Ti:',output2.inputTokens,'Tc:',output2.cachedTokens,'To:',output2.outputTokens);
|
||||
// cost breakdown per model and totals (prices are per 1M tokens)
|
||||
const perMillion = 1_000_000;
|
||||
const models = new Set([
|
||||
...Object.keys(output2.inputTokens || {}),
|
||||
...Object.keys(output2.cachedTokens || {}),
|
||||
...Object.keys(output2.outputTokens || {})
|
||||
]);
|
||||
|
||||
let grandTotal = 0;
|
||||
for (const model of models) {
|
||||
const inputT = (output2.inputTokens || {})[model];
|
||||
const cachedT = (output2.cachedTokens || {})[model];
|
||||
const outputT = (output2.outputTokens || {})[model];
|
||||
|
||||
const p = price[model];
|
||||
const inputCost = (typeof inputT === 'number' && p) ? (inputT / perMillion) * p.input : undefined;
|
||||
const cachedCost = (typeof cachedT === 'number' && p) ? (cachedT / perMillion) * p.cached : undefined;
|
||||
const outputCost = (typeof outputT === 'number' && p) ? (outputT / perMillion) * p.output : undefined;
|
||||
|
||||
const subtotal = [inputCost, cachedCost, outputCost].every(v => typeof v === 'number')
|
||||
? (inputCost + cachedCost + outputCost)
|
||||
: undefined;
|
||||
|
||||
if (typeof subtotal === 'number') grandTotal += subtotal;
|
||||
|
||||
console.log('cost for', model, {
|
||||
inputCost: parseFloat(inputCost.toFixed(6)),
|
||||
cachedCost: parseFloat(cachedCost.toFixed(6)),
|
||||
outputCost: parseFloat(outputCost.toFixed(6)),
|
||||
subtotal: parseFloat(subtotal.toFixed(4))
|
||||
});
|
||||
}
|
||||
|
||||
//console.log('total cost:', grandTotal);
|
||||
})()
|
||||
132
index.html
Normal file
132
index.html
Normal file
@@ -0,0 +1,132 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Pelikan fährt Rad – SVG</title>
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; background:#0e1320; color:#e7ecf3; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; }
|
||||
.wrap { display:grid; place-items:center; height:100%; }
|
||||
.cap { position: fixed; bottom: 0.75rem; left: 0; right: 0; text-align:center; font-size: 0.9rem; opacity: .8; }
|
||||
svg { width: min(92vw, 960px); height: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<!-- Rein vektoriell gezeichneter Pelikan auf einem Fahrrad. -->
|
||||
<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d">
|
||||
<title id="t">Pelikan fährt Fahrrad</title>
|
||||
<desc id="d">Ein stilisierter Pelikan mit großem Schnabel fährt ein Fahrrad mit zwei Rädern; einfache Formen, klare Farben.</desc>
|
||||
|
||||
<!-- Hintergrund-Deko -->
|
||||
<defs>
|
||||
<radialGradient id="sky" cx="50%" cy="40%" r="70%">
|
||||
<stop offset="0%" stop-color="#1b2a4a" />
|
||||
<stop offset="100%" stop-color="#0e1320" />
|
||||
</radialGradient>
|
||||
<linearGradient id="beakGrad" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stop-color="#ffb347"/>
|
||||
<stop offset="100%" stop-color="#ff7f11"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="frameGrad" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stop-color="#4bd5ff"/>
|
||||
<stop offset="100%" stop-color="#6a5cff"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#sky)"/>
|
||||
|
||||
<!-- Bodenlinie -->
|
||||
<g opacity="0.35">
|
||||
<ellipse cx="400" cy="520" rx="300" ry="40" fill="#000" />
|
||||
</g>
|
||||
|
||||
<!-- Fahrrad -->
|
||||
<g id="bike" transform="translate(0,0)" filter="url(#shadow)">
|
||||
<!-- Räder -->
|
||||
<g id="wheels" stroke="#cfe7ff" stroke-width="6" fill="none">
|
||||
<circle cx="260" cy="460" r="90" />
|
||||
<circle cx="540" cy="460" r="90" />
|
||||
<!-- Speichen -->
|
||||
<g stroke="#9fd0ff" stroke-width="3" opacity="0.6">
|
||||
<line x1="260" y1="370" x2="260" y2="550" />
|
||||
<line x1="170" y1="460" x2="350" y2="460" />
|
||||
<line x1="200" y1="400" x2="320" y2="520" />
|
||||
<line x1="200" y1="520" x2="320" y2="400" />
|
||||
<line x1="540" y1="370" x2="540" y2="550" />
|
||||
<line x1="450" y1="460" x2="630" y2="460" />
|
||||
<line x1="480" y1="400" x2="600" y2="520" />
|
||||
<line x1="480" y1="520" x2="600" y2="400" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Rahmen -->
|
||||
<g id="frame" stroke="url(#frameGrad)" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<polyline points="260,460 360,420 450,460 540,460" />
|
||||
<line x1="360" y1="420" x2="410" y2="360" />
|
||||
<line x1="410" y1="360" x2="520" y2="360" />
|
||||
<line x1="520" y1="360" x2="540" y2="460" />
|
||||
</g>
|
||||
|
||||
<!-- Kurbel und Pedale -->
|
||||
<g id="crank" transform="translate(405,440)">
|
||||
<circle r="16" fill="#b7d8ff" />
|
||||
<g stroke="#b7d8ff" stroke-width="6" stroke-linecap="round">
|
||||
<line x1="0" y1="0" x2="28" y2="-28" />
|
||||
</g>
|
||||
<circle cx="28" cy="-28" r="8" fill="#e5f3ff" />
|
||||
</g>
|
||||
|
||||
<!-- Lenker und Sattel -->
|
||||
<g stroke="#cfe7ff" stroke-width="10" stroke-linecap="round" fill="none">
|
||||
<path d="M520 360 C 555 335, 585 345, 600 360"/>
|
||||
</g>
|
||||
<rect x="325" y="330" width="70" height="16" rx="8" fill="#cfe7ff" />
|
||||
</g>
|
||||
|
||||
<!-- Pelikan -->
|
||||
<g id="pelican" transform="translate(0,0)">
|
||||
<!-- Körper -->
|
||||
<ellipse cx="360" cy="360" rx="90" ry="70" fill="#f2f6fb" stroke="#e1e8f5" stroke-width="4" />
|
||||
<!-- Flügel -->
|
||||
<path d="M330 345 C 275 350, 250 395, 270 430 C 295 470, 355 470, 390 440 C 410 420, 400 380, 370 360 Z" fill="#eaf2fb" stroke="#d7e4f6" stroke-width="3" />
|
||||
<!-- Hals -->
|
||||
<path d="M400 335 C 445 320, 470 300, 500 295 C 520 292, 540 305, 540 325 C 540 345, 520 360, 500 360 C 470 360, 445 350, 415 355" fill="none" stroke="#f2f6fb" stroke-width="20" stroke-linecap="round" />
|
||||
<!-- Kopf -->
|
||||
<circle cx="520" cy="325" r="28" fill="#f7fbff" stroke="#e1e8f5" stroke-width="3" />
|
||||
<!-- Auge -->
|
||||
<circle cx="528" cy="322" r="5" fill="#21314f" />
|
||||
<circle cx="526" cy="320" r="2" fill="#ffffff" />
|
||||
<!-- Schnabel -->
|
||||
<path d="M535 330 C 585 330, 630 338, 675 352 C 640 360, 600 368, 555 368 C 545 360, 538 348, 535 330 Z" fill="url(#beakGrad)" stroke="#ff9a2a" stroke-width="3" />
|
||||
<!-- Kehlsack angedeutet -->
|
||||
<path d="M540 338 C 585 345, 590 352, 552 365" fill="none" stroke="#ffb347" stroke-width="4" opacity="0.7" />
|
||||
<!-- Beine -->
|
||||
<g stroke="#ffb347" stroke-width="10" stroke-linecap="round">
|
||||
<path d="M340 420 L 320 455" />
|
||||
<path d="M380 420 L 390 450" />
|
||||
</g>
|
||||
<!-- Füße auf Pedalen -->
|
||||
<g fill="#ffb347">
|
||||
<circle cx="320" cy="455" r="8" />
|
||||
<circle cx="390" cy="450" r="8" />
|
||||
</g>
|
||||
<!-- Schwanz -->
|
||||
<path d="M280 365 C 255 360, 240 350, 230 340 C 248 368, 260 390, 290 392" fill="#eaf2fb" stroke="#d7e4f6" stroke-width="3" />
|
||||
</g>
|
||||
|
||||
<!-- Sterne / Deko -->
|
||||
<g fill="#9fd0ff" opacity="0.8">
|
||||
<circle cx="90" cy="80" r="2"/>
|
||||
<circle cx="160" cy="120" r="1.5"/>
|
||||
<circle cx="720" cy="90" r="2.2"/>
|
||||
<circle cx="650" cy="160" r="1.8"/>
|
||||
<circle cx="120" cy="200" r="1.6"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="cap">SVG Illustration: Pelikan auf Fahrrad. Datei lokal öffnen oder auf einen Webserver legen.</div>
|
||||
</body>
|
||||
</html>
|
||||
198
modelDialog.js
Normal file
198
modelDialog.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import OpenAI from 'openai';
|
||||
import 'dotenv/config';
|
||||
import EventEmitter from 'events';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import chalk from 'chalk';
|
||||
|
||||
async function loadTools() {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const toolsDir = path.join(__dirname, "tools");
|
||||
const dirents = await fs.readdir(toolsDir, { withFileTypes: true });
|
||||
const toolEntries = await Promise.all(
|
||||
dirents
|
||||
.filter((dirent) => dirent.isFile() && dirent.name.endsWith(".js"))
|
||||
.map(async (dirent) => {
|
||||
const fileName = dirent.name.replace(/\.js$/, "");
|
||||
const module = await import(`file://${path.join(toolsDir, dirent.name)}`);
|
||||
return [fileName, { def: module.default, run: module.run }];
|
||||
})
|
||||
);
|
||||
return Object.fromEntries(toolEntries);
|
||||
}
|
||||
const toolsByFile = await loadTools();
|
||||
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
|
||||
const systemprompt = {"role": "developer", "content": [ { "type": "input_text","text":
|
||||
|
||||
`You are a helpful assistant.`
|
||||
|
||||
}]};
|
||||
|
||||
if (!Array.fromAsync) {
|
||||
Array.fromAsync = async function fromAsync(asyncIterable) {
|
||||
const array = [];
|
||||
for await (const item of asyncIterable) {
|
||||
array.push(item);
|
||||
}
|
||||
return array;
|
||||
};
|
||||
}
|
||||
|
||||
class ModelDialog {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.messages = [systemprompt];
|
||||
this.messagesSent = [];
|
||||
this.isActive = false;
|
||||
this.currentStream = null;
|
||||
this.previousResponseId = null;
|
||||
this.emitter = new EventEmitter();
|
||||
this.inputTokens = {};
|
||||
this.outputTokens = {};
|
||||
this.cachedTokens = {};
|
||||
this.lastDebouncedUpdate = 0;
|
||||
};
|
||||
|
||||
handleUsage = (usage, model) => {
|
||||
if (typeof this.inputTokens[model] !== 'number') this.inputTokens[model] = 0;
|
||||
if (typeof this.outputTokens[model] !== 'number') this.outputTokens[model] = 0;
|
||||
if (typeof this.cachedTokens[model] !== 'number') this.cachedTokens[model] = 0;
|
||||
this.inputTokens[model] += usage.input_tokens - usage.input_tokens_details.cached_tokens;
|
||||
this.outputTokens[model] += usage.output_tokens;
|
||||
this.cachedTokens[model] += usage.input_tokens_details.cached_tokens;
|
||||
}
|
||||
|
||||
on = (event, callback) => {
|
||||
const debounceTime = 1000; // 1 second
|
||||
|
||||
const debouncedCallback = (...args) => {
|
||||
const now = Date.now();
|
||||
if (now - this.lastDebouncedUpdate >= debounceTime) {
|
||||
this.lastDebouncedUpdate = now;
|
||||
callback(...args);
|
||||
}
|
||||
};
|
||||
|
||||
this.emitter.on(event, debouncedCallback);
|
||||
}
|
||||
|
||||
interrogate = async (prompt) => {
|
||||
if(this.isActive) return;
|
||||
this.isActive = true;
|
||||
|
||||
this.messages.push({"role": "user", "content": [ {"type": "input_text","text": prompt }]});
|
||||
|
||||
const outputs = [];
|
||||
|
||||
do{
|
||||
const messagesToSend = this.messages.splice(0);
|
||||
console.log(chalk.blue('sending messages:'),messagesToSend.length);
|
||||
//console.log(chalk.blue('messages:'),JSON.stringify(messagesToSend,null,2));
|
||||
this.messagesSent.push(...messagesToSend);
|
||||
|
||||
const model = this.options.model || 'gpt-5-mini';
|
||||
if(!['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-4.1', 'gpt-4.1-mini'].includes(model)){
|
||||
throw new Error('Invalid model: ' + model);
|
||||
}
|
||||
|
||||
const call = {
|
||||
model: model,
|
||||
input: messagesToSend,
|
||||
text: { format: { type: 'text' } },
|
||||
tools: Object.values(toolsByFile).map(t => t.def),
|
||||
store: true,
|
||||
previous_response_id: this.previousResponseId,
|
||||
parallel_tool_calls: true,
|
||||
include: ['reasoning.encrypted_content']
|
||||
}
|
||||
if(model.startsWith('gpt-5')){
|
||||
call.reasoning = { effort: 'low', summary: 'detailed' };
|
||||
//call.text.format.verbosity = 'low';
|
||||
}
|
||||
|
||||
|
||||
this.currentStream = openai.responses.stream(call);
|
||||
|
||||
this.currentStream.on('response.created', (event) => {
|
||||
this.previousResponseId = event.response.id;
|
||||
});
|
||||
|
||||
const deltas = [];
|
||||
this.currentStream.on('response.output_text.delta', (event) => {
|
||||
deltas.push(event.delta);
|
||||
this.emitter.emit('outputUpdate', deltas.join(''));
|
||||
});
|
||||
|
||||
const reasoningDeltas = [];
|
||||
this.currentStream.on('response.reasoning_summary_text.delta', (event) => {
|
||||
if(!reasoningDeltas[event.summary_index]) reasoningDeltas[event.summary_index] = [];
|
||||
reasoningDeltas[event.summary_index].push(event.delta);
|
||||
this.emitter.emit('reasoningUpdate', reasoningDeltas[event.summary_index].join(''));
|
||||
});
|
||||
|
||||
this.currentStream.on('response.reasoning_summary_text.done', (event) => {
|
||||
//console.log(event);
|
||||
});
|
||||
this.currentStream.on('response.function_call_arguments.delta', (event) => {
|
||||
process.stdout.write(chalk.yellow(event.delta));
|
||||
});
|
||||
this.currentStream.on('response.function_call_arguments.done', (event) => {
|
||||
process.stdout.write("\n");
|
||||
});
|
||||
|
||||
|
||||
this.currentStream.on('response.completed', async (event) => {
|
||||
//console.log(chalk.blue('response completed:'),event.response.usage);
|
||||
this.handleUsage(event.response.usage, event.response.model);
|
||||
outputs.push(...event.response.output);
|
||||
|
||||
for(const toolCall of event.response.output.filter(i => i.type === 'function_call')){
|
||||
// Limit the 'arguments' field to 400 characters for logging
|
||||
const limitedArgs = typeof toolCall.arguments === 'string'
|
||||
? (toolCall.arguments.length > 400 ? toolCall.arguments.slice(0, 400) + '...[truncated]' : toolCall.arguments)
|
||||
: toolCall.arguments;
|
||||
|
||||
const tool = toolsByFile[toolCall.name];
|
||||
let args;
|
||||
try{
|
||||
args = JSON.parse(toolCall.arguments);
|
||||
} catch(e){
|
||||
console.error(chalk.red('Error parsing arguments:'), e, toolCall.arguments);
|
||||
this.messages.push({
|
||||
type: "function_call_output",
|
||||
call_id: toolCall.call_id,
|
||||
output: {error: 'Exception in parsing arguments', exception: e},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const result = await tool.run(args);
|
||||
console.log(chalk.green('function call result:'),'<toolCall.name>',toolCall.name,'</toolCall.name>\n','<args>',limitedArgs,'</args>\n','<result>',JSON.stringify(result).slice(0,100),'...</result>');
|
||||
this.messages.push({
|
||||
type: "function_call_output",
|
||||
call_id: toolCall.call_id,
|
||||
output: JSON.stringify(result),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Array.fromAsync(this.currentStream);
|
||||
|
||||
console.log(chalk.green('Tico'),[Object.values(this.inputTokens),Object.values(this.cachedTokens),Object.values(this.outputTokens)]);
|
||||
console.log(chalk.green('Do we need to loop? messages in array = '),this.messages.length)
|
||||
|
||||
} while(this.messages.length > 0);
|
||||
|
||||
this.isActive = false;
|
||||
this.lastDebouncedUpdate = 0;
|
||||
return {
|
||||
output: outputs.filter(i => i.type === 'message').map(i => i.content[0].text) ,
|
||||
reasoning: outputs.filter(i => i.type === 'reasoning').map(i => i.summary.map(j => j.text).join('\n')),
|
||||
inputTokens: this.inputTokens, outputTokens: this.outputTokens, cachedTokens: this.cachedTokens
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ModelDialog;
|
||||
718
package-lock.json
generated
718
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
64
package.json
64
package.json
@@ -3,55 +3,6 @@
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "cli.js",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"agentkeepalive": "^4.6.0",
|
||||
"asynckit": "^0.4.0",
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"chalk": "^5.5.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"delayed-stream": "^1.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"esbuild": "^0.25.8",
|
||||
"event-target-shim": "^5.0.1",
|
||||
"exa-js": "^1.8.27",
|
||||
"form-data": "^4.0.4",
|
||||
"form-data-encoder": "^1.7.2",
|
||||
"formdata-node": "^4.4.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-intrinsic": "^1.3.0",
|
||||
"get-proto": "^1.0.1",
|
||||
"get-tsconfig": "^4.10.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2",
|
||||
"humanize-ms": "^1.2.1",
|
||||
"ink": "^6.1.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"math-intrinsics": "^1.1.0",
|
||||
"mime-db": "^1.52.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"ms": "^2.1.3",
|
||||
"node-domexception": "^1.0.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"openai": "^4.104.0",
|
||||
"resolve-pkg-maps": "^1.0.0",
|
||||
"terminal-kit": "^3.1.2",
|
||||
"tr46": "^0.0.3",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.9.2",
|
||||
"undici-types": "^5.26.5",
|
||||
"web-streams-polyfill": "^4.0.0-beta.3",
|
||||
"webidl-conversions": "^3.0.1",
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node cli.js",
|
||||
"start:ink": "tsx cli-ink.js",
|
||||
@@ -64,5 +15,18 @@
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": ""
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"chalk": "^5.5.0",
|
||||
"dotenv": "^17.2.1",
|
||||
"exa-js": "^1.8.27",
|
||||
"ink": "^6.1.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^19.1.1",
|
||||
"terminal-kit": "^3.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.20.4"
|
||||
}
|
||||
}
|
||||
|
||||
359
plan.md
Normal file
359
plan.md
Normal file
@@ -0,0 +1,359 @@
|
||||
You're on the right track! You want to create a **Roundcube plugin** that allows users to **enhance or rewrite the email body using an LLM**, by sending the current message content to a backend API (your LLM service), then replacing the textarea content with the response.
|
||||
|
||||
Here’s a complete working example of such a plugin, called `llm_compose_helper`. It adds a button to the compose screen, sends the current message text to a configured URL via AJAX, and replaces the message body with the LLM-generated result.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Goal
|
||||
- Add a "Rewrite with AI" button in the compose window.
|
||||
- On click: open a popup asking the user for a rewrite prompt/instructions.
|
||||
- Submit both the current message body and the user prompt to the configured LLM API endpoint.
|
||||
- Replace the `<textarea>` content with the returned text.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Plugin Structure
|
||||
```
|
||||
plugins/
|
||||
llm_compose_helper/
|
||||
llm_compose_helper.php <-- Main plugin class
|
||||
config.inc.php <-- Configuration file
|
||||
localization/en_US.inc <-- Language labels
|
||||
js/llm_compose_helper.js <-- Client-side JavaScript
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. `llm_compose_helper.php` – Plugin Class
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* LLM Compose Helper Plugin
|
||||
*
|
||||
* Allows rewriting the message body using an external LLM service
|
||||
*
|
||||
* @version 1.0
|
||||
* @author You
|
||||
* @license GPL
|
||||
*/
|
||||
class llm_compose_helper extends rcube_plugin
|
||||
{
|
||||
public $task = 'mail';
|
||||
|
||||
function init()
|
||||
{
|
||||
// Load configuration
|
||||
$this->load_config();
|
||||
|
||||
// Load localization texts (available in JS if second param = true)
|
||||
$this->add_texts('localization/', true);
|
||||
|
||||
// Only run in compose view
|
||||
$rcmail = rcmail::get_instance();
|
||||
if ($rcmail->action == 'compose') {
|
||||
// Include JS and CSS
|
||||
$this->include_script('js/llm_compose_helper.js');
|
||||
|
||||
// Register UI placeholder handler
|
||||
$this->register_handler('plugin.llm_button', array($this, 'llm_button'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the button HTML to be inserted into the toolbar
|
||||
*/
|
||||
function llm_button()
|
||||
{
|
||||
$rcmail = rcmail::get_instance();
|
||||
return html::tag('a', array(
|
||||
'href' => '#',
|
||||
'id' => 'llm_rewrite_button',
|
||||
'class' => 'button',
|
||||
'title' => $this->gettext('rewrite_with_llm'),
|
||||
'onclick' => 'return false;'
|
||||
), $this->gettext('rewrite_label'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `config.inc.php` – Plugin Configuration
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// URL of your LLM API endpoint
|
||||
$config['llm_api_url'] = 'https://your-llm-api.example.com/rewrite';
|
||||
|
||||
// Optional: headers (e.g., auth tokens)
|
||||
$config['llm_api_headers'] = array(
|
||||
'Authorization: Bearer your-secret-token',
|
||||
'Content-Type: application/json'
|
||||
);
|
||||
```
|
||||
|
||||
> 💡 You can later override this in `config/config.inc.php` globally.
|
||||
|
||||
---
|
||||
|
||||
## 3. `localization/en_US.inc`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
$labels = array(
|
||||
'rewrite_with_llm' => 'Rewrite message with AI',
|
||||
'rewrite_label' => 'AI Rewrite',
|
||||
'enter_prompt' => 'Enter rewrite prompt',
|
||||
'rewrite_submit' => 'Rewrite',
|
||||
'rewrite_cancel' => 'Cancel',
|
||||
);
|
||||
|
||||
$messages = array(
|
||||
'prompt_placeholder' => 'e.g., Make it shorter and more formal',
|
||||
);
|
||||
```
|
||||
|
||||
> Add other language files like `fr_FR.inc`, `de_DE.inc`, etc., if needed.
|
||||
|
||||
---
|
||||
|
||||
## 4. `js/llm_compose_helper.js` – Client-Side Logic
|
||||
|
||||
```javascript
|
||||
// Wait for Roundcube to initialize
|
||||
rcmail.addEventListener('init', function() {
|
||||
const button = document.getElementById('llm_rewrite_button');
|
||||
if (!button) return;
|
||||
|
||||
// Insert button into compose toolbar
|
||||
const toolbar = rcmail.gui_objects.toolbar;
|
||||
if (toolbar) {
|
||||
toolbar.appendChild(button);
|
||||
}
|
||||
|
||||
// Attach click event
|
||||
$(button).click(function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const textarea = rcmail.env.html_editor ? rcmail.editor : document.getElementById('composebody');
|
||||
let messageText;
|
||||
|
||||
// Get current message body
|
||||
if (rcmail.env.html_editor && rcmail.editor && rcmail.editor.getData) {
|
||||
messageText = rcmail.editor.getData(); // CKEditor
|
||||
} else {
|
||||
messageText = $('#composebody').val();
|
||||
}
|
||||
|
||||
if (!messageText || messageText.trim() === '') {
|
||||
alert(rcmail.gettext('non_empty', 'llm_compose_helper'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build prompt dialog content
|
||||
var promptId = 'llm_prompt_input_' + Date.now();
|
||||
var dialogHtml = '<div style="padding:8px 0">' +
|
||||
'<label for="' + promptId + '">' + rcmail.gettext('enter_prompt', 'llm_compose_helper') + '</label>' +
|
||||
'<textarea id="' + promptId + '" style="width:100%;height:120px;box-sizing:border-box;margin-top:6px" placeholder="' + (rcmail.gettext('prompt_placeholder', 'llm_compose_helper') || '') + '"></textarea>' +
|
||||
'</div>';
|
||||
|
||||
var buttons = [
|
||||
{
|
||||
text: rcmail.gettext('rewrite_submit', 'llm_compose_helper'),
|
||||
classes: 'mainaction',
|
||||
click: function(e, ref) {
|
||||
var promptValue = document.getElementById(promptId).value || '';
|
||||
|
||||
// Show loading
|
||||
rcmail.set_busy(true, 'loading');
|
||||
|
||||
// Send to LLM API with message and prompt
|
||||
rcmail.http_post('plugin.llm_rewrite', {
|
||||
message: messageText,
|
||||
prompt: promptValue
|
||||
}, function() {
|
||||
rcmail.set_busy(false);
|
||||
});
|
||||
|
||||
if (ref && ref.hide) ref.hide();
|
||||
}
|
||||
},
|
||||
{
|
||||
text: rcmail.gettext('rewrite_cancel', 'llm_compose_helper'),
|
||||
click: function(e, ref) { if (ref && ref.hide) ref.hide(); }
|
||||
}
|
||||
];
|
||||
|
||||
// Open Roundcube dialog
|
||||
rcmail.show_popup_dialog(dialogHtml, rcmail.gettext('rewrite_with_llm', 'llm_compose_helper'), buttons, {modal: true, width: 520});
|
||||
});
|
||||
});
|
||||
|
||||
// Handle response from server
|
||||
rcmail.addEventListener('plugin.llm_rewrite_response', function(response) {
|
||||
if (response.status === 'success' && response.text) {
|
||||
const newText = response.text;
|
||||
|
||||
if (rcmail.env.html_editor && rcmail.editor && rcmail.editor.setData) {
|
||||
rcmail.editor.setData(newText); // For CKEditor
|
||||
} else {
|
||||
$('#composebody').val(newText);
|
||||
}
|
||||
|
||||
rcmail.showMessage(rcmail.gettext('rewrite_success', 'llm_compose_helper'), 'confirmation');
|
||||
} else {
|
||||
var errorMsg = response && response.message ? String(response.message) : rcmail.gettext('rewrite_error', 'llm_compose_helper');
|
||||
rcmail.showMessage(errorMsg, 'error');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Extend `llm_compose_helper.php` – Add Server-Side Action
|
||||
|
||||
Update the `llm_compose_helper.php` file to register the AJAX action and handle the request:
|
||||
|
||||
```php
|
||||
function init()
|
||||
{
|
||||
$this->load_config();
|
||||
$this->add_texts('localization/', true);
|
||||
|
||||
$rcmail = rcmail::get_instance();
|
||||
if ($rcmail->action == 'compose') {
|
||||
$this->include_script('js/llm_compose_helper.js');
|
||||
$this->register_handler('plugin.llm_button', array($this, 'llm_button'));
|
||||
|
||||
// Register custom action for AJAX
|
||||
$this->register_action('plugin.llm_rewrite', array($this, 'action_handler'));
|
||||
}
|
||||
}
|
||||
|
||||
function action_handler()
|
||||
{
|
||||
$rcmail = rcmail::get_instance();
|
||||
|
||||
// Get input
|
||||
$message = rcube_utils::get_input_value('message', rcube_utils::INPUT_POST);
|
||||
$prompt = rcube_utils::get_input_value('prompt', rcube_utils::INPUT_POST);
|
||||
if (empty($message)) {
|
||||
$rcmail->output->command('plugin.llm_rewrite_response', [
|
||||
'status' => 'error',
|
||||
'message' => 'No message provided'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get config
|
||||
$api_url = $rcmail->config->get('llm_api_url');
|
||||
if (!$api_url) {
|
||||
$rcmail->output->command('plugin.llm_rewrite_response', [
|
||||
'status' => 'error',
|
||||
'message' => 'LLM API URL not configured'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$headers = $rcmail->config->get('llm_api_headers', ['Content-Type: application/json']);
|
||||
|
||||
// Prepare request
|
||||
$data = json_encode(['text' => $message, 'prompt' => $prompt]);
|
||||
|
||||
// Use file_get_contents or cURL
|
||||
$options = [
|
||||
'http' => [
|
||||
'header' => $headers,
|
||||
'method' => 'POST',
|
||||
'content' => $data,
|
||||
'timeout' => 30
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
'cafile' => '/etc/ssl/certs/ca-certificates.crt', // Adjust as needed
|
||||
]
|
||||
];
|
||||
|
||||
$context = stream_context_create($options);
|
||||
$result = file_get_contents($api_url, false, $context);
|
||||
|
||||
if ($result === false) {
|
||||
$rcmail->output->command('plugin.llm_rewrite_response', [
|
||||
'status' => 'error',
|
||||
'message' => 'Request failed'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$response = json_decode($result, true);
|
||||
$rewritten = isset($response['text']) && $response['text'] !== null ? $response['text'] : ($response['message'] ?? $result);
|
||||
|
||||
// Return success
|
||||
$rcmail->output->command('plugin.llm_rewrite_response', [
|
||||
'status' => 'success',
|
||||
'text' => $rewritten
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Example LLM API Response Format (Expected)
|
||||
|
||||
Your external LLM endpoint should accept POST JSON:
|
||||
|
||||
```json
|
||||
{ "text": "Hello, I am writing to ask about...", "prompt": "Make it shorter and more formal" }
|
||||
```
|
||||
|
||||
And return:
|
||||
|
||||
```json
|
||||
{ "text": "Hi, I hope you're doing well. I'm reaching out to inquire about..." }
|
||||
```
|
||||
|
||||
Ensure CORS is allowed if hosted separately.
|
||||
|
||||
---
|
||||
|
||||
## 7. Optional: Add More Labels
|
||||
|
||||
In `localization/en_US.inc`, add:
|
||||
|
||||
```php
|
||||
$messages = array(
|
||||
'rewrite_success' => 'Message rewritten successfully!',
|
||||
'rewrite_error' => 'Failed to rewrite message.',
|
||||
'non_empty' => 'Please write something before using AI rewrite.'
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Enable the Plugin
|
||||
|
||||
In `config/config.inc.php`:
|
||||
|
||||
```php
|
||||
$config['plugins'] = array(
|
||||
// ... other plugins
|
||||
'llm_compose_helper'
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Final Result
|
||||
|
||||
When composing or replying:
|
||||
- A new button labeled "AI Rewrite" appears in the toolbar.
|
||||
- Clicking it opens a dialog asking for a rewrite prompt.
|
||||
- Submitting sends the current message and the prompt to your LLM API.
|
||||
- The response replaces the message body.
|
||||
- Works with plain text and HTML (via CKEditor if enabled).
|
||||
|
||||
@@ -30,24 +30,31 @@ class TerminalService extends EventEmitter {
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
PS1: '> '
|
||||
PS1: 'bash> '
|
||||
},
|
||||
});
|
||||
|
||||
this.ptyProcess.onData((data) => {
|
||||
// Normalize line endings
|
||||
const normalized = String(data).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const parts = normalized.split('\n');
|
||||
|
||||
// First part joins the existing partial
|
||||
if (parts.length > 0) {
|
||||
this.partial += parts[0];
|
||||
}
|
||||
|
||||
// For each subsequent part before the last, we have completed lines
|
||||
for (let i = 1; i < parts.length; i += 1) {
|
||||
this.lines.push(this.partial);
|
||||
this.partial = parts[i];
|
||||
const str = String(data);
|
||||
// Normalize CRLF to LF to avoid double-handling \r and \n
|
||||
const normalized = str.replace(/\r\n/g, '\n');
|
||||
for (let i = 0; i < normalized.length; i += 1) {
|
||||
const ch = normalized[i];
|
||||
if (ch === '\n') {
|
||||
// Line feed completes the current line
|
||||
this.lines.push(this.partial);
|
||||
this.partial = '';
|
||||
} else if (ch === '\r') {
|
||||
// Standalone carriage return: simulate return to start of line (overwrite)
|
||||
this.partial = '';
|
||||
} else if (ch === '\b' || ch === '\x7f') {
|
||||
// Backspace or DEL: remove last char if present
|
||||
if (this.partial.length > 0) {
|
||||
this.partial = this.partial.slice(0, -1);
|
||||
}
|
||||
} else {
|
||||
this.partial += ch;
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce max lines buffer
|
||||
@@ -111,10 +118,16 @@ class TerminalService extends EventEmitter {
|
||||
this.ptyProcess.kill();
|
||||
this.ptyProcess = null;
|
||||
}
|
||||
this.started = false;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
restart() {
|
||||
try { this.dispose(); } catch {}
|
||||
try { this.start(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const terminalService = new TerminalService();
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import uiService from './uiService.js';
|
||||
import TextInput from 'ink-text-input';
|
||||
import terminalService from '../terminalService.js';
|
||||
import ModelDialog from '../../modelDialog.js';
|
||||
|
||||
const sharedModelDialog = new ModelDialog();
|
||||
const npmSpinnerFrames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
||||
|
||||
class Pane extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
cursorVisible: true,
|
||||
};
|
||||
this._cursorTimer = null;
|
||||
}
|
||||
componentDidMount() {
|
||||
if (this.props.showCursor) {
|
||||
this._cursorTimer = setInterval(() => {
|
||||
this.setState((s) => ({ cursorVisible: !s.cursorVisible }));
|
||||
}, typeof this.props.cursorBlinkMs === 'number' && this.props.cursorBlinkMs > 0 ? this.props.cursorBlinkMs : 500);
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this._cursorTimer) {
|
||||
clearInterval(this._cursorTimer);
|
||||
this._cursorTimer = null;
|
||||
}
|
||||
}
|
||||
// Strip ANSI escape sequences so width measurement/truncation is accurate
|
||||
stripAnsi(input) {
|
||||
if (input == null) return '';
|
||||
@@ -35,6 +60,24 @@ class Pane extends React.Component {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Apply a blinking cursor to the given line according to width constraints
|
||||
withCursor(line, maxWidth) {
|
||||
const cursorChar = typeof this.props.cursorChar === 'string' && this.props.cursorChar.length > 0 ? this.props.cursorChar[0] : '█';
|
||||
const visible = !!this.state.cursorVisible;
|
||||
if (typeof maxWidth !== 'number' || maxWidth <= 0) {
|
||||
return line;
|
||||
}
|
||||
// Place cursor at the logical end of content, clamped to last column
|
||||
const width = maxWidth;
|
||||
const base = (line || '').slice(0, width);
|
||||
const cursorIndex = Math.min(base.length, width - 1);
|
||||
const targetLen = Math.min(width, cursorIndex + 1);
|
||||
const padLen = Math.max(0, targetLen - base.length);
|
||||
const padded = padLen > 0 ? `${base}${' '.repeat(padLen)}` : base.slice(0, targetLen);
|
||||
const chars = padded.split('');
|
||||
chars[cursorIndex] = visible ? cursorChar : ' ';
|
||||
return chars.join('');
|
||||
}
|
||||
render() {
|
||||
const { title, lines, maxWidth } = this.props;
|
||||
return (
|
||||
@@ -42,20 +85,17 @@ class Pane extends React.Component {
|
||||
<Text color="cyan">{title}</Text>
|
||||
<Box flexDirection="column" width="100%" flexShrink={1} minWidth={0}>
|
||||
{(lines && lines.length > 0)
|
||||
? lines.map((line, index) => (
|
||||
<Text key={index}>{
|
||||
(() => {
|
||||
const clean = this.stripAnsi(line);
|
||||
const width = typeof maxWidth === 'number' && maxWidth > 0 ? maxWidth : undefined;
|
||||
// Expand tabs before slicing to visual width
|
||||
const expanded = this.expandTabs(clean, 8, width);
|
||||
if (width && expanded.length > width) {
|
||||
return expanded.slice(0, width);
|
||||
}
|
||||
return expanded;
|
||||
})()
|
||||
}</Text>
|
||||
))
|
||||
? lines.map((line, index) => {
|
||||
const isLast = index === lines.length - 1;
|
||||
const width = typeof maxWidth === 'number' && maxWidth > 0 ? maxWidth : undefined;
|
||||
const clean = this.stripAnsi(line);
|
||||
const expanded = this.expandTabs(clean, 8, width);
|
||||
const baseLine = (width && expanded.length > width) ? expanded.slice(0, width) : expanded;
|
||||
const finalLine = (this.props.showCursor && isLast) ? this.withCursor(baseLine, width) : baseLine;
|
||||
return (
|
||||
<Text key={index}>{finalLine}</Text>
|
||||
);
|
||||
})
|
||||
: <Text dimColor>—</Text>
|
||||
}
|
||||
</Box>
|
||||
@@ -72,7 +112,14 @@ export default class InkApp extends React.Component {
|
||||
logs: [],
|
||||
terminal: [],
|
||||
chainOfThought: [],
|
||||
llmOutput: []
|
||||
llmOutput: [],
|
||||
menuOpen: false,
|
||||
menuIndex: 0,
|
||||
model: 'gpt-5',
|
||||
reasoningEffort: 'minimal',
|
||||
outputVerbosity: 'low',
|
||||
isLoading: false,
|
||||
spinnerIndex: 0
|
||||
};
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
@@ -80,6 +127,11 @@ export default class InkApp extends React.Component {
|
||||
this.setChainOfThought = this.setChainOfThought.bind(this);
|
||||
this.setTerminal = this.setTerminal.bind(this);
|
||||
this.setLogs = this.setLogs.bind(this);
|
||||
this.toggleMenu = this.toggleMenu.bind(this);
|
||||
this.onKeypress = this.onKeypress.bind(this);
|
||||
this.menuAction = this.menuAction.bind(this);
|
||||
this.getModelSettingsItems = this.getModelSettingsItems.bind(this);
|
||||
this.handleModelSettingAdjust = this.handleModelSettingAdjust.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -104,6 +156,18 @@ export default class InkApp extends React.Component {
|
||||
process.stdout.on('resize', this.onResize);
|
||||
}
|
||||
this.onResize();
|
||||
|
||||
// Keyboard handling for menu
|
||||
if (process.stdin && process.stdin.on) {
|
||||
try { process.stdin.setRawMode(true); } catch {}
|
||||
process.stdin.on('data', this.onKeypress);
|
||||
}
|
||||
// spinner timer
|
||||
this._spinnerTimer = setInterval(() => {
|
||||
if (this.state.isLoading) {
|
||||
this.setState((s) => ({ spinnerIndex: (s.spinnerIndex + 1) % npmSpinnerFrames.length }));
|
||||
}
|
||||
}, 80);
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.terminalUnsub) {
|
||||
@@ -114,6 +178,13 @@ export default class InkApp extends React.Component {
|
||||
process.stdout.off('resize', this.onResize);
|
||||
this.onResize = null;
|
||||
}
|
||||
if (process.stdin && process.stdin.off) {
|
||||
process.stdin.off('data', this.onKeypress);
|
||||
}
|
||||
if (this._spinnerTimer) {
|
||||
clearInterval(this._spinnerTimer);
|
||||
this._spinnerTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
setPaneLines(stateKey, lines) {
|
||||
@@ -150,21 +221,168 @@ export default class InkApp extends React.Component {
|
||||
this.setState({ input: value });
|
||||
}
|
||||
|
||||
handleSubmit() {
|
||||
async handleSubmit() {
|
||||
const { input } = this.state;
|
||||
if (!input) return;
|
||||
try {
|
||||
terminalService.write(`${input}\r`);
|
||||
} catch (e) {
|
||||
// do not hide errors; show in logs
|
||||
this.setState((state) => ({
|
||||
logs: [...state.logs, `! write error: ${String(e && e.message ? e.message : e)}`],
|
||||
}));
|
||||
}
|
||||
this.setState((state) => ({
|
||||
logs: [...state.logs, `> ${input}`],
|
||||
input: ''
|
||||
input: '',
|
||||
isLoading: true
|
||||
}));
|
||||
try {
|
||||
const result = await sharedModelDialog.interrogate(input);
|
||||
const finalOutput = Array.isArray(result && result.output) ? result.output : [String(result && result.output ? result.output : '')];
|
||||
const finalReasoning = Array.isArray(result && result.reasoning) ? result.reasoning : (result && result.reasoning ? [String(result.reasoning)] : []);
|
||||
// Append to LLM output with a separator, overwrite chain of thought
|
||||
this.setState((state) => ({
|
||||
llmOutput: [
|
||||
...state.llmOutput,
|
||||
...(state.llmOutput.length ? ['----------'] : []),
|
||||
...finalOutput
|
||||
]
|
||||
}));
|
||||
this.setChainOfThought(finalReasoning);
|
||||
this.setState((state) => ({
|
||||
logs: [
|
||||
...state.logs,
|
||||
`tokens input: ${JSON.stringify(result && result.inputTokens)}`,
|
||||
`tokens cached: ${JSON.stringify(result && result.cachedTokens)}`,
|
||||
`tokens output: ${JSON.stringify(result && result.outputTokens)}`
|
||||
]
|
||||
}));
|
||||
} catch (e) {
|
||||
this.setState((state) => ({
|
||||
logs: [...state.logs, `! interrogate error: ${String(e && e.message ? e.message : e)}`]
|
||||
}));
|
||||
} finally {
|
||||
this.setState({ isLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
toggleMenu(open) {
|
||||
this.setState((s) => ({ menuOpen: typeof open === 'boolean' ? open : !s.menuOpen }));
|
||||
}
|
||||
|
||||
onKeypress(buf) {
|
||||
const data = Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf));
|
||||
// ESC [ A => Up arrow
|
||||
const isUp = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x41;
|
||||
const isDown = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x42;
|
||||
const isEnter = data.length === 1 && data[0] === 0x0d;
|
||||
const isLeft = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x44;
|
||||
const isRight = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x43;
|
||||
const isCtrlC = data.length === 1 && data[0] === 0x03;
|
||||
|
||||
if (!this.state.menuOpen) {
|
||||
if (isUp) {
|
||||
this.toggleMenu(true);
|
||||
return;
|
||||
}
|
||||
// let Ink TextInput handle normal typing; we don't intercept here
|
||||
if (isCtrlC) {
|
||||
// Pass through to exit behavior handled in cli-ink.js
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Submenu: Model settings adjustments
|
||||
if (this.state.menuOpen && this.state.menuMode === 'model') {
|
||||
const items = this.getModelSettingsItems();
|
||||
if (isUp) {
|
||||
this.setState((s) => ({ menuIndex: (s.menuIndex - 1 + items.length) % items.length }));
|
||||
return;
|
||||
}
|
||||
if (isDown) {
|
||||
this.setState((s) => ({ menuIndex: (s.menuIndex + 1) % items.length }));
|
||||
return;
|
||||
}
|
||||
if (isLeft || isRight) {
|
||||
const idx = this.state.menuIndex;
|
||||
const dir = isRight ? 1 : -1;
|
||||
this.handleModelSettingAdjust(items[idx].key, dir);
|
||||
return;
|
||||
}
|
||||
if (isEnter) {
|
||||
// Enter exits model submenu back to main menu
|
||||
this.setState({ menuMode: undefined, menuIndex: 0 });
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Menu navigation (main menu)
|
||||
const items = this.getMenuItems();
|
||||
if (isUp) {
|
||||
this.setState((s) => ({ menuIndex: (s.menuIndex - 1 + items.length) % items.length }));
|
||||
return;
|
||||
}
|
||||
if (isDown) {
|
||||
this.setState((s) => ({ menuIndex: (s.menuIndex + 1) % items.length }));
|
||||
return;
|
||||
}
|
||||
if (isEnter) {
|
||||
const idx = this.state.menuIndex;
|
||||
this.menuAction(items[idx]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getMenuItems() {
|
||||
return [
|
||||
'Send CTRL-C to terminal',
|
||||
'Restart Terminal',
|
||||
'Model settings',
|
||||
'Exit the app',
|
||||
'Close menu'
|
||||
];
|
||||
}
|
||||
|
||||
menuAction(label) {
|
||||
switch (label) {
|
||||
case 'Send CTRL-C to terminal':
|
||||
try { terminalService.write('\x03'); } catch {}
|
||||
break;
|
||||
case 'Restart Terminal':
|
||||
try { terminalService.restart(); } catch {}
|
||||
break;
|
||||
case 'Model settings':
|
||||
// Toggle a sub-menu state
|
||||
this.setState({ menuMode: 'model', menuIndex: 0 });
|
||||
break;
|
||||
case 'Exit the app':
|
||||
try { process.exit(0); } catch {}
|
||||
break;
|
||||
case 'Close menu':
|
||||
this.toggleMenu(false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
getModelSettingsItems() {
|
||||
return [
|
||||
{ key: 'model', label: 'Model', options: ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano'] },
|
||||
{ key: 'reasoningEffort', label: 'Reasoning effort', options: ['minimal', 'low', 'medium', 'high'] },
|
||||
{ key: 'outputVerbosity', label: 'Output verbosity', options: ['low', 'medium', 'high'] },
|
||||
{ key: 'back', label: 'Back to main menu' }
|
||||
];
|
||||
}
|
||||
|
||||
handleModelSettingAdjust(key, dir) {
|
||||
if (key === 'back') {
|
||||
this.setState({ menuMode: undefined, menuIndex: 0 });
|
||||
return;
|
||||
}
|
||||
const items = this.getModelSettingsItems();
|
||||
const item = items.find((i) => i.key === key);
|
||||
if (!item || !item.options) return;
|
||||
const currentValue = this.state[key];
|
||||
const idx = item.options.indexOf(currentValue);
|
||||
const nextIdx = ((idx === -1 ? 0 : idx) + dir + item.options.length) % item.options.length;
|
||||
const nextValue = item.options[nextIdx];
|
||||
this.setState({ [key]: nextValue });
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -193,6 +411,9 @@ export default class InkApp extends React.Component {
|
||||
const chainOfThoughtView = sliceLast(chainOfThought);
|
||||
const terminalView = sliceLast(terminal);
|
||||
const logsView = sliceLast(logs);
|
||||
|
||||
const menuItems = this.getMenuItems();
|
||||
const selected = this.state.menuIndex;
|
||||
return (
|
||||
<Box flexDirection="column" height="100%">
|
||||
<Box flexGrow={1} flexDirection="row" minWidth={0}>
|
||||
@@ -201,18 +422,43 @@ export default class InkApp extends React.Component {
|
||||
<Pane title="Chain of Thought" lines={chainOfThoughtView} maxWidth={paneContentWidth} />
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="column" minWidth={0}>
|
||||
<Pane title="Terminal" lines={terminalView} maxWidth={paneContentWidth} />
|
||||
<Pane title="Terminal" lines={terminalView} maxWidth={paneContentWidth} showCursor cursorBlinkMs={600} />
|
||||
<Pane title="Logging" lines={logsView} maxWidth={paneContentWidth} />
|
||||
</Box>
|
||||
</Box>
|
||||
{this.state.menuOpen && this.state.menuMode !== 'model' && (
|
||||
<Box borderStyle="round" paddingX={1} paddingY={0} marginTop={1} flexDirection="column">
|
||||
<Text color="yellow">Main Menu (Up/Down to navigate, Enter to select)</Text>
|
||||
{menuItems.map((label, i) => (
|
||||
<Text key={label} color={i === selected ? 'cyan' : undefined}>
|
||||
{i === selected ? '› ' : ' '}{label}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{this.state.menuOpen && this.state.menuMode === 'model' && (
|
||||
<Box borderStyle="round" paddingX={1} paddingY={0} marginTop={1} flexDirection="column">
|
||||
<Text color="yellow">Model Settings (Up/Down select, Left/Right change, Enter back)</Text>
|
||||
{this.getModelSettingsItems().map((item, i) => (
|
||||
<Text key={item.key || item.label} color={i === selected ? 'cyan' : undefined}>
|
||||
{i === selected ? '› ' : ' '}
|
||||
{item.label}{item.options ? `: ${this.state[item.key]}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>Input: </Text>
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={this.handleChange}
|
||||
onSubmit={this.handleSubmit}
|
||||
placeholder="Type and press Enter..."
|
||||
/>
|
||||
{this.state.isLoading ? (
|
||||
<Text color="yellow">{npmSpinnerFrames[this.state.spinnerIndex]} Processing...</Text>
|
||||
) : (
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={this.handleChange}
|
||||
onSubmit={this.handleSubmit}
|
||||
placeholder="Type and press Enter..."
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
9
src/ui/uiService.js
Normal file
9
src/ui/uiService.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import EventEmitter from 'events';
|
||||
|
||||
class UIService extends EventEmitter {}
|
||||
|
||||
const uiService = new UIService();
|
||||
export default uiService;
|
||||
|
||||
|
||||
|
||||
2
todo.md
Normal file
2
todo.md
Normal file
@@ -0,0 +1,2 @@
|
||||
return the function call result via event.
|
||||
display function call evenst in logging
|
||||
@@ -1,7 +1,7 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// Utility to normalize and validate paths within chroot
|
||||
// Utility to normalize and validate paths within a contained root directory
|
||||
const normalizePath = (inputPath, chrootDir) => {
|
||||
// Resolve chroot directory
|
||||
const chrootResolved = path.resolve(chrootDir);
|
||||
@@ -17,12 +17,19 @@ const normalizePath = (inputPath, chrootDir) => {
|
||||
|
||||
// Ensure the path is within chrootDir
|
||||
if (!normalized.startsWith(chrootResolved)) {
|
||||
throw new Error(`Path escapes chroot boundary: ${inputPath}`);
|
||||
throw new Error(`Path escapes root boundary: ${inputPath}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
// Convert an absolute path under the contained root to a user-display path (root-relative)
|
||||
const toDisplayPath = (absPath, chrootDir) => {
|
||||
const rel = path.relative(path.resolve(chrootDir), absPath);
|
||||
if (!rel || rel === "") return "/";
|
||||
return `/${rel}`;
|
||||
};
|
||||
|
||||
// Main recursive directory listing function
|
||||
async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden = false) {
|
||||
const results = [];
|
||||
@@ -35,7 +42,7 @@ async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden
|
||||
try {
|
||||
dirents = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read directory: ${currentDir} (${err?.message || String(err)})`);
|
||||
throw new Error(`Failed to read directory: ${toDisplayPath(currentDir, chrootDir)}`);
|
||||
}
|
||||
|
||||
for (const dirent of dirents) {
|
||||
@@ -91,39 +98,39 @@ async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden
|
||||
export default {
|
||||
type: "function",
|
||||
name: "list_files",
|
||||
description: "List files and directories recursively within a chroot directory with customizable options",
|
||||
description: "List files and directories recursively from the root with customizable options",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Directory or file path to list relative to chroot. Use '/' for the chroot root. Defaults to chroot root if not specified.",
|
||||
type: ["string", "null"],
|
||||
description: "Directory or file path relative to the root. Use '/' for the root. Defaults to root if not specified.",
|
||||
},
|
||||
depth: {
|
||||
type: "integer",
|
||||
type: ["integer", "null"],
|
||||
description: "Maximum subdirectory levels to traverse. Use -1 for unlimited depth. Defaults to 1.",
|
||||
minimum: -1,
|
||||
},
|
||||
includeHidden: {
|
||||
type: "boolean",
|
||||
type: ["boolean", "null"],
|
||||
description: "Whether to include hidden files and directories (starting with '.'). Defaults to false.",
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
required: ["path", "depth", "includeHidden"],
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
strict: true,
|
||||
strict: false,
|
||||
};
|
||||
|
||||
export async function run(args) {
|
||||
const inputPath = args?.path || "";
|
||||
const depth = Number.isInteger(args?.depth) ? args.depth : 1;
|
||||
const includeHidden = args?.includeHidden ?? false;
|
||||
const chrootPath = '/workspaces/aiTools';
|
||||
const chrootPath = '/workspaces/aiTools/root';
|
||||
|
||||
if (!chrootPath) {
|
||||
return { err: "Chroot path is required" };
|
||||
return { err: "Root path is required" };
|
||||
}
|
||||
if (depth < -1) {
|
||||
return { err: `Depth must be >= -1, received ${args?.depth}` };
|
||||
@@ -135,7 +142,7 @@ export async function run(args) {
|
||||
// Auto-create the chroot base directory if it does not exist
|
||||
await fs.mkdir(chrootResolved, { recursive: true });
|
||||
} catch (err) {
|
||||
return { err: `Failed to prepare chroot path: ${chrootPath} (${err?.message || String(err)})` };
|
||||
return { err: "Failed to initialize root directory" };
|
||||
}
|
||||
|
||||
let resolvedBase;
|
||||
@@ -149,10 +156,13 @@ export async function run(args) {
|
||||
try {
|
||||
stat = await fs.lstat(resolvedBase);
|
||||
} catch (err) {
|
||||
return { err: `Path does not exist: ${resolvedBase} (${err?.message || String(err)})` };
|
||||
return { err: `Path does not exist${inputPath ? `: ${inputPath}` : ""}` };
|
||||
}
|
||||
|
||||
const cwd = path.relative(chrootResolved, stat.isFile() ? path.dirname(resolvedBase) : resolvedBase) || ".";
|
||||
const cwd = toDisplayPath(
|
||||
stat.isFile() ? path.dirname(resolvedBase) : resolvedBase,
|
||||
chrootResolved
|
||||
);
|
||||
|
||||
// Handle single file case
|
||||
if (stat.isFile()) {
|
||||
@@ -166,7 +176,7 @@ export async function run(args) {
|
||||
|
||||
// Handle non-directory case
|
||||
if (!stat.isDirectory()) {
|
||||
return { err: `Not a file or directory: ${resolvedBase}` };
|
||||
return { err: `Not a file or directory${inputPath ? `: ${inputPath}` : ""}` };
|
||||
}
|
||||
|
||||
// Handle directory case
|
||||
@@ -181,6 +191,6 @@ export async function run(args) {
|
||||
files: mapped,
|
||||
};
|
||||
} catch (err) {
|
||||
return { err: `Failed to list files: ${err?.message || String(err)}` };
|
||||
return { err: "Failed to list files" };
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import chalk from 'chalk';
|
||||
|
||||
const desc = `
|
||||
This is a custom utility that makes it more convenient to add, remove, move, or edit code files. 'apply_patch' effectively allows you to execute a diff/patch against a file,
|
||||
but the format of the diff specification is unique to this task, so pay careful attention to these instructions.
|
||||
To use the 'apply_patch' command, you should pass a message of the following structure as "input":
|
||||
|
||||
*** Begin Patch
|
||||
[YOUR_PATH]
|
||||
[YOUR_PATCH]
|
||||
*** End Patch
|
||||
|
||||
Where [YOUR_PATCH] is the actual content of your patch, specified in the following V4A diff format.
|
||||
@@ -36,8 +38,17 @@ For instructions on [context_before] and [context_after]:
|
||||
+ [new_code]
|
||||
[3 lines of post-context]
|
||||
|
||||
Note, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code. An example of a message that you might pass as "input" to this function, in order to apply a patch, is shown below.
|
||||
`;
|
||||
Note, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code.
|
||||
|
||||
Complese Example:
|
||||
*** Begin Patch
|
||||
*** Add File: /test.js
|
||||
+ function method() {
|
||||
+ console.log("Hello, world!");
|
||||
+ }
|
||||
*** End Patch
|
||||
`;
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Domain objects
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -117,10 +128,10 @@ function resolvePath(chroot, filepath) {
|
||||
|
||||
const root = normalizePath(chroot);
|
||||
|
||||
// If file is absolute, use it as-is (after normalization).
|
||||
// We assume the caller ensures it is inside the chroot.
|
||||
// If file is absolute, resolve it under the chroot rather than using host FS root
|
||||
if (file.startsWith('/')) {
|
||||
return file;
|
||||
const resolvedAbs = joinPaths(root, file);
|
||||
return resolvedAbs.startsWith('/') ? resolvedAbs : '/' + resolvedAbs;
|
||||
}
|
||||
|
||||
// If file is relative, join with chroot
|
||||
@@ -229,9 +240,9 @@ class Parser {
|
||||
if (path) {
|
||||
// Resolve path with chroot
|
||||
const resolvedPath = resolvePath(this.chroot, path);
|
||||
|
||||
if (resolvedPath in this.patch.actions) {
|
||||
throw new DiffError(`Duplicate update for file: ${resolvedPath}`);
|
||||
// Use user-specified path as the key to avoid double-resolving later
|
||||
if (path in this.patch.actions) {
|
||||
throw new DiffError(`Duplicate update for file: ${path}`);
|
||||
}
|
||||
const move_to = this.read_str("*** Move to: ");
|
||||
if (!(resolvedPath in this.current_files)) {
|
||||
@@ -239,8 +250,8 @@ class Parser {
|
||||
}
|
||||
const text = this.current_files[resolvedPath];
|
||||
const action = this._parse_update_file(text);
|
||||
action.move_path = move_to ? resolvePath(this.chroot, move_to) : null;
|
||||
this.patch.actions[resolvedPath] = action;
|
||||
action.move_path = move_to ? move_to : null;
|
||||
this.patch.actions[path] = action;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -249,14 +260,13 @@ class Parser {
|
||||
if (path) {
|
||||
// Resolve path with chroot
|
||||
const resolvedPath = resolvePath(this.chroot, path);
|
||||
|
||||
if (resolvedPath in this.patch.actions) {
|
||||
throw new DiffError(`Duplicate delete for file: ${resolvedPath}`);
|
||||
if (path in this.patch.actions) {
|
||||
throw new DiffError(`Duplicate delete for file: ${path}`);
|
||||
}
|
||||
if (!(resolvedPath in this.current_files)) {
|
||||
throw new DiffError(`Delete File Error - missing file: ${resolvedPath}`);
|
||||
throw new DiffError(`Delete File Error - missing file: ${path}`);
|
||||
}
|
||||
this.patch.actions[resolvedPath] = new PatchAction(ActionType.DELETE);
|
||||
this.patch.actions[path] = new PatchAction(ActionType.DELETE);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -265,14 +275,13 @@ class Parser {
|
||||
if (path) {
|
||||
// Resolve path with chroot
|
||||
const resolvedPath = resolvePath(this.chroot, path);
|
||||
|
||||
if (resolvedPath in this.patch.actions) {
|
||||
throw new DiffError(`Duplicate add for file: ${resolvedPath}`);
|
||||
if (path in this.patch.actions) {
|
||||
throw new DiffError(`Duplicate add for file: ${path}`);
|
||||
}
|
||||
if (resolvedPath in this.current_files) {
|
||||
throw new DiffError(`Add File Error - file already exists: ${resolvedPath}`);
|
||||
throw new DiffError(`Add File Error - file already exists: ${path}`);
|
||||
}
|
||||
this.patch.actions[resolvedPath] = this._parse_add_file();
|
||||
this.patch.actions[path] = this._parse_add_file();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -361,7 +370,9 @@ class Parser {
|
||||
if (!s.startsWith("+")) {
|
||||
throw new DiffError(`Invalid Add File line (missing '+'): ${s}`);
|
||||
}
|
||||
lines.push(s.substring(1)); // strip leading '+'
|
||||
// Strip leading '+' and ignore a single optional space immediately after '+'
|
||||
const content = s.substring(1).replace(/^ /, "");
|
||||
lines.push(content);
|
||||
}
|
||||
return new PatchAction(ActionType.ADD, lines.join("\n"));
|
||||
}
|
||||
@@ -603,10 +614,11 @@ function _get_updated_file(text, action, path) {
|
||||
function patch_to_commit(patch, orig, chroot = null) {
|
||||
const commit = new Commit();
|
||||
for (const [path, action] of Object.entries(patch.actions)) {
|
||||
const resolvedPath = resolvePath(chroot, path);
|
||||
if (action.type === ActionType.DELETE) {
|
||||
commit.changes[path] = new FileChange(
|
||||
ActionType.DELETE,
|
||||
orig[path],
|
||||
orig[resolvedPath],
|
||||
null,
|
||||
null
|
||||
);
|
||||
@@ -621,11 +633,11 @@ function patch_to_commit(patch, orig, chroot = null) {
|
||||
null
|
||||
);
|
||||
} else if (action.type === ActionType.UPDATE) {
|
||||
const new_content = _get_updated_file(orig[path], action, path);
|
||||
const new_content = _get_updated_file(orig[resolvedPath], action, path);
|
||||
const move_path = action.move_path ? unresolvePath(chroot, action.move_path) : null;
|
||||
commit.changes[path] = new FileChange(
|
||||
ActionType.UPDATE,
|
||||
orig[path],
|
||||
orig[resolvedPath],
|
||||
new_content,
|
||||
move_path
|
||||
);
|
||||
@@ -721,20 +733,23 @@ function load_files(paths, open_fn) {
|
||||
function apply_commit(commit, write_fn, remove_fn, chroot = null) {
|
||||
for (const [path, change] of Object.entries(commit.changes)) {
|
||||
if (change.type === ActionType.DELETE) {
|
||||
remove_fn(path);
|
||||
const target = resolvePath(chroot, path);
|
||||
remove_fn(target);
|
||||
} else if (change.type === ActionType.ADD) {
|
||||
if (change.new_content === null) {
|
||||
throw new DiffError(`ADD change for ${path} has no content`);
|
||||
}
|
||||
write_fn(path, change.new_content);
|
||||
const target = resolvePath(chroot, path);
|
||||
write_fn(target, change.new_content);
|
||||
} else if (change.type === ActionType.UPDATE) {
|
||||
if (change.new_content === null) {
|
||||
throw new DiffError(`UPDATE change for ${path} has no new content`);
|
||||
}
|
||||
const target = change.move_path ? resolvePath(chroot, change.move_path) : path;
|
||||
const target = change.move_path ? resolvePath(chroot, change.move_path) : resolvePath(chroot, path);
|
||||
write_fn(target, change.new_content);
|
||||
if (change.move_path) {
|
||||
remove_fn(path);
|
||||
const source = resolvePath(chroot, path);
|
||||
remove_fn(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -808,10 +823,11 @@ export async function run(args) {
|
||||
open_file,
|
||||
write_file,
|
||||
remove_file,
|
||||
'/workspaces/aiTools'
|
||||
'/workspaces/aiTools/root'
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.log(chalk.red('Patch error:'),error);
|
||||
return `Patch error: ${error.message}`
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const virtual_chroot = '/workspaces/aiTools';
|
||||
const virtual_chroot = '/workspaces/aiTools/root';
|
||||
|
||||
// Ensures reads are confined to `virtual_chroot`.
|
||||
|
||||
export default {
|
||||
type: "function", name: "read_file", description: "read a file", strict: true,
|
||||
type: "function", name: "read_file", description: "read a file", strict: false,
|
||||
parameters: {
|
||||
type: "object", required: ["path","linesToSkip","linesToRead"], additionalProperties: false, properties: {
|
||||
type: "object", required: ["path"], additionalProperties: false, properties: {
|
||||
path: { type: "string", description: "The path to the file to read.", },
|
||||
linesToSkip: { type: "integer", description: "The number of lines to skip. Use 0 to read from the beginning.", minimum: 0 },
|
||||
linesToRead: { type: "integer", description: "1-400 The number of lines to read. Use 0 or more than 400 to read 400 lines.", minimum: 0 }
|
||||
linesToSkip: { type: ["integer", "null"], description: "The number of lines to skip. Use 0 to read from the beginning, which is the default.", minimum: 0 },
|
||||
linesToRead: { type: ["integer", "null"], description: "1-400 The number of lines to read. 400 is the default.", minimum: 1, maximum: 400 }
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -29,6 +29,11 @@ export async function run(args) {
|
||||
|
||||
// Normalize linesToRead (1-400, with 0 or >400 meaning 400)
|
||||
const maxLines = (linesToRead <= 0 || linesToRead > 400) ? 400 : linesToRead;
|
||||
|
||||
// check if the file exists
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return `read_file error: File does not exist`;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -51,7 +56,7 @@ export async function run(args) {
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
return 'Filecontent: ´´´'+lines.join('\n')+'´´´';
|
||||
} catch (error) {
|
||||
return `read_file error: ${error.message}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const virtual_chroot = '/workspaces/aiTools';
|
||||
const virtual_chroot = '/workspaces/aiTools/root';
|
||||
|
||||
export default {
|
||||
type: "function", name: "ripgrep", strict: true,
|
||||
|
||||
@@ -7,7 +7,10 @@ export async function run(args){
|
||||
const buffer = await res.buffer();
|
||||
const filename = new Date().getTime() + '.' + url.split('.').pop();
|
||||
const content = buffer.slice(0, 500).toString('utf8');
|
||||
return { filename, content };
|
||||
// save the file to the chroot
|
||||
const filePath = `/workspaces/aiTools/root/${filename}`;
|
||||
fs.writeFileSync(filePath, content);
|
||||
return { 'Downloaded to:': filename };
|
||||
};
|
||||
|
||||
// metadata for the tool runner
|
||||
|
||||
Reference in New Issue
Block a user