Compare commits

...

3 Commits

8 changed files with 433 additions and 26 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules node_modules
.env .env
tmp tmp
root

View File

@@ -24,7 +24,7 @@ modelDialog.on('reasoningUpdate', (output) => {
//const output = await modelDialog.interrogate('Can you remember "seven" ?'); //const output = await modelDialog.interrogate('Can you remember "seven" ?');
//console.log(output.output,JSON.stringify(output.reasoning,null,2)); //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('read a file that is what you remebered plus 1 as a word with txt ending, check that file.');
const output2 = await modelDialog.interrogate('Ersttelle eine beispiel business webseite für acme mit react und webpack. Lege die Dateien in /demo an'); const output2 = await modelDialog.interrogate('schaue welche Dateien du findet und wenn du webseiten findest, invertiere die farben in ihnen.');
console.log('final output:',output2.output); console.log('final output:',output2.output);
console.log('reasoning:',output2.reasoning); console.log('reasoning:',output2.reasoning);
console.log('Tokens:',output2.inputTokens,output2.cachedTokens,output2.outputTokens); console.log('Tokens:',output2.inputTokens,output2.cachedTokens,output2.outputTokens);

View File

@@ -88,10 +88,10 @@ class ModelDialog {
this.messagesSent.push(...messagesToSend); this.messagesSent.push(...messagesToSend);
const call = { const call = {
model: 'gpt-5-nano', model: 'gpt-5',
input: messagesToSend, input: messagesToSend,
text: { format: { type: 'text' }, verbosity: 'low' }, text: { format: { type: 'text' }, verbosity: 'low' },
reasoning: { effort: 'medium', summary: 'detailed' }, reasoning: { effort: 'low', summary: 'detailed' },
tools: Object.values(toolsByFile).map(t => t.def), tools: Object.values(toolsByFile).map(t => t.def),
store: true, store: true,
previous_response_id: this.previousResponseId previous_response_id: this.previousResponseId

359
plan.md Normal file
View 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.
Heres 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).

View File

@@ -3,6 +3,10 @@ import { Box, Text } from 'ink';
import uiService from './uiService.js'; import uiService from './uiService.js';
import TextInput from 'ink-text-input'; import TextInput from 'ink-text-input';
import terminalService from '../terminalService.js'; 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) { constructor(props) {
@@ -113,7 +117,9 @@ export default class InkApp extends React.Component {
menuIndex: 0, menuIndex: 0,
model: 'gpt-5', model: 'gpt-5',
reasoningEffort: 'minimal', reasoningEffort: 'minimal',
outputVerbosity: 'low' 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);
@@ -156,6 +162,12 @@ export default class InkApp extends React.Component {
try { process.stdin.setRawMode(true); } catch {} try { process.stdin.setRawMode(true); } catch {}
process.stdin.on('data', this.onKeypress); 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() { componentWillUnmount() {
if (this.terminalUnsub) { if (this.terminalUnsub) {
@@ -169,6 +181,10 @@ export default class InkApp extends React.Component {
if (process.stdin && process.stdin.off) { if (process.stdin && process.stdin.off) {
process.stdin.off('data', this.onKeypress); process.stdin.off('data', this.onKeypress);
} }
if (this._spinnerTimer) {
clearInterval(this._spinnerTimer);
this._spinnerTimer = null;
}
} }
setPaneLines(stateKey, lines) { setPaneLines(stateKey, lines) {
@@ -205,21 +221,42 @@ export default class InkApp extends React.Component {
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;
try {
terminalService.write(`${input}\r`);
} catch (e) {
// do not hide errors; show in logs
this.setState((state) => ({
logs: [...state.logs, `! write error: ${String(e && e.message ? e.message : e)}`],
}));
}
this.setState((state) => ({ this.setState((state) => ({
logs: [...state.logs, `> ${input}`], logs: [...state.logs, `> ${input}`],
input: '' input: '',
isLoading: true
})); }));
try {
const result = await sharedModelDialog.interrogate(input);
const finalOutput = Array.isArray(result && result.output) ? result.output : [String(result && result.output ? result.output : '')];
const finalReasoning = Array.isArray(result && result.reasoning) ? result.reasoning : (result && result.reasoning ? [String(result.reasoning)] : []);
// Append to LLM output with a separator, overwrite chain of thought
this.setState((state) => ({
llmOutput: [
...state.llmOutput,
...(state.llmOutput.length ? ['----------'] : []),
...finalOutput
]
}));
this.setChainOfThought(finalReasoning);
this.setState((state) => ({
logs: [
...state.logs,
`tokens input: ${JSON.stringify(result && result.inputTokens)}`,
`tokens cached: ${JSON.stringify(result && result.cachedTokens)}`,
`tokens output: ${JSON.stringify(result && result.outputTokens)}`
]
}));
} catch (e) {
this.setState((state) => ({
logs: [...state.logs, `! interrogate error: ${String(e && e.message ? e.message : e)}`]
}));
} finally {
this.setState({ isLoading: false });
}
} }
toggleMenu(open) { toggleMenu(open) {
@@ -412,12 +449,16 @@ export default class InkApp extends React.Component {
)} )}
<Box marginTop={1}> <Box marginTop={1}>
<Text>Input: </Text> <Text>Input: </Text>
{this.state.isLoading ? (
<Text color="yellow">{npmSpinnerFrames[this.state.spinnerIndex]} Processing...</Text>
) : (
<TextInput <TextInput
value={input} value={input}
onChange={this.handleChange} onChange={this.handleChange}
onSubmit={this.handleSubmit} onSubmit={this.handleSubmit}
placeholder="Type and press Enter..." placeholder="Type and press Enter..."
/> />
)}
</Box> </Box>
</Box> </Box>
); );

2
todo.md Normal file
View File

@@ -0,0 +1,2 @@
return the function call result via event.
display function call evenst in logging

View File

@@ -1,5 +1,7 @@
#!/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.
@@ -612,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
); );
@@ -630,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
); );
@@ -824,6 +827,7 @@ export async function run(args) {
); );
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}`
} }
} }

View File

@@ -56,7 +56,7 @@ export async function run(args) {
} }
} }
return 'Filecontent: ´´´'+lines.join('')+'´´´'; return 'Filecontent: ´´´'+lines.join('\n')+'´´´';
} catch (error) { } catch (error) {
return `read_file error: ${error.message}`; return `read_file error: ${error.message}`;
} }