Compare commits
25 Commits
7a6c2488da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f43e0af918 | ||
|
|
46c9fe9fac | ||
|
|
eb3f58b2e6 | ||
|
|
6e8a336143 | ||
|
|
839cea7fe6 | ||
|
|
131a45e305 | ||
|
|
7fb261a3b7 | ||
|
|
7ad5d10378 | ||
|
|
3c6bf7184c | ||
|
|
9974a78394 | ||
|
|
421b47355b | ||
|
|
657b6af993 | ||
|
|
df85e5e603 | ||
|
|
b49c798fc7 | ||
|
|
83ac8709b7 | ||
|
|
58d8c352f3 | ||
|
|
14305859de | ||
|
|
0815d64802 | ||
|
|
b515275407 | ||
|
|
ac09e4ed08 | ||
|
|
62e9754ab0 | ||
|
|
ff3accdc76 | ||
|
|
60e288454c | ||
|
|
324aea5775 | ||
|
|
182ccd34ca |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"image": "mcr.microsoft.com/devcontainers/javascript-node"
|
"image": "devpit:latest"
|
||||||
}
|
}
|
||||||
32
.eslintrc.json
Normal file
32
.eslintrc.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"env": {
|
||||||
|
"es2022": true,
|
||||||
|
"node": true,
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2022,
|
||||||
|
"sourceType": "module",
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": [],
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended"
|
||||||
|
],
|
||||||
|
"ignorePatterns": [
|
||||||
|
"node_modules/",
|
||||||
|
"out1",
|
||||||
|
"dist/",
|
||||||
|
"build/"
|
||||||
|
],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["**/*.jsx"],
|
||||||
|
"rules": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
tmp
|
tmp
|
||||||
|
root
|
||||||
14
.vscode/launch.json
vendored
Normal file
14
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "npm start",
|
||||||
|
"name": "Run npm start",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9
.vscode/settings.json
vendored
Normal file
9
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"eslint.experimental.useFlatConfig": true,
|
||||||
|
"eslint.workingDirectories": [
|
||||||
|
{
|
||||||
|
"mode": "auto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
15
cli-ink.js
Normal file → Executable file
15
cli-ink.js
Normal file → Executable file
@@ -1,8 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env -S node --import tsx
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render } from 'ink';
|
import { render } from 'ink';
|
||||||
import InkApp from './src/ui/InkApp.jsx';
|
import InkApp from './src/ui/InkApp.jsx';
|
||||||
|
import terminalService from './src/terminalService.js';
|
||||||
|
|
||||||
|
// Start the PTY backend independent from UI lifecycle
|
||||||
|
terminalService.start();
|
||||||
|
|
||||||
const { unmount } = render(React.createElement(InkApp));
|
const { unmount } = render(React.createElement(InkApp));
|
||||||
|
|
||||||
@@ -14,6 +18,7 @@ if (process.stdin.isTTY) {
|
|||||||
|
|
||||||
const exitCleanly = () => {
|
const exitCleanly = () => {
|
||||||
unmount();
|
unmount();
|
||||||
|
try { terminalService.dispose(); } catch { }
|
||||||
try { process.stdin.setRawMode(false); } catch { }
|
try { process.stdin.setRawMode(false); } catch { }
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
@@ -21,6 +26,10 @@ if (process.stdin.isTTY) {
|
|||||||
const onData = (data) => {
|
const onData = (data) => {
|
||||||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
|
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
|
||||||
for (const byte of buffer) {
|
for (const byte of buffer) {
|
||||||
|
// Ctrl-C (ETX)
|
||||||
|
if (byte === 0x03) {
|
||||||
|
return exitCleanly();
|
||||||
|
}
|
||||||
if (!escPending) {
|
if (!escPending) {
|
||||||
if (byte === 0x1b) { // ESC
|
if (byte === 0x1b) { // ESC
|
||||||
escPending = true;
|
escPending = true;
|
||||||
@@ -42,6 +51,10 @@ if (process.stdin.isTTY) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
process.stdin.on('data', onData);
|
process.stdin.on('data', onData);
|
||||||
|
|
||||||
|
// Also handle SIGINT in case raw mode changes or comes from elsewhere
|
||||||
|
const onSigint = () => exitCleanly();
|
||||||
|
process.on('SIGINT', onSigint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
65
cli.js
65
cli.js
@@ -6,15 +6,25 @@ import terminalKit from 'terminal-kit';
|
|||||||
//npm install tiktoken
|
//npm install tiktoken
|
||||||
//csk-8jftdte6r6vf8fdvp9xkyek5t3jnc6jfhh93d3ewfcwxxvh9
|
//csk-8jftdte6r6vf8fdvp9xkyek5t3jnc6jfhh93d3ewfcwxxvh9
|
||||||
|
|
||||||
import { promises as fs } from "node:fs";
|
import { promises as fs, unwatchFile } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import path from "node:path";
|
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) {
|
function renderUsage(usage) {
|
||||||
const inputTokens = usage.input_tokens - usage.input_tokens_details.cached_tokens;
|
const inputTokens = usage.input_tokens - usage.input_tokens_details.cached_tokens;
|
||||||
const cacheTokens = usage.input_tokens_details.cached_tokens;
|
const cacheTokens = usage.input_tokens_details.cached_tokens;
|
||||||
const outputToken = usage.output_tokens;
|
const outputToken = usage.output_tokens;
|
||||||
console.log(' Cost', inputTokens, cacheTokens, outputToken);
|
console.log('\nCost', inputTokens, cacheTokens, outputToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
function printIndented(indentNum, ...args) {
|
function printIndented(indentNum, ...args) {
|
||||||
@@ -48,6 +58,7 @@ term.on('key', (name) => {
|
|||||||
async function askUserForInput() {
|
async function askUserForInput() {
|
||||||
term.cyan("Enter your request: ");
|
term.cyan("Enter your request: ");
|
||||||
const input = await term.inputField({ mouse: false }).promise;
|
const input = await term.inputField({ mouse: false }).promise;
|
||||||
|
console.log('\n');
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,20 +76,21 @@ async function loadTools() {
|
|||||||
);
|
);
|
||||||
return Object.fromEntries(toolEntries);
|
return Object.fromEntries(toolEntries);
|
||||||
}
|
}
|
||||||
|
let counter = 0;
|
||||||
|
let previousResponseId;
|
||||||
while(true){
|
while(true){
|
||||||
|
|
||||||
let counter = 0;
|
|
||||||
// Block for user input before kicking off the LLM loop
|
// Block for user input before kicking off the LLM loop
|
||||||
const userText = await askUserForInput();
|
const userText = await askUserForInput();
|
||||||
await streamOnce(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }), userText );
|
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) {
|
async function streamOnce(openai, userText) {
|
||||||
const toolsByFile = await loadTools();
|
const toolsByFile = await loadTools();
|
||||||
let previousResponseId;
|
|
||||||
|
|
||||||
let input = [
|
|
||||||
{"role": "developer", "content": [ {
|
const systemprompt = {"role": "developer", "content": [ {
|
||||||
"type": "input_text","text": `You are an interactive CLI AI assistant. Follow the user's instructions.
|
"type": "input_text","text": `You are an interactive CLI AI assistant. Follow the user's instructions.
|
||||||
If a tool is available and relevant, plan to use it.
|
If a tool is available and relevant, plan to use it.
|
||||||
Tools:
|
Tools:
|
||||||
|
|
||||||
@@ -86,27 +98,28 @@ list_files - (no/empty path means root)
|
|||||||
patch_files - (zum anlegen, ändern und löschen von Dateien)
|
patch_files - (zum anlegen, ändern und löschen von Dateien)
|
||||||
read_file - (nach zeilen)
|
read_file - (nach zeilen)
|
||||||
ripgrep - suchmusater und dateimuster
|
ripgrep - suchmusater und dateimuster
|
||||||
websearch - eine Google Suche machen mit Schlüsselwörtern`
|
websearch - eine Google Suche machen mit Schlüsselwörtern
|
||||||
}] },
|
`
|
||||||
{"role": "user", "content": [ {"type": "input_text","text": userText } ]},
|
}]};
|
||||||
]
|
|
||||||
|
const input = [{"role": "user", "content": [ {"type": "input_text","text": userText } ]}];
|
||||||
while(input.length > 0){
|
|
||||||
|
do{
|
||||||
|
|
||||||
const call = {
|
const call = {
|
||||||
model: 'gpt-5-mini',
|
model: 'gpt-4.1-nano',
|
||||||
input: input,
|
input: counter == 0 ? [systemprompt,...structuredClone(input)] : structuredClone(input),
|
||||||
text: { format: { type: 'text' }, verbosity: 'high' },
|
text: { format: { type: 'text' }/*, verbosity: 'low' */},
|
||||||
reasoning: { effort: 'medium', summary: 'detailed' },
|
//reasoning: { effort: 'minimal', summary: 'detailed' },
|
||||||
tools: Object.values(toolsByFile).map(t => t.def),
|
tools: Object.values(toolsByFile).map(t => t.def),
|
||||||
store: true,
|
store: true,
|
||||||
}
|
}
|
||||||
if(previousResponseId) call.previous_response_id = previousResponseId;
|
if(previousResponseId) call.previous_response_id = previousResponseId;
|
||||||
|
|
||||||
console.log("\n\n\n\n\n------NEW OPENAI CALL-"+input.length+"-------------"
|
//console.log("\n\n\n\n\n------NEW OPENAI CALL-"+input.length+"-------------"
|
||||||
,"\n",counter++,"\n",'----INPUT-----------------'
|
// ,"\n",counter++,"\n",'----INPUT-----------------'
|
||||||
,"\n",call.input.map(i => JSON.stringify(i)),"\n",
|
// ,"\n",call.input.map(i => JSON.stringify(i)),"\n",
|
||||||
'--------CALL-------------',"\n");
|
// '--------CALL-------------',call,"\n");
|
||||||
const stream = await openai.responses.stream(call);
|
const stream = await openai.responses.stream(call);
|
||||||
stream.on('response.created', (event) => {
|
stream.on('response.created', (event) => {
|
||||||
previousResponseId = event.response.id;
|
previousResponseId = event.response.id;
|
||||||
@@ -143,9 +156,9 @@ websearch - eine Google Suche machen mit Schlüsselwörtern`
|
|||||||
try {
|
try {
|
||||||
args = JSON.parse(event.item.arguments);
|
args = JSON.parse(event.item.arguments);
|
||||||
} catch (e){
|
} catch (e){
|
||||||
console.error('Error parsing arguments:', e, event.item.arguments);
|
// console.error('Error parsing arguments:', e, event.item.arguments);
|
||||||
}
|
}
|
||||||
console.log(' function call:', id, name);
|
//console.log(' function call:', id, name);
|
||||||
functionCalls.push({ id, name, args, promise: toolsByFile[name].run(args) });
|
functionCalls.push({ id, name, args, promise: toolsByFile[name].run(args) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -156,7 +169,7 @@ websearch - eine Google Suche machen mit Schlüsselwörtern`
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Array.fromAsync(stream);
|
await Array.fromAsync(stream);
|
||||||
input=[];
|
input.length = 0;
|
||||||
|
|
||||||
for (const call of functionCalls) {
|
for (const call of functionCalls) {
|
||||||
//try {
|
//try {
|
||||||
@@ -171,7 +184,7 @@ websearch - eine Google Suche machen mit Schlüsselwörtern`
|
|||||||
// console.error('Error in function call:', call.name, err);
|
// console.error('Error in function call:', call.name, err);
|
||||||
//}
|
//}
|
||||||
}
|
}
|
||||||
}
|
}while(input.length > 0)
|
||||||
|
|
||||||
//console.log('OPENAI STREAM FINISHED');
|
//console.log('OPENAI STREAM FINISHED');
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
})()
|
||||||
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'node_modules',
|
||||||
|
'out1',
|
||||||
|
'dist',
|
||||||
|
'build'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'module',
|
||||||
|
globals: {
|
||||||
|
browser: true,
|
||||||
|
node: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// baseline rules; extend as needed
|
||||||
|
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
'no-undef': 'error',
|
||||||
|
'no-console': 'off'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
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",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "cli.js",
|
"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": {
|
"scripts": {
|
||||||
"start": "node cli.js",
|
"start": "node cli.js",
|
||||||
"start:ink": "tsx cli-ink.js",
|
"start:ink": "tsx cli-ink.js",
|
||||||
@@ -64,5 +15,18 @@
|
|||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"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).
|
||||||
|
|
||||||
43
scripts/jsx-loader.mjs
Normal file
43
scripts/jsx-loader.mjs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// ESM loader to transpile .jsx files on the fly using esbuild
|
||||||
|
// Usage: node --loader ./scripts/jsx-loader.mjs cli-ink.js
|
||||||
|
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||||
|
import { transform } from 'esbuild';
|
||||||
|
|
||||||
|
/** @typedef {import('node:module').LoadHook} LoadHook */
|
||||||
|
/** @typedef {import('node:module').ResolveHook} ResolveHook */
|
||||||
|
|
||||||
|
/** @type {ResolveHook} */
|
||||||
|
export async function resolve(specifier, context, nextResolve) {
|
||||||
|
// Defer to Node's default resolver for most cases
|
||||||
|
return nextResolve(specifier, context, nextResolve);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {LoadHook} */
|
||||||
|
export async function load(url, context, nextLoad) {
|
||||||
|
// Handle .jsx sources
|
||||||
|
if (url.endsWith('.jsx')) {
|
||||||
|
const filename = fileURLToPath(url);
|
||||||
|
const source = await readFile(filename, 'utf8');
|
||||||
|
|
||||||
|
const result = await transform(source, {
|
||||||
|
loader: 'jsx',
|
||||||
|
format: 'esm',
|
||||||
|
jsx: 'automatic',
|
||||||
|
sourcefile: filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
format: 'module',
|
||||||
|
source: result.code,
|
||||||
|
shortCircuit: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to default loader for everything else
|
||||||
|
return nextLoad(url, context, nextLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
9
scripts/register-jsx-loader.mjs
Normal file
9
scripts/register-jsx-loader.mjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Registers our custom JSX ESM loader using Node's stable register() API
|
||||||
|
import { register } from 'node:module';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
// Resolve relative to project root cwd
|
||||||
|
register('./scripts/jsx-loader.mjs', pathToFileURL('./'));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
136
src/terminalService.js
Normal file
136
src/terminalService.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import EventEmitter from 'events';
|
||||||
|
import pty from 'node-pty';
|
||||||
|
|
||||||
|
class TerminalService extends EventEmitter {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.lines = [];
|
||||||
|
this.partial = '';
|
||||||
|
this.ptyProcess = null;
|
||||||
|
this.started = false;
|
||||||
|
this.maxLines = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.started) return;
|
||||||
|
const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash';
|
||||||
|
const cols = process.stdout && process.stdout.columns ? process.stdout.columns : 120;
|
||||||
|
const rows = process.stdout && process.stdout.rows ? process.stdout.rows : 30;
|
||||||
|
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
const userShell = process.env.SHELL && !isWindows ? process.env.SHELL : null;
|
||||||
|
const shellPath = userShell || (isWindows ? 'powershell.exe' : '/bin/bash');
|
||||||
|
const args = ['--rcfile','rc'];
|
||||||
|
|
||||||
|
this.ptyProcess = pty.spawn(shellPath, args, {
|
||||||
|
name: 'xterm-256color',
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TERM: 'xterm-256color',
|
||||||
|
PS1: 'bash> '
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ptyProcess.onData((data) => {
|
||||||
|
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
|
||||||
|
if (this.lines.length > this.maxLines) {
|
||||||
|
this.lines.splice(0, this.lines.length - this.maxLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit lines including current partial to ensure prompts (no trailing newline) are visible
|
||||||
|
const display = this.partial ? [...this.lines, this.partial] : this.lines.slice();
|
||||||
|
this.emit('update', display);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize with the host TTY
|
||||||
|
const onResize = () => {
|
||||||
|
try {
|
||||||
|
const newCols = process.stdout.columns || cols;
|
||||||
|
const newRows = process.stdout.rows || rows;
|
||||||
|
this.ptyProcess.resize(newCols, newRows);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (process.stdout && process.stdout.on) {
|
||||||
|
process.stdout.on('resize', onResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ptyProcess.onExit(({ exitCode, signal }) => {
|
||||||
|
this.emit('exit', { exitCode, signal });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLines() {
|
||||||
|
return this.lines.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
write(input) {
|
||||||
|
if (!this.ptyProcess) return;
|
||||||
|
this.ptyProcess.write(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(columns, rows) {
|
||||||
|
if (!this.ptyProcess) return;
|
||||||
|
try {
|
||||||
|
const cols = Math.max(1, Number(columns) || 1);
|
||||||
|
const r = rows ? Math.max(1, Number(rows) || 1) : undefined;
|
||||||
|
if (r !== undefined) {
|
||||||
|
this.ptyProcess.resize(cols, r);
|
||||||
|
} else {
|
||||||
|
this.ptyProcess.resize(cols, this.ptyProcess.rows || 24);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
try {
|
||||||
|
if (this.ptyProcess) {
|
||||||
|
this.ptyProcess.kill();
|
||||||
|
this.ptyProcess = null;
|
||||||
|
}
|
||||||
|
this.started = false;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
restart() {
|
||||||
|
try { this.dispose(); } catch {}
|
||||||
|
try { this.start(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalService = new TerminalService();
|
||||||
|
export default terminalService;
|
||||||
|
|
||||||
|
|
||||||
@@ -1,18 +1,101 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
|
import uiService from './uiService.js';
|
||||||
import TextInput from 'ink-text-input';
|
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 {
|
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 '';
|
||||||
|
const str = String(input);
|
||||||
|
const ansiPattern = /\u001B\[[0-9;?]*[ -\/]*[@-~]/g; // ESC[ ... cmd
|
||||||
|
return str.replace(ansiPattern, '');
|
||||||
|
}
|
||||||
|
// Expand tab stops to spaces using 8-column tabs (like a typical terminal)
|
||||||
|
expandTabs(input, tabWidth, limit) {
|
||||||
|
const width = typeof tabWidth === 'number' && tabWidth > 0 ? tabWidth : 8;
|
||||||
|
const max = typeof limit === 'number' && limit > 0 ? limit : Infinity;
|
||||||
|
let col = 0;
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
const ch = input[i];
|
||||||
|
if (ch === '\t') {
|
||||||
|
const spaces = width - (col % width);
|
||||||
|
// If adding spaces exceeds max, clamp
|
||||||
|
const add = Math.min(spaces, Math.max(0, max - col));
|
||||||
|
out += ' '.repeat(add);
|
||||||
|
col += add;
|
||||||
|
if (col >= max) break;
|
||||||
|
} else {
|
||||||
|
// Treat as width 1 for simplicity (does not account for wide unicode)
|
||||||
|
if (col + 1 > max) break;
|
||||||
|
out += ch;
|
||||||
|
col += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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() {
|
render() {
|
||||||
const { title, lines } = this.props;
|
const { title, lines, maxWidth } = this.props;
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" borderStyle="round" paddingX={1} paddingY={0} flexGrow={1}>
|
<Box flexDirection="column" borderStyle="round" paddingX={1} paddingY={0} flexGrow={1} flexShrink={1} minWidth={0}>
|
||||||
<Text color="cyan">{title}</Text>
|
<Text color="cyan">{title}</Text>
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column" width="100%" flexShrink={1} minWidth={0}>
|
||||||
{(lines && lines.length > 0)
|
{(lines && lines.length > 0)
|
||||||
? lines.map((line, index) => (
|
? lines.map((line, index) => {
|
||||||
<Text key={index}>{line}</Text>
|
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>
|
: <Text dimColor>—</Text>
|
||||||
}
|
}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -29,53 +112,353 @@ export default class InkApp extends React.Component {
|
|||||||
logs: [],
|
logs: [],
|
||||||
terminal: [],
|
terminal: [],
|
||||||
chainOfThought: [],
|
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.handleSubmit = this.handleSubmit.bind(this);
|
||||||
this.handleChange = this.handleChange.bind(this);
|
this.handleChange = this.handleChange.bind(this);
|
||||||
|
this.setLLMOutput = this.setLLMOutput.bind(this);
|
||||||
|
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() { }
|
componentDidMount() {
|
||||||
componentWillUnmount() { }
|
this.terminalUnsub = (lines) => {
|
||||||
|
this.setTerminal(lines);
|
||||||
|
};
|
||||||
|
terminalService.on('update', this.terminalUnsub);
|
||||||
|
// initialize with current buffered output if any
|
||||||
|
const initial = terminalService.getLines();
|
||||||
|
if (initial && initial.length) {
|
||||||
|
this.setTerminal(initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize PTY columns to match the Terminal pane width on start and on TTY resize
|
||||||
|
this.onResize = () => {
|
||||||
|
const totalCols = (process && process.stdout && process.stdout.columns) ? process.stdout.columns : 80;
|
||||||
|
const columnWidth = Math.max(1, Math.floor(totalCols / 2));
|
||||||
|
const paneContentWidth = Math.max(1, columnWidth - 4); // borders + padding
|
||||||
|
terminalService.resize(paneContentWidth);
|
||||||
|
};
|
||||||
|
if (process.stdout && process.stdout.on) {
|
||||||
|
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) {
|
||||||
|
terminalService.off('update', this.terminalUnsub);
|
||||||
|
this.terminalUnsub = null;
|
||||||
|
}
|
||||||
|
if (this.onResize && process.stdout && process.stdout.off) {
|
||||||
|
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) {
|
||||||
|
if (typeof stateKey !== 'string' || !(stateKey in this.state)) {
|
||||||
|
throw new Error(`Invalid state key: ${String(stateKey)}`);
|
||||||
|
}
|
||||||
|
if (lines === undefined) {
|
||||||
|
this.setState({ [stateKey]: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(lines)) {
|
||||||
|
throw new TypeError(`Expected an array of lines or undefined for ${stateKey}`);
|
||||||
|
}
|
||||||
|
this.setState({ [stateKey]: lines });
|
||||||
|
}
|
||||||
|
|
||||||
|
setLLMOutput(lines) {
|
||||||
|
this.setPaneLines('llmOutput', lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
setChainOfThought(lines) {
|
||||||
|
this.setPaneLines('chainOfThought', lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTerminal(lines) {
|
||||||
|
this.setPaneLines('terminal', lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogs(lines) {
|
||||||
|
this.setPaneLines('logs', lines);
|
||||||
|
}
|
||||||
|
|
||||||
handleChange(value) {
|
handleChange(value) {
|
||||||
this.setState({ input: value });
|
this.setState({ input: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSubmit() {
|
async handleSubmit() {
|
||||||
const { input } = this.state;
|
const { input } = this.state;
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
this.setState((state) => ({
|
this.setState((state) => ({
|
||||||
logs: [...state.logs, `> ${input}`],
|
logs: [...state.logs, `> ${input}`],
|
||||||
terminal: [...state.terminal, `echo ${input}`],
|
input: '',
|
||||||
chainOfThought: [...state.chainOfThought, `(internal) Thought about: ${input}`],
|
isLoading: true
|
||||||
llmOutput: [...state.llmOutput, `Model says: ${input}`],
|
|
||||||
input: ''
|
|
||||||
}));
|
}));
|
||||||
|
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() {
|
render() {
|
||||||
const { input, logs, terminal, chainOfThought, llmOutput } = this.state;
|
const { input, logs, terminal, chainOfThought, llmOutput } = this.state;
|
||||||
|
const totalCols = (process && process.stdout && process.stdout.columns) ? process.stdout.columns : 80;
|
||||||
|
const totalRows = (process && process.stdout && process.stdout.rows) ? process.stdout.rows : 24;
|
||||||
|
const columnWidth = Math.max(1, Math.floor(totalCols / 2));
|
||||||
|
const paneContentWidth = Math.max(1, columnWidth - 4); // borders (2) + paddingX=1 on both sides (2)
|
||||||
|
|
||||||
|
// Compute how many lines fit vertically per pane
|
||||||
|
// Reserve ~2 rows for the input area (label + margin) at bottom
|
||||||
|
const reservedFooterRows = 2;
|
||||||
|
const panesAreaRows = Math.max(4, totalRows - reservedFooterRows);
|
||||||
|
const paneOuterHeight = Math.max(4, Math.floor(panesAreaRows / 2));
|
||||||
|
// Remove top/bottom border (2) and title line (1)
|
||||||
|
const paneContentHeight = Math.max(0, paneOuterHeight - 3);
|
||||||
|
|
||||||
|
const sliceLast = (arr) => {
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
|
if (paneContentHeight <= 0) return [];
|
||||||
|
if (arr.length <= paneContentHeight) return arr;
|
||||||
|
return arr.slice(arr.length - paneContentHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const llmOutputView = sliceLast(llmOutput);
|
||||||
|
const chainOfThoughtView = sliceLast(chainOfThought);
|
||||||
|
const terminalView = sliceLast(terminal);
|
||||||
|
const logsView = sliceLast(logs);
|
||||||
|
|
||||||
|
const menuItems = this.getMenuItems();
|
||||||
|
const selected = this.state.menuIndex;
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" height="100%">
|
<Box flexDirection="column" height="100%">
|
||||||
<Box flexGrow={1} flexDirection="row">
|
<Box flexGrow={1} flexDirection="row" minWidth={0}>
|
||||||
<Box flexGrow={1} flexDirection="column">
|
<Box flexGrow={1} flexDirection="column" minWidth={0}>
|
||||||
<Pane title="LLM Output" lines={llmOutput} />
|
<Pane title="LLM Output" lines={llmOutputView} maxWidth={paneContentWidth} />
|
||||||
<Pane title="Chain of Thought" lines={chainOfThought} />
|
<Pane title="Chain of Thought" lines={chainOfThoughtView} maxWidth={paneContentWidth} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box flexGrow={1} flexDirection="column">
|
<Box flexGrow={1} flexDirection="column" minWidth={0}>
|
||||||
<Pane title="Terminal" lines={terminal} />
|
<Pane title="Terminal" lines={terminalView} maxWidth={paneContentWidth} showCursor cursorBlinkMs={600} />
|
||||||
<Pane title="Logging" lines={logs} />
|
<Pane title="Logging" lines={logsView} maxWidth={paneContentWidth} />
|
||||||
</Box>
|
</Box>
|
||||||
</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}>
|
<Box marginTop={1}>
|
||||||
<Text>Input: </Text>
|
<Text>Input: </Text>
|
||||||
<TextInput
|
{this.state.isLoading ? (
|
||||||
value={input}
|
<Text color="yellow">{npmSpinnerFrames[this.state.spinnerIndex]} Processing...</Text>
|
||||||
onChange={this.handleChange}
|
) : (
|
||||||
onSubmit={this.handleSubmit}
|
<TextInput
|
||||||
placeholder="Type and press Enter..."
|
value={input}
|
||||||
/>
|
onChange={this.handleChange}
|
||||||
|
onSubmit={this.handleSubmit}
|
||||||
|
placeholder="Type and press Enter..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</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 { promises as fs } from "node:fs";
|
||||||
import path from "node:path";
|
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) => {
|
const normalizePath = (inputPath, chrootDir) => {
|
||||||
// Resolve chroot directory
|
// Resolve chroot directory
|
||||||
const chrootResolved = path.resolve(chrootDir);
|
const chrootResolved = path.resolve(chrootDir);
|
||||||
@@ -17,12 +17,19 @@ const normalizePath = (inputPath, chrootDir) => {
|
|||||||
|
|
||||||
// Ensure the path is within chrootDir
|
// Ensure the path is within chrootDir
|
||||||
if (!normalized.startsWith(chrootResolved)) {
|
if (!normalized.startsWith(chrootResolved)) {
|
||||||
throw new Error(`Path escapes chroot boundary: ${inputPath}`);
|
throw new Error(`Path escapes root boundary: ${inputPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
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
|
// Main recursive directory listing function
|
||||||
async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden = false) {
|
async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden = false) {
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -35,7 +42,7 @@ async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden
|
|||||||
try {
|
try {
|
||||||
dirents = await fs.readdir(currentDir, { withFileTypes: true });
|
dirents = await fs.readdir(currentDir, { withFileTypes: true });
|
||||||
} catch (err) {
|
} 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) {
|
for (const dirent of dirents) {
|
||||||
@@ -91,39 +98,39 @@ async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden
|
|||||||
export default {
|
export default {
|
||||||
type: "function",
|
type: "function",
|
||||||
name: "list_files",
|
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: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
path: {
|
path: {
|
||||||
type: "string",
|
type: ["string", "null"],
|
||||||
description: "Directory or file path to list relative to chroot. Use '/' for the chroot root. Defaults to chroot root if not specified.",
|
description: "Directory or file path relative to the root. Use '/' for the root. Defaults to root if not specified.",
|
||||||
},
|
},
|
||||||
depth: {
|
depth: {
|
||||||
type: "integer",
|
type: ["integer", "null"],
|
||||||
description: "Maximum subdirectory levels to traverse. Use -1 for unlimited depth. Defaults to 1.",
|
description: "Maximum subdirectory levels to traverse. Use -1 for unlimited depth. Defaults to 1.",
|
||||||
minimum: -1,
|
minimum: -1,
|
||||||
},
|
},
|
||||||
includeHidden: {
|
includeHidden: {
|
||||||
type: "boolean",
|
type: ["boolean", "null"],
|
||||||
description: "Whether to include hidden files and directories (starting with '.'). Defaults to false.",
|
description: "Whether to include hidden files and directories (starting with '.'). Defaults to false.",
|
||||||
default: false,
|
default: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
required: ["path", "depth", "includeHidden"],
|
required: [],
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
strict: true,
|
strict: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function run(args) {
|
export async function run(args) {
|
||||||
const inputPath = args?.path || "";
|
const inputPath = args?.path || "";
|
||||||
const depth = Number.isInteger(args?.depth) ? args.depth : 1;
|
const depth = Number.isInteger(args?.depth) ? args.depth : 1;
|
||||||
const includeHidden = args?.includeHidden ?? false;
|
const includeHidden = args?.includeHidden ?? false;
|
||||||
const chrootPath = '/home/seb/src/aiTools/tmp';
|
const chrootPath = '/workspaces/aiTools/root';
|
||||||
|
|
||||||
if (!chrootPath) {
|
if (!chrootPath) {
|
||||||
return { err: "Chroot path is required" };
|
return { err: "Root path is required" };
|
||||||
}
|
}
|
||||||
if (depth < -1) {
|
if (depth < -1) {
|
||||||
return { err: `Depth must be >= -1, received ${args?.depth}` };
|
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
|
// Auto-create the chroot base directory if it does not exist
|
||||||
await fs.mkdir(chrootResolved, { recursive: true });
|
await fs.mkdir(chrootResolved, { recursive: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { err: `Failed to prepare chroot path: ${chrootPath} (${err?.message || String(err)})` };
|
return { err: "Failed to initialize root directory" };
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolvedBase;
|
let resolvedBase;
|
||||||
@@ -149,10 +156,13 @@ export async function run(args) {
|
|||||||
try {
|
try {
|
||||||
stat = await fs.lstat(resolvedBase);
|
stat = await fs.lstat(resolvedBase);
|
||||||
} catch (err) {
|
} 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
|
// Handle single file case
|
||||||
if (stat.isFile()) {
|
if (stat.isFile()) {
|
||||||
@@ -160,22 +170,27 @@ export async function run(args) {
|
|||||||
if (!includeHidden && fileName.startsWith(".")) {
|
if (!includeHidden && fileName.startsWith(".")) {
|
||||||
return { cwd, files: [] };
|
return { cwd, files: [] };
|
||||||
}
|
}
|
||||||
return { cwd, files: JSON.stringify([[fileName, 'f', stat.size]]) };
|
// Return structured object for easier machine parsing
|
||||||
|
return { cwd, files: [{ path: fileName, type: 'f', size: stat.size }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle non-directory case
|
// Handle non-directory case
|
||||||
if (!stat.isDirectory()) {
|
if (!stat.isDirectory()) {
|
||||||
return { err: `Not a file or directory: ${resolvedBase}` };
|
return { err: `Not a file or directory${inputPath ? `: ${inputPath}` : ""}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle directory case
|
// Handle directory case
|
||||||
try {
|
try {
|
||||||
const files = await listEntriesRecursive(resolvedBase, chrootResolved, depth === -1 ? Infinity : depth, includeHidden);
|
const files = await listEntriesRecursive(resolvedBase, chrootResolved, depth === -1 ? Infinity : depth, includeHidden);
|
||||||
|
// Map to structured objects and sort by path for consistent output
|
||||||
|
const mapped = files
|
||||||
|
.map(([p, t, s]) => ({ path: p, type: t, size: s }))
|
||||||
|
.sort((a, b) => a.path.localeCompare(b.path));
|
||||||
return {
|
return {
|
||||||
cwd,
|
cwd,
|
||||||
files: JSON.stringify(files.sort((a, b) => a[0].localeCompare(b[0]))), // Sort for consistent output
|
files: mapped,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} 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
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
const desc = `
|
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,
|
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.
|
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":
|
To use the 'apply_patch' command, you should pass a message of the following structure as "input":
|
||||||
|
|
||||||
*** Begin Patch
|
*** Begin Patch
|
||||||
[YOUR_PATH]
|
[YOUR_PATCH]
|
||||||
*** End Patch
|
*** End Patch
|
||||||
|
|
||||||
Where [YOUR_PATCH] is the actual content of your patch, specified in the following V4A diff format.
|
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]
|
+ [new_code]
|
||||||
[3 lines of post-context]
|
[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
|
// Domain objects
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
@@ -117,10 +128,10 @@ function resolvePath(chroot, filepath) {
|
|||||||
|
|
||||||
const root = normalizePath(chroot);
|
const root = normalizePath(chroot);
|
||||||
|
|
||||||
// If file is absolute, use it as-is (after normalization).
|
// If file is absolute, resolve it under the chroot rather than using host FS root
|
||||||
// We assume the caller ensures it is inside the chroot.
|
|
||||||
if (file.startsWith('/')) {
|
if (file.startsWith('/')) {
|
||||||
return file;
|
const resolvedAbs = joinPaths(root, file);
|
||||||
|
return resolvedAbs.startsWith('/') ? resolvedAbs : '/' + resolvedAbs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If file is relative, join with chroot
|
// If file is relative, join with chroot
|
||||||
@@ -229,9 +240,9 @@ class Parser {
|
|||||||
if (path) {
|
if (path) {
|
||||||
// Resolve path with chroot
|
// Resolve path with chroot
|
||||||
const resolvedPath = resolvePath(this.chroot, path);
|
const resolvedPath = resolvePath(this.chroot, path);
|
||||||
|
// Use user-specified path as the key to avoid double-resolving later
|
||||||
if (resolvedPath in this.patch.actions) {
|
if (path in this.patch.actions) {
|
||||||
throw new DiffError(`Duplicate update for file: ${resolvedPath}`);
|
throw new DiffError(`Duplicate update for file: ${path}`);
|
||||||
}
|
}
|
||||||
const move_to = this.read_str("*** Move to: ");
|
const move_to = this.read_str("*** Move to: ");
|
||||||
if (!(resolvedPath in this.current_files)) {
|
if (!(resolvedPath in this.current_files)) {
|
||||||
@@ -239,8 +250,8 @@ class Parser {
|
|||||||
}
|
}
|
||||||
const text = this.current_files[resolvedPath];
|
const text = this.current_files[resolvedPath];
|
||||||
const action = this._parse_update_file(text);
|
const action = this._parse_update_file(text);
|
||||||
action.move_path = move_to ? resolvePath(this.chroot, move_to) : null;
|
action.move_path = move_to ? move_to : null;
|
||||||
this.patch.actions[resolvedPath] = action;
|
this.patch.actions[path] = action;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,14 +260,13 @@ class Parser {
|
|||||||
if (path) {
|
if (path) {
|
||||||
// Resolve path with chroot
|
// Resolve path with chroot
|
||||||
const resolvedPath = resolvePath(this.chroot, path);
|
const resolvedPath = resolvePath(this.chroot, path);
|
||||||
|
if (path in this.patch.actions) {
|
||||||
if (resolvedPath in this.patch.actions) {
|
throw new DiffError(`Duplicate delete for file: ${path}`);
|
||||||
throw new DiffError(`Duplicate delete for file: ${resolvedPath}`);
|
|
||||||
}
|
}
|
||||||
if (!(resolvedPath in this.current_files)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,14 +275,13 @@ class Parser {
|
|||||||
if (path) {
|
if (path) {
|
||||||
// Resolve path with chroot
|
// Resolve path with chroot
|
||||||
const resolvedPath = resolvePath(this.chroot, path);
|
const resolvedPath = resolvePath(this.chroot, path);
|
||||||
|
if (path in this.patch.actions) {
|
||||||
if (resolvedPath in this.patch.actions) {
|
throw new DiffError(`Duplicate add for file: ${path}`);
|
||||||
throw new DiffError(`Duplicate add for file: ${resolvedPath}`);
|
|
||||||
}
|
}
|
||||||
if (resolvedPath in this.current_files) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +370,9 @@ class Parser {
|
|||||||
if (!s.startsWith("+")) {
|
if (!s.startsWith("+")) {
|
||||||
throw new DiffError(`Invalid Add File line (missing '+'): ${s}`);
|
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"));
|
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) {
|
function patch_to_commit(patch, orig, chroot = null) {
|
||||||
const commit = new Commit();
|
const commit = new Commit();
|
||||||
for (const [path, action] of Object.entries(patch.actions)) {
|
for (const [path, action] of Object.entries(patch.actions)) {
|
||||||
|
const resolvedPath = resolvePath(chroot, path);
|
||||||
if (action.type === ActionType.DELETE) {
|
if (action.type === ActionType.DELETE) {
|
||||||
commit.changes[path] = new FileChange(
|
commit.changes[path] = new FileChange(
|
||||||
ActionType.DELETE,
|
ActionType.DELETE,
|
||||||
orig[path],
|
orig[resolvedPath],
|
||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -621,11 +633,11 @@ function patch_to_commit(patch, orig, chroot = null) {
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
} else if (action.type === ActionType.UPDATE) {
|
} 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;
|
const move_path = action.move_path ? unresolvePath(chroot, action.move_path) : null;
|
||||||
commit.changes[path] = new FileChange(
|
commit.changes[path] = new FileChange(
|
||||||
ActionType.UPDATE,
|
ActionType.UPDATE,
|
||||||
orig[path],
|
orig[resolvedPath],
|
||||||
new_content,
|
new_content,
|
||||||
move_path
|
move_path
|
||||||
);
|
);
|
||||||
@@ -721,20 +733,23 @@ function load_files(paths, open_fn) {
|
|||||||
function apply_commit(commit, write_fn, remove_fn, chroot = null) {
|
function apply_commit(commit, write_fn, remove_fn, chroot = null) {
|
||||||
for (const [path, change] of Object.entries(commit.changes)) {
|
for (const [path, change] of Object.entries(commit.changes)) {
|
||||||
if (change.type === ActionType.DELETE) {
|
if (change.type === ActionType.DELETE) {
|
||||||
remove_fn(path);
|
const target = resolvePath(chroot, path);
|
||||||
|
remove_fn(target);
|
||||||
} else if (change.type === ActionType.ADD) {
|
} else if (change.type === ActionType.ADD) {
|
||||||
if (change.new_content === null) {
|
if (change.new_content === null) {
|
||||||
throw new DiffError(`ADD change for ${path} has no content`);
|
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) {
|
} else if (change.type === ActionType.UPDATE) {
|
||||||
if (change.new_content === null) {
|
if (change.new_content === null) {
|
||||||
throw new DiffError(`UPDATE change for ${path} has no new content`);
|
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);
|
write_fn(target, change.new_content);
|
||||||
if (change.move_path) {
|
if (change.move_path) {
|
||||||
remove_fn(path);
|
const source = resolvePath(chroot, path);
|
||||||
|
remove_fn(source);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -786,7 +801,7 @@ function remove_file(filepath) {
|
|||||||
export default {
|
export default {
|
||||||
type: "function",
|
type: "function",
|
||||||
name: "patch_files",
|
name: "patch_files",
|
||||||
description: "Apply a unified diff patch " + desc,
|
description: "Generic Text File Editor create,edit,delete - Apply a unified diff patch " + desc,
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -808,10 +823,11 @@ export async function run(args) {
|
|||||||
open_file,
|
open_file,
|
||||||
write_file,
|
write_file,
|
||||||
remove_file,
|
remove_file,
|
||||||
'/home/seb/src/aiTools/tmp'
|
'/workspaces/aiTools/root'
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log(chalk.red('Patch error:'),error);
|
||||||
return `Patch error: ${error.message}`
|
return `Patch error: ${error.message}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
import { createReadStream } from "node:fs";
|
import { createReadStream } from "node:fs";
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const virtual_chroot = '/home/seb/src/aiTools/tmp';
|
const virtual_chroot = '/workspaces/aiTools/root';
|
||||||
|
|
||||||
// Ensures reads are confined to `virtual_chroot`.
|
// Ensures reads are confined to `virtual_chroot`.
|
||||||
|
|
||||||
export default {
|
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: {
|
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.", },
|
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 },
|
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", description: "1-400 The number of lines to read. Use 0 or more than 400 to read 400 lines.", 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)
|
// Normalize linesToRead (1-400, with 0 or >400 meaning 400)
|
||||||
const maxLines = (linesToRead <= 0 || linesToRead > 400) ? 400 : linesToRead;
|
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 {
|
try {
|
||||||
|
|
||||||
@@ -51,7 +56,7 @@ export async function run(args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join('\n');
|
return 'Filecontent: ´´´'+lines.join('\n')+'´´´';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return `read_file error: ${error.message}`;
|
return `read_file error: ${error.message}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
const virtual_chroot = '/home/seb/src/aiTools/tmp';
|
const virtual_chroot = '/workspaces/aiTools/root';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
type: "function", name: "ripgrep", strict: true,
|
type: "function", name: "ripgrep", strict: true,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
import Exa from "exa-js";
|
import Exa from "exa-js";
|
||||||
const exa = new Exa("1513ba88-5280-402b-9da3-e060d38f96d8");
|
|
||||||
|
const exaApiKey = process.env.EXA_API_KEY;
|
||||||
|
if (!exaApiKey) {
|
||||||
|
throw new Error("Missing EXA_API_KEY environment variable for websearch");
|
||||||
|
}
|
||||||
|
const exa = new Exa(exaApiKey);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
type: 'function',
|
type: 'function',
|
||||||
|
|||||||
30
tools/wget.js
Normal file
30
tools/wget.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export async function run(args){
|
||||||
|
const { url } = params;
|
||||||
|
if (!url) throw new Error('missing url');
|
||||||
|
const res = await fetch(url);
|
||||||
|
const buffer = await res.buffer();
|
||||||
|
const filename = new Date().getTime() + '.' + url.split('.').pop();
|
||||||
|
const content = buffer.slice(0, 500).toString('utf8');
|
||||||
|
// 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
|
||||||
|
export default {
|
||||||
|
type: 'function',
|
||||||
|
name: 'wget',
|
||||||
|
description: 'Download URL to filesystem',
|
||||||
|
strict: true,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['url'],
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
url: { type: 'string', description: 'The url to get.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user