diff --git a/cli2.js b/cli2.js index 434bd2a..f276b26 100644 --- a/cli2.js +++ b/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); diff --git a/modelDialog.js b/modelDialog.js index 5040091..20269b2 100644 --- a/modelDialog.js +++ b/modelDialog.js @@ -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; \ No newline at end of file +export default ModelDialog; diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..e7e8ca3 --- /dev/null +++ b/plan.md @@ -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 `' + + ''; + + 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). + diff --git a/src/ui/InkApp.jsx b/src/ui/InkApp.jsx index 068aca3..3d7ac4e 100644 --- a/src/ui/InkApp.jsx +++ b/src/ui/InkApp.jsx @@ -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 { )} Input: - + {this.state.isLoading ? ( + {npmSpinnerFrames[this.state.spinnerIndex]} Processing... + ) : ( + + )} ); diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..0fabfff --- /dev/null +++ b/todo.md @@ -0,0 +1,2 @@ +return the function call result via event. +display function call evenst in logging diff --git a/tools/read_file.js b/tools/read_file.js index 84ff6ba..407dcc7 100644 --- a/tools/read_file.js +++ b/tools/read_file.js @@ -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}`; }