blyuecode 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/agent.js +6 -4
- package/dist/core/config.js +33 -3
- package/dist/core/n8n.js +85 -0
- package/dist/core/tools.js +100 -9
- package/dist/ui/Chat.js +32 -10
- package/dist/ui/Settings.js +143 -0
- package/package.json +1 -1
package/dist/core/agent.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Provider } from './provider.js';
|
|
2
|
-
import {
|
|
2
|
+
import { getToolDefs, executeTool } from './tools.js';
|
|
3
3
|
export class Agent {
|
|
4
4
|
provider = null;
|
|
5
5
|
getProvider() {
|
|
@@ -11,14 +11,16 @@ export class Agent {
|
|
|
11
11
|
{
|
|
12
12
|
role: 'system',
|
|
13
13
|
content: 'You are BLYUE, a coding assistant running in a terminal. ' +
|
|
14
|
-
'Use tools to read, write, and run commands.
|
|
15
|
-
'
|
|
14
|
+
'Use tools to read, write, and run commands. ' +
|
|
15
|
+
'If n8n tools are available, you can also manage n8n workflows. ' +
|
|
16
|
+
'Always explain what you are doing. Be concise.',
|
|
16
17
|
},
|
|
17
18
|
];
|
|
18
19
|
async *run(userInput) {
|
|
19
20
|
this.messages.push({ role: 'user', content: userInput });
|
|
20
21
|
while (true) {
|
|
21
|
-
const
|
|
22
|
+
const tools = getToolDefs();
|
|
23
|
+
const res = await this.getProvider().chat(this.messages, tools);
|
|
22
24
|
if (res.content)
|
|
23
25
|
yield res.content;
|
|
24
26
|
if (res.toolCalls.length === 0)
|
package/dist/core/config.js
CHANGED
|
@@ -3,9 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
const GLOBAL_DIR = path.join(os.homedir(), '.blyue');
|
|
5
5
|
const GLOBAL_ENV = path.join(GLOBAL_DIR, '.env');
|
|
6
|
-
// Anything matching this = not a real key
|
|
7
6
|
const PLACEHOLDER = /your[-_]?key|placeholder|^sk-your|^xxx|^sk-xxx|here$/i;
|
|
8
|
-
/** Only look at ~/.blyue/.env (global, works from any cwd) */
|
|
9
7
|
export function loadConfig() {
|
|
10
8
|
if (!fs.existsSync(GLOBAL_ENV))
|
|
11
9
|
return null;
|
|
@@ -20,7 +18,7 @@ function parseEnv(file) {
|
|
|
20
18
|
const text = fs.readFileSync(file, 'utf8');
|
|
21
19
|
const map = {};
|
|
22
20
|
for (const line of text.split('\n')) {
|
|
23
|
-
const m = line.match(/^\s*([A-
|
|
21
|
+
const m = line.match(/^\s*([A-Z_0-9]+)\s*=\s*(.*)\s*$/);
|
|
24
22
|
if (m)
|
|
25
23
|
map[m[1]] = m[2].trim();
|
|
26
24
|
}
|
|
@@ -28,6 +26,8 @@ function parseEnv(file) {
|
|
|
28
26
|
apiKey: map.OPENAI_API_KEY || '',
|
|
29
27
|
baseUrl: map.OPENAI_BASE_URL || undefined,
|
|
30
28
|
model: map.MODEL || 'gpt-4o-mini',
|
|
29
|
+
n8nUrl: map.N8N_URL || undefined,
|
|
30
|
+
n8nApiKey: map.N8N_API_KEY || undefined,
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
export function saveConfig(cfg) {
|
|
@@ -40,6 +40,10 @@ export function saveConfig(cfg) {
|
|
|
40
40
|
if (cfg.baseUrl)
|
|
41
41
|
lines.push(`OPENAI_BASE_URL=${cfg.baseUrl}`);
|
|
42
42
|
lines.push(`MODEL=${cfg.model}`);
|
|
43
|
+
if (cfg.n8nUrl)
|
|
44
|
+
lines.push(`N8N_URL=${cfg.n8nUrl}`);
|
|
45
|
+
if (cfg.n8nApiKey)
|
|
46
|
+
lines.push(`N8N_API_KEY=${cfg.n8nApiKey}`);
|
|
43
47
|
lines.push('');
|
|
44
48
|
fs.writeFileSync(GLOBAL_ENV, lines.join('\n'), { mode: 0o600 });
|
|
45
49
|
return GLOBAL_ENV;
|
|
@@ -48,5 +52,31 @@ export function applyConfig(cfg) {
|
|
|
48
52
|
process.env.OPENAI_API_KEY = cfg.apiKey;
|
|
49
53
|
if (cfg.baseUrl)
|
|
50
54
|
process.env.OPENAI_BASE_URL = cfg.baseUrl;
|
|
55
|
+
else
|
|
56
|
+
delete process.env.OPENAI_BASE_URL;
|
|
51
57
|
process.env.MODEL = cfg.model;
|
|
58
|
+
if (cfg.n8nUrl)
|
|
59
|
+
process.env.N8N_URL = cfg.n8nUrl;
|
|
60
|
+
else
|
|
61
|
+
delete process.env.N8N_URL;
|
|
62
|
+
if (cfg.n8nApiKey)
|
|
63
|
+
process.env.N8N_API_KEY = cfg.n8nApiKey;
|
|
64
|
+
else
|
|
65
|
+
delete process.env.N8N_API_KEY;
|
|
66
|
+
}
|
|
67
|
+
export function getConfig() {
|
|
68
|
+
const cfg = loadConfig();
|
|
69
|
+
if (cfg)
|
|
70
|
+
return cfg;
|
|
71
|
+
return {
|
|
72
|
+
apiKey: process.env.OPENAI_API_KEY || '',
|
|
73
|
+
baseUrl: process.env.OPENAI_BASE_URL,
|
|
74
|
+
model: process.env.MODEL || 'gpt-4o-mini',
|
|
75
|
+
n8nUrl: process.env.N8N_URL,
|
|
76
|
+
n8nApiKey: process.env.N8N_API_KEY,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export function hasN8n() {
|
|
80
|
+
const cfg = getConfig();
|
|
81
|
+
return !!(cfg.n8nUrl && cfg.n8nApiKey);
|
|
52
82
|
}
|
package/dist/core/n8n.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const BASE = process.env.N8N_URL || '';
|
|
2
|
+
const KEY = process.env.N8N_API_KEY || '';
|
|
3
|
+
function headers() {
|
|
4
|
+
return {
|
|
5
|
+
'X-N8N-API-KEY': KEY,
|
|
6
|
+
'Content-Type': 'application/json',
|
|
7
|
+
Accept: 'application/json',
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function ensureConfig() {
|
|
11
|
+
if (!BASE)
|
|
12
|
+
throw new Error('N8N_URL not set. Add to ~/.blyue/.env');
|
|
13
|
+
if (!KEY)
|
|
14
|
+
throw new Error('N8N_API_KEY not set. Add to ~/.blyue/.env');
|
|
15
|
+
}
|
|
16
|
+
export async function n8nListWorkflows() {
|
|
17
|
+
ensureConfig();
|
|
18
|
+
const res = await fetch(`${BASE}/api/v1/workflows`, { headers: headers() });
|
|
19
|
+
if (!res.ok)
|
|
20
|
+
throw new Error(`n8n API ${res.status}: ${await res.text()}`);
|
|
21
|
+
const json = await res.json();
|
|
22
|
+
return (json.data || []).map((w) => ({
|
|
23
|
+
id: w.id,
|
|
24
|
+
name: w.name,
|
|
25
|
+
active: w.active,
|
|
26
|
+
updatedAt: w.updatedAt,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
export async function n8nTriggerWorkflow(workflowId, data = {}) {
|
|
30
|
+
ensureConfig();
|
|
31
|
+
const res = await fetch(`${BASE}/webhook/${workflowId}`, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
body: JSON.stringify(data),
|
|
35
|
+
});
|
|
36
|
+
return await res.text();
|
|
37
|
+
}
|
|
38
|
+
export async function n8nListExecutions(workflowId, limit = 10) {
|
|
39
|
+
ensureConfig();
|
|
40
|
+
const url = new URL(`${BASE}/api/v1/executions`);
|
|
41
|
+
url.searchParams.set('limit', String(limit));
|
|
42
|
+
if (workflowId)
|
|
43
|
+
url.searchParams.set('workflowId', workflowId);
|
|
44
|
+
const res = await fetch(url.toString(), { headers: headers() });
|
|
45
|
+
if (!res.ok)
|
|
46
|
+
throw new Error(`n8n API ${res.status}: ${await res.text()}`);
|
|
47
|
+
const json = await res.json();
|
|
48
|
+
return (json.data || []).map((e) => ({
|
|
49
|
+
id: e.id,
|
|
50
|
+
status: e.finished
|
|
51
|
+
? 'success'
|
|
52
|
+
: e.stoppedAt
|
|
53
|
+
? 'failed'
|
|
54
|
+
: 'running',
|
|
55
|
+
startedAt: e.startedAt,
|
|
56
|
+
workflowId: e.workflowId,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
export async function n8nGetWorkflow(id) {
|
|
60
|
+
ensureConfig();
|
|
61
|
+
const res = await fetch(`${BASE}/api/v1/workflows/${id}`, { headers: headers() });
|
|
62
|
+
if (!res.ok)
|
|
63
|
+
throw new Error(`n8n API ${res.status}: ${await res.text()}`);
|
|
64
|
+
return await res.json();
|
|
65
|
+
}
|
|
66
|
+
export async function n8nActivateWorkflow(id) {
|
|
67
|
+
ensureConfig();
|
|
68
|
+
const res = await fetch(`${BASE}/api/v1/workflows/${id}/activate`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: headers(),
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok)
|
|
73
|
+
throw new Error(`n8n API ${res.status}: ${await res.text()}`);
|
|
74
|
+
return 'activated';
|
|
75
|
+
}
|
|
76
|
+
export async function n8nDeactivateWorkflow(id) {
|
|
77
|
+
ensureConfig();
|
|
78
|
+
const res = await fetch(`${BASE}/api/v1/workflows/${id}/deactivate`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: headers(),
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok)
|
|
83
|
+
throw new Error(`n8n API ${res.status}: ${await res.text()}`);
|
|
84
|
+
return 'deactivated';
|
|
85
|
+
}
|
package/dist/core/tools.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import { exec } from 'child_process';
|
|
3
3
|
import { promisify } from 'util';
|
|
4
|
+
import { hasN8n } from './config.js';
|
|
5
|
+
import { n8nListWorkflows, n8nTriggerWorkflow, n8nListExecutions, n8nGetWorkflow, n8nActivateWorkflow, n8nDeactivateWorkflow, } from './n8n.js';
|
|
4
6
|
const execAsync = promisify(exec);
|
|
5
|
-
|
|
7
|
+
// Base tools — always available
|
|
8
|
+
const BASE_TOOLS = [
|
|
6
9
|
{
|
|
7
10
|
type: 'function',
|
|
8
11
|
function: {
|
|
@@ -10,9 +13,7 @@ export const toolDefs = [
|
|
|
10
13
|
description: 'Read a file from disk',
|
|
11
14
|
parameters: {
|
|
12
15
|
type: 'object',
|
|
13
|
-
properties: {
|
|
14
|
-
path: { type: 'string', description: 'File path' },
|
|
15
|
-
},
|
|
16
|
+
properties: { path: { type: 'string' } },
|
|
16
17
|
required: ['path'],
|
|
17
18
|
},
|
|
18
19
|
},
|
|
@@ -39,9 +40,7 @@ export const toolDefs = [
|
|
|
39
40
|
description: 'List files in a directory',
|
|
40
41
|
parameters: {
|
|
41
42
|
type: 'object',
|
|
42
|
-
properties: {
|
|
43
|
-
path: { type: 'string', description: 'Directory path (default: .)' },
|
|
44
|
-
},
|
|
43
|
+
properties: { path: { type: 'string' } },
|
|
45
44
|
},
|
|
46
45
|
},
|
|
47
46
|
},
|
|
@@ -50,16 +49,96 @@ export const toolDefs = [
|
|
|
50
49
|
function: {
|
|
51
50
|
name: 'run_command',
|
|
52
51
|
description: 'Run a shell command. Use carefully.',
|
|
52
|
+
parameters: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: { command: { type: 'string' } },
|
|
55
|
+
required: ['command'],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
// n8n tools — only loaded when N8N_URL + N8N_API_KEY are set
|
|
61
|
+
const N8N_TOOLS = [
|
|
62
|
+
{
|
|
63
|
+
type: 'function',
|
|
64
|
+
function: {
|
|
65
|
+
name: 'n8n_list_workflows',
|
|
66
|
+
description: 'List all n8n workflows. Returns id, name, active status, updatedAt.',
|
|
67
|
+
parameters: { type: 'object', properties: {} },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
type: 'function',
|
|
72
|
+
function: {
|
|
73
|
+
name: 'n8n_get_workflow',
|
|
74
|
+
description: 'Get details of a specific n8n workflow by ID',
|
|
75
|
+
parameters: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: { id: { type: 'string' } },
|
|
78
|
+
required: ['id'],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
type: 'function',
|
|
84
|
+
function: {
|
|
85
|
+
name: 'n8n_trigger_workflow',
|
|
86
|
+
description: 'Trigger an n8n workflow via its webhook URL',
|
|
53
87
|
parameters: {
|
|
54
88
|
type: 'object',
|
|
55
89
|
properties: {
|
|
56
|
-
|
|
90
|
+
workflowId: { type: 'string' },
|
|
91
|
+
data: { type: 'object' },
|
|
57
92
|
},
|
|
58
|
-
required: ['
|
|
93
|
+
required: ['workflowId'],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
type: 'function',
|
|
99
|
+
function: {
|
|
100
|
+
name: 'n8n_list_executions',
|
|
101
|
+
description: 'List recent n8n executions (runs)',
|
|
102
|
+
parameters: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
workflowId: { type: 'string' },
|
|
106
|
+
limit: { type: 'number' },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
type: 'function',
|
|
113
|
+
function: {
|
|
114
|
+
name: 'n8n_activate_workflow',
|
|
115
|
+
description: 'Activate an n8n workflow',
|
|
116
|
+
parameters: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: { id: { type: 'string' } },
|
|
119
|
+
required: ['id'],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
type: 'function',
|
|
125
|
+
function: {
|
|
126
|
+
name: 'n8n_deactivate_workflow',
|
|
127
|
+
description: 'Deactivate an n8n workflow',
|
|
128
|
+
parameters: {
|
|
129
|
+
type: 'object',
|
|
130
|
+
properties: { id: { type: 'string' } },
|
|
131
|
+
required: ['id'],
|
|
59
132
|
},
|
|
60
133
|
},
|
|
61
134
|
},
|
|
62
135
|
];
|
|
136
|
+
// Dynamic — includes n8n tools only if configured
|
|
137
|
+
export function getToolDefs() {
|
|
138
|
+
return hasN8n() ? [...BASE_TOOLS, ...N8N_TOOLS] : [...BASE_TOOLS];
|
|
139
|
+
}
|
|
140
|
+
// For backwards compat if anything imports toolDefs directly
|
|
141
|
+
export const toolDefs = BASE_TOOLS;
|
|
63
142
|
export async function executeTool(name, args) {
|
|
64
143
|
try {
|
|
65
144
|
switch (name) {
|
|
@@ -82,6 +161,18 @@ export async function executeTool(name, args) {
|
|
|
82
161
|
});
|
|
83
162
|
return 'stdout:\n' + stdout + '\nstderr:\n' + stderr;
|
|
84
163
|
}
|
|
164
|
+
case 'n8n_list_workflows':
|
|
165
|
+
return JSON.stringify(await n8nListWorkflows(), null, 2);
|
|
166
|
+
case 'n8n_get_workflow':
|
|
167
|
+
return JSON.stringify(await n8nGetWorkflow(args.id), null, 2);
|
|
168
|
+
case 'n8n_trigger_workflow':
|
|
169
|
+
return await n8nTriggerWorkflow(args.workflowId, args.data || {});
|
|
170
|
+
case 'n8n_list_executions':
|
|
171
|
+
return JSON.stringify(await n8nListExecutions(args.workflowId, args.limit ?? 10), null, 2);
|
|
172
|
+
case 'n8n_activate_workflow':
|
|
173
|
+
return await n8nActivateWorkflow(args.id);
|
|
174
|
+
case 'n8n_deactivate_workflow':
|
|
175
|
+
return await n8nDeactivateWorkflow(args.id);
|
|
85
176
|
default:
|
|
86
177
|
return 'Unknown tool: ' + name;
|
|
87
178
|
}
|
package/dist/ui/Chat.js
CHANGED
|
@@ -4,6 +4,7 @@ import { Box, Text, useInput, useStdout, Static } from 'ink';
|
|
|
4
4
|
import { Agent } from '../core/agent.js';
|
|
5
5
|
import { InputBar } from './InputBar.js';
|
|
6
6
|
import { ModelPicker } from './ModelPicker.js';
|
|
7
|
+
import { Settings } from './Settings.js';
|
|
7
8
|
import { theme } from './theme.js';
|
|
8
9
|
let nextId = 0;
|
|
9
10
|
const mkItem = (kind, text) => ({
|
|
@@ -11,9 +12,6 @@ const mkItem = (kind, text) => ({
|
|
|
11
12
|
text,
|
|
12
13
|
id: nextId++,
|
|
13
14
|
});
|
|
14
|
-
// ─────────────────────────────────────────────
|
|
15
|
-
// One history line — rendered ONCE via <Static>
|
|
16
|
-
// ─────────────────────────────────────────────
|
|
17
15
|
const HistoryLine = ({ item }) => {
|
|
18
16
|
if (item.kind === 'user') {
|
|
19
17
|
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(Text, { color: theme.text, bold: true, children: item.text })] }));
|
|
@@ -32,12 +30,14 @@ export const Chat = () => {
|
|
|
32
30
|
const [history, setHistory] = useState([]);
|
|
33
31
|
const [input, setInput] = useState('');
|
|
34
32
|
const [loading, setLoading] = useState(false);
|
|
35
|
-
const [
|
|
33
|
+
const [overlay, setOverlay] = useState('none');
|
|
36
34
|
const [currentModel, setCurrentModel] = useState(process.env.MODEL || '');
|
|
37
35
|
const [agent, setAgent] = useState(() => new Agent());
|
|
38
36
|
useInput((inputChar, key) => {
|
|
39
37
|
if (key.ctrl && inputChar === 'p')
|
|
40
|
-
|
|
38
|
+
setOverlay('model');
|
|
39
|
+
if (key.ctrl && inputChar === 's')
|
|
40
|
+
setOverlay('settings');
|
|
41
41
|
});
|
|
42
42
|
const handleSubmit = async (text) => {
|
|
43
43
|
if (!text.trim() || loading)
|
|
@@ -45,7 +45,23 @@ export const Chat = () => {
|
|
|
45
45
|
const trimmed = text.trim();
|
|
46
46
|
if (trimmed === '/model' || trimmed === '/models') {
|
|
47
47
|
setInput('');
|
|
48
|
-
|
|
48
|
+
setOverlay('model');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (trimmed === '/settings' || trimmed === '/config') {
|
|
52
|
+
setInput('');
|
|
53
|
+
setOverlay('settings');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (trimmed === '/help') {
|
|
57
|
+
setInput('');
|
|
58
|
+
setHistory((h) => [
|
|
59
|
+
...h,
|
|
60
|
+
mkItem('tool', 'Commands:'),
|
|
61
|
+
mkItem('tool', ' /model switch model (Ctrl+P)'),
|
|
62
|
+
mkItem('tool', ' /settings open settings (Ctrl+S)'),
|
|
63
|
+
mkItem('tool', ' /help show this list'),
|
|
64
|
+
]);
|
|
49
65
|
return;
|
|
50
66
|
}
|
|
51
67
|
setInput('');
|
|
@@ -65,16 +81,22 @@ export const Chat = () => {
|
|
|
65
81
|
const handleModelSelect = (id) => {
|
|
66
82
|
process.env.MODEL = id;
|
|
67
83
|
setCurrentModel(id);
|
|
68
|
-
|
|
84
|
+
setOverlay('none');
|
|
69
85
|
setAgent(new Agent());
|
|
70
86
|
setHistory((h) => [...h, mkItem('tool', `model → ${id}`)]);
|
|
71
87
|
};
|
|
72
|
-
if (
|
|
73
|
-
return (_jsx(Box, { flexDirection: "column", children: _jsx(ModelPicker, { current: currentModel, onSelect: handleModelSelect, onCancel: () =>
|
|
88
|
+
if (overlay === 'model') {
|
|
89
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsx(ModelPicker, { current: currentModel, onSelect: handleModelSelect, onCancel: () => setOverlay('none') }) }));
|
|
90
|
+
}
|
|
91
|
+
if (overlay === 'settings') {
|
|
92
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsx(Settings, { onClose: () => setOverlay('none'), onModelChange: (m) => {
|
|
93
|
+
setCurrentModel(m);
|
|
94
|
+
setAgent(new Agent());
|
|
95
|
+
} }) }));
|
|
74
96
|
}
|
|
75
97
|
const maxModelLen = Math.max(10, width - 14);
|
|
76
98
|
const shortModel = currentModel.length > maxModelLen
|
|
77
99
|
? '…' + currentModel.slice(-(maxModelLen - 1))
|
|
78
100
|
: currentModel || 'unset';
|
|
79
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (item) => _jsx(HistoryLine, { item: item }, item.id) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(InputBar, { value: input, onChange: setInput, onSubmit: handleSubmit, loading: loading }), _jsxs(Text, { children: [_jsx(Text, { color: theme.accent, children: " /model" }),
|
|
101
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (item) => _jsx(HistoryLine, { item: item }, item.id) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(InputBar, { value: input, onChange: setInput, onSubmit: handleSubmit, loading: loading }), _jsxs(Text, { children: [_jsx(Text, { color: theme.accent, children: " /model" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.accent, children: "/settings" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.text, children: shortModel })] })] })] }));
|
|
80
102
|
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
4
|
+
import { KeyInput } from './KeyInput.js';
|
|
5
|
+
import { getConfig, saveConfig, applyConfig } from '../core/config.js';
|
|
6
|
+
import { theme } from './theme.js';
|
|
7
|
+
export const Settings = ({ onClose, onModelChange }) => {
|
|
8
|
+
const { stdout } = useStdout();
|
|
9
|
+
const width = Math.max(40, stdout?.columns ?? 60);
|
|
10
|
+
const [screen, setScreen] = useState('main');
|
|
11
|
+
const [cursor, setCursor] = useState(0);
|
|
12
|
+
const [cfg, setCfg] = useState(() => getConfig());
|
|
13
|
+
const [draft, setDraft] = useState('');
|
|
14
|
+
const [testResult, setTestResult] = useState(null);
|
|
15
|
+
const persist = (next) => {
|
|
16
|
+
saveConfig(next);
|
|
17
|
+
applyConfig(next);
|
|
18
|
+
setCfg(next);
|
|
19
|
+
};
|
|
20
|
+
const mainItems = [
|
|
21
|
+
{ label: 'API key', hint: cfg.apiKey ? '•••' + cfg.apiKey.slice(-4) : 'not set', action: () => setScreen('apikey') },
|
|
22
|
+
{ label: 'Base URL', hint: cfg.baseUrl || 'default (OpenAI)', action: () => setScreen('baseurl') },
|
|
23
|
+
{ label: 'Model', hint: cfg.model, action: () => setScreen('model') },
|
|
24
|
+
{
|
|
25
|
+
label: 'n8n',
|
|
26
|
+
hint: cfg.n8nUrl
|
|
27
|
+
? (cfg.n8nApiKey ? `${cfg.n8nUrl} ✓` : `${cfg.n8nUrl} (no key)`)
|
|
28
|
+
: 'not configured',
|
|
29
|
+
action: () => setScreen('n8n'),
|
|
30
|
+
},
|
|
31
|
+
{ label: 'Reset all config', hint: 'delete ~/.blyue/.env', action: () => setScreen('reset') },
|
|
32
|
+
{ label: 'Close', hint: 'esc', action: onClose },
|
|
33
|
+
];
|
|
34
|
+
// ---- Main menu navigation ----
|
|
35
|
+
useInput((_, key) => {
|
|
36
|
+
if (key.escape)
|
|
37
|
+
return onClose();
|
|
38
|
+
if (key.upArrow)
|
|
39
|
+
setCursor((c) => (c - 1 + mainItems.length) % mainItems.length);
|
|
40
|
+
if (key.downArrow)
|
|
41
|
+
setCursor((c) => (c + 1) % mainItems.length);
|
|
42
|
+
if (key.return)
|
|
43
|
+
mainItems[cursor].action();
|
|
44
|
+
}, { isActive: screen === 'main' });
|
|
45
|
+
// ---- n8n submenu navigation ----
|
|
46
|
+
const n8nItems = [
|
|
47
|
+
{ label: 'URL', hint: cfg.n8nUrl || 'not set', action: () => { setDraft(cfg.n8nUrl || ''); setScreen('n8n-url'); } },
|
|
48
|
+
{ label: 'API key', hint: cfg.n8nApiKey ? '•••' + cfg.n8nApiKey.slice(-4) : 'not set', action: () => { setDraft(cfg.n8nApiKey || ''); setScreen('n8n-key'); } },
|
|
49
|
+
{ label: 'Test connection', hint: 'ping n8n API', action: async () => {
|
|
50
|
+
setScreen('n8n-test');
|
|
51
|
+
setTestResult(null);
|
|
52
|
+
try {
|
|
53
|
+
const { n8nListWorkflows } = await import('../core/n8n.js');
|
|
54
|
+
const wf = await n8nListWorkflows();
|
|
55
|
+
setTestResult(`✓ Connected. ${wf.length} workflow(s) found.`);
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
setTestResult('✗ ' + (e?.message || String(e)));
|
|
59
|
+
}
|
|
60
|
+
} },
|
|
61
|
+
{ label: 'Disable n8n', hint: 'remove URL + key', action: () => {
|
|
62
|
+
const next = { ...cfg };
|
|
63
|
+
delete next.n8nUrl;
|
|
64
|
+
delete next.n8nApiKey;
|
|
65
|
+
persist(next);
|
|
66
|
+
} },
|
|
67
|
+
{ label: 'Back', hint: 'esc', action: () => setScreen('main') },
|
|
68
|
+
];
|
|
69
|
+
const [n8nCursor, setN8nCursor] = useState(0);
|
|
70
|
+
useInput((_, key) => {
|
|
71
|
+
if (key.escape)
|
|
72
|
+
return setScreen('main');
|
|
73
|
+
if (key.upArrow)
|
|
74
|
+
setN8nCursor((c) => (c - 1 + n8nItems.length) % n8nItems.length);
|
|
75
|
+
if (key.downArrow)
|
|
76
|
+
setN8nCursor((c) => (c + 1) % n8nItems.length);
|
|
77
|
+
if (key.return)
|
|
78
|
+
n8nItems[n8nCursor].action();
|
|
79
|
+
}, { isActive: screen === 'n8n' });
|
|
80
|
+
// ---- Frame helpers ----
|
|
81
|
+
const inner = width - 4;
|
|
82
|
+
const top = (label) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children: label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(Math.max(1, inner - label.length - 3)) + '╮' })] }));
|
|
83
|
+
const bot = (_jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(inner) + '╯' }));
|
|
84
|
+
const row = (content) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), content] }));
|
|
85
|
+
// ---- Screens ----
|
|
86
|
+
if (screen === 'main') {
|
|
87
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top('settings'), mainItems.map((it, i) => {
|
|
88
|
+
const active = i === cursor;
|
|
89
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, children: active ? '❯ ' : ' ' }), _jsx(Text, { color: active ? theme.text : theme.muted, bold: active, children: it.label.padEnd(20) }), _jsx(Text, { color: theme.muted, children: it.hint })] }, it.label));
|
|
90
|
+
}), bot, _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 \u21B5 select \u00B7 esc close" })] }));
|
|
91
|
+
}
|
|
92
|
+
if (screen === 'apikey' || screen === 'baseurl') {
|
|
93
|
+
const isKey = screen === 'apikey';
|
|
94
|
+
const title = isKey ? 'api key' : 'base url';
|
|
95
|
+
const value = isKey ? cfg.apiKey : cfg.baseUrl || '';
|
|
96
|
+
const setValue = (v) => {
|
|
97
|
+
const next = { ...cfg, [isKey ? 'apiKey' : 'baseUrl']: v };
|
|
98
|
+
persist(next);
|
|
99
|
+
};
|
|
100
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top(title), row(_jsx(Text, { color: theme.muted, children: isKey ? 'Paste your new API key' : 'e.g. https://openrouter.ai/api/v1' })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: value, onChange: setValue, placeholder: isKey ? 'sk-...' : 'https://...', masked: isKey, onSubmit: () => setScreen('main') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 backspace \u00B7 \u21B5 save \u00B7 esc back" })] }));
|
|
101
|
+
}
|
|
102
|
+
if (screen === 'model') {
|
|
103
|
+
// Simple inline editor for now — full ModelPicker opens separately if user wants
|
|
104
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top('model'), row(_jsx(Text, { color: theme.muted, children: "Type model name (or use /model for picker)" })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: cfg.model, onChange: (v) => persist({ ...cfg, model: v }), placeholder: "gpt-4o-mini", onSubmit: () => {
|
|
105
|
+
onModelChange?.(cfg.model);
|
|
106
|
+
setScreen('main');
|
|
107
|
+
} })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 \u21B5 save \u00B7 esc back" })] }));
|
|
108
|
+
}
|
|
109
|
+
if (screen === 'n8n') {
|
|
110
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top('n8n integration'), n8nItems.map((it, i) => {
|
|
111
|
+
const active = i === n8nCursor;
|
|
112
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, children: active ? '❯ ' : ' ' }), _jsx(Text, { color: active ? theme.text : theme.muted, bold: active, children: it.label.padEnd(20) }), _jsx(Text, { color: theme.muted, children: it.hint })] }, it.label));
|
|
113
|
+
}), bot, _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 \u21B5 select \u00B7 esc back" })] }));
|
|
114
|
+
}
|
|
115
|
+
if (screen === 'n8n-url' || screen === 'n8n-key') {
|
|
116
|
+
const isKey = screen === 'n8n-key';
|
|
117
|
+
const title = isKey ? 'n8n api key' : 'n8n url';
|
|
118
|
+
const value = isKey ? cfg.n8nApiKey || '' : cfg.n8nUrl || '';
|
|
119
|
+
const setValue = (v) => {
|
|
120
|
+
const next = { ...cfg, [isKey ? 'n8nApiKey' : 'n8nUrl']: v };
|
|
121
|
+
persist(next);
|
|
122
|
+
};
|
|
123
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top(title), row(_jsx(Text, { color: theme.muted, children: isKey ? 'Settings → n8n API → create key' : 'e.g. http://localhost:5678' })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: value, onChange: setValue, placeholder: isKey ? 'n8n_api_...' : 'http://localhost:5678', masked: isKey, onSubmit: () => setScreen('n8n') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 backspace \u00B7 \u21B5 save \u00B7 esc back" })] }));
|
|
124
|
+
}
|
|
125
|
+
if (screen === 'n8n-test') {
|
|
126
|
+
useInput(() => setScreen('n8n'), { isActive: screen === 'n8n-test' });
|
|
127
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top('n8n test'), row(_jsx(Text, { color: testResult?.startsWith('✓') ? theme.success : theme.muted, children: testResult || 'testing…' })), bot, _jsx(Text, { color: theme.muted, children: "press any key to go back" })] }));
|
|
128
|
+
}
|
|
129
|
+
if (screen === 'reset') {
|
|
130
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top('reset config'), row(_jsx(Text, { color: theme.error, children: "Delete ~/.blyue/.env and reconfigure?" })), row(_jsx(Text, { color: theme.muted, children: "Press Y to confirm, N to cancel." })), bot] }));
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
};
|
|
134
|
+
// Handle Y/N for reset outside component main body
|
|
135
|
+
export const SettingsResetHandler = ({ onConfirm, onCancel, }) => {
|
|
136
|
+
useInput((input) => {
|
|
137
|
+
if (input === 'y' || input === 'Y')
|
|
138
|
+
onConfirm();
|
|
139
|
+
if (input === 'n' || input === 'N')
|
|
140
|
+
onCancel();
|
|
141
|
+
});
|
|
142
|
+
return null;
|
|
143
|
+
};
|