Update CLI and ModelDialog to enhance functionality and user experience. Modify interrogation command in CLI for improved output generation, adjust model settings in ModelDialog for better reasoning effort, and introduce a new plugin structure in plan.md for LLM integration in Roundcube. Add spinner functionality in InkApp for loading states and improve error handling in read_file.js to ensure proper line breaks in file content output.
This commit is contained in:
2
cli2.js
2
cli2.js
@@ -24,7 +24,7 @@ modelDialog.on('reasoningUpdate', (output) => {
|
||||
//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('Ersttelle eine beispiel business webseite für acme mit react und webpack. Lege die Dateien in /demo an');
|
||||
const output2 = await modelDialog.interrogate('lege 5 Dateinen an, mache in jede eine webseite mit einem farbverlauf.');
|
||||
console.log('final output:',output2.output);
|
||||
console.log('reasoning:',output2.reasoning);
|
||||
console.log('Tokens:',output2.inputTokens,output2.cachedTokens,output2.outputTokens);
|
||||
|
||||
@@ -88,10 +88,10 @@ class ModelDialog {
|
||||
this.messagesSent.push(...messagesToSend);
|
||||
|
||||
const call = {
|
||||
model: 'gpt-5-nano',
|
||||
model: 'gpt-5',
|
||||
input: messagesToSend,
|
||||
text: { format: { type: 'text' }, verbosity: 'low' },
|
||||
reasoning: { effort: 'medium', summary: 'detailed' },
|
||||
reasoning: { effort: 'low', summary: 'detailed' },
|
||||
tools: Object.values(toolsByFile).map(t => t.def),
|
||||
store: true,
|
||||
previous_response_id: this.previousResponseId
|
||||
@@ -174,4 +174,4 @@ class ModelDialog {
|
||||
}
|
||||
}
|
||||
|
||||
export default ModelDialog;
|
||||
export default ModelDialog;
|
||||
|
||||
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).
|
||||
|
||||
@@ -3,6 +3,10 @@ import { Box, Text } from 'ink';
|
||||
import uiService from './uiService.js';
|
||||
import TextInput from 'ink-text-input';
|
||||
import terminalService from '../terminalService.js';
|
||||
import ModelDialog from '../../modelDialog.js';
|
||||
|
||||
const sharedModelDialog = new ModelDialog();
|
||||
const npmSpinnerFrames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
||||
|
||||
class Pane extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -113,7 +117,9 @@ export default class InkApp extends React.Component {
|
||||
menuIndex: 0,
|
||||
model: 'gpt-5',
|
||||
reasoningEffort: 'minimal',
|
||||
outputVerbosity: 'low'
|
||||
outputVerbosity: 'low',
|
||||
isLoading: false,
|
||||
spinnerIndex: 0
|
||||
};
|
||||
this.handleSubmit = this.handleSubmit.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 {}
|
||||
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) {
|
||||
@@ -169,6 +181,10 @@ export default class InkApp extends React.Component {
|
||||
if (process.stdin && process.stdin.off) {
|
||||
process.stdin.off('data', this.onKeypress);
|
||||
}
|
||||
if (this._spinnerTimer) {
|
||||
clearInterval(this._spinnerTimer);
|
||||
this._spinnerTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
setPaneLines(stateKey, lines) {
|
||||
@@ -205,21 +221,42 @@ export default class InkApp extends React.Component {
|
||||
this.setState({ input: value });
|
||||
}
|
||||
|
||||
handleSubmit() {
|
||||
async handleSubmit() {
|
||||
const { input } = this.state;
|
||||
if (!input) return;
|
||||
try {
|
||||
terminalService.write(`${input}\r`);
|
||||
} catch (e) {
|
||||
// do not hide errors; show in logs
|
||||
this.setState((state) => ({
|
||||
logs: [...state.logs, `! write error: ${String(e && e.message ? e.message : e)}`],
|
||||
}));
|
||||
}
|
||||
this.setState((state) => ({
|
||||
logs: [...state.logs, `> ${input}`],
|
||||
input: ''
|
||||
input: '',
|
||||
isLoading: true
|
||||
}));
|
||||
try {
|
||||
const result = await sharedModelDialog.interrogate(input);
|
||||
const finalOutput = Array.isArray(result && result.output) ? result.output : [String(result && result.output ? result.output : '')];
|
||||
const finalReasoning = Array.isArray(result && result.reasoning) ? result.reasoning : (result && result.reasoning ? [String(result.reasoning)] : []);
|
||||
// Append to LLM output with a separator, overwrite chain of thought
|
||||
this.setState((state) => ({
|
||||
llmOutput: [
|
||||
...state.llmOutput,
|
||||
...(state.llmOutput.length ? ['----------'] : []),
|
||||
...finalOutput
|
||||
]
|
||||
}));
|
||||
this.setChainOfThought(finalReasoning);
|
||||
this.setState((state) => ({
|
||||
logs: [
|
||||
...state.logs,
|
||||
`tokens input: ${JSON.stringify(result && result.inputTokens)}`,
|
||||
`tokens cached: ${JSON.stringify(result && result.cachedTokens)}`,
|
||||
`tokens output: ${JSON.stringify(result && result.outputTokens)}`
|
||||
]
|
||||
}));
|
||||
} catch (e) {
|
||||
this.setState((state) => ({
|
||||
logs: [...state.logs, `! interrogate error: ${String(e && e.message ? e.message : e)}`]
|
||||
}));
|
||||
} finally {
|
||||
this.setState({ isLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
toggleMenu(open) {
|
||||
@@ -412,12 +449,16 @@ export default class InkApp extends React.Component {
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>Input: </Text>
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={this.handleChange}
|
||||
onSubmit={this.handleSubmit}
|
||||
placeholder="Type and press Enter..."
|
||||
/>
|
||||
{this.state.isLoading ? (
|
||||
<Text color="yellow">{npmSpinnerFrames[this.state.spinnerIndex]} Processing...</Text>
|
||||
) : (
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={this.handleChange}
|
||||
onSubmit={this.handleSubmit}
|
||||
placeholder="Type and press Enter..."
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
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
|
||||
@@ -56,7 +56,7 @@ export async function run(args) {
|
||||
}
|
||||
}
|
||||
|
||||
return 'Filecontent: ´´´'+lines.join('')+'´´´';
|
||||
return 'Filecontent: ´´´'+lines.join('\n')+'´´´';
|
||||
} catch (error) {
|
||||
return `read_file error: ${error.message}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user