swiftroutercli 1.1.1 → 3.0.0
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/README.md +46 -0
- package/dist/api/client.js +24 -2
- package/dist/config.js +18 -1
- package/dist/constants.js +1 -0
- package/dist/ui/Chat.js +146 -34
- package/package.json +6 -2
- package/src/api/client.ts +26 -2
- package/src/config.ts +19 -1
- package/src/constants.ts +1 -0
- package/src/ui/Chat.tsx +197 -53
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# SwiftRouter CLI
|
|
2
|
+
|
|
3
|
+
The Official Command Line Interface for SwiftRouter API Gateway, built with React Ink.
|
|
4
|
+
|
|
5
|
+
## V3 Features (Codex Parity)
|
|
6
|
+
- **Interactive Markdown Renderer**: Syntactically highlights Assistant markdown responses within the terminal.
|
|
7
|
+
- **Intelligent Bash Execution**: Automatically detects `bash` snippet blocks and prompts: `Execute suggested command? [Y/n]`.
|
|
8
|
+
- **RAG Local Workspaces**: Injects `process.cwd()` state and files directly into a system prompt context so the AI knows where you are.
|
|
9
|
+
- **Persistent History Storage**: Automatically saves conversation threads to `~/.swiftrouter-cli/history.json` and recalls them using up/down keys in the input.
|
|
10
|
+
- **Slash Commands & Menus**: Type `/models` to pop open a sleek `ink-select-input` menu fetched directly from the SwiftRouter `/v1/models` API endpoint. Navigate with up/down arrows!
|
|
11
|
+
- **System Commands**: Type `/clear` to reset the terminal history buffer, or `/exit`/`/quit` to cleanly exit the CLI.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
```bash
|
|
15
|
+
npm install -g swiftroutercli
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Configuration
|
|
19
|
+
Upon first launch, the CLI asks for configuration parameters. You can also configure via system ENV variables:
|
|
20
|
+
- `SWIFTROUTER_API_KEY`
|
|
21
|
+
- `SWIFTROUTER_BASE_URL` (Defaults to `http://localhost:3000`)
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
Simply run the CLI anywhere on your system:
|
|
25
|
+
```bash
|
|
26
|
+
swiftrouter chat
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- Type `/models` to select models interactively.
|
|
30
|
+
- Type `/clear` to reset the session.
|
|
31
|
+
- Type `/exit` to quit.
|
|
32
|
+
|
|
33
|
+
### Additional Commands
|
|
34
|
+
- `swiftrouter config --set-api-key <KEY> --set-base-url <URL>`: Manually configure CLI
|
|
35
|
+
- `swiftrouter models`: List available models natively
|
|
36
|
+
- `swiftrouter status`: Check authentication and connection status
|
|
37
|
+
- `swiftrouter logout`: Clear local configuration securely
|
|
38
|
+
|
|
39
|
+
## Built With
|
|
40
|
+
- `ink`
|
|
41
|
+
- `marked-terminal`
|
|
42
|
+
- `ink-select-input`
|
|
43
|
+
- `eventsource-parser`
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
ISC
|
package/dist/api/client.js
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
import { createParser } from "eventsource-parser";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
function getWorkspaceContext() {
|
|
5
|
+
const cwd = process.cwd();
|
|
6
|
+
let context = `Current Workspace: ${cwd}\n`;
|
|
7
|
+
const filesToRead = ["README.md", "AGENTS.md", "package.json"];
|
|
8
|
+
for (const file of filesToRead) {
|
|
9
|
+
const filePath = path.join(cwd, file);
|
|
10
|
+
if (fs.existsSync(filePath)) {
|
|
11
|
+
try {
|
|
12
|
+
const content = fs.readFileSync(filePath, "utf-8").slice(0, 1000);
|
|
13
|
+
context += `\n--- File: ${file} ---\n${content}\n`;
|
|
14
|
+
}
|
|
15
|
+
catch (e) { }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return context;
|
|
19
|
+
}
|
|
2
20
|
export async function fetchModels(config) {
|
|
3
21
|
const url = `${config.baseUrl.replace(/\/$/, "")}/v1/models`;
|
|
4
22
|
const response = await fetch(url, {
|
|
@@ -12,8 +30,12 @@ export async function fetchModels(config) {
|
|
|
12
30
|
const data = await response.json();
|
|
13
31
|
return data.data || [];
|
|
14
32
|
}
|
|
15
|
-
export async function streamChatCompletion(config, model,
|
|
33
|
+
export async function streamChatCompletion(config, model, messages, onData, onComplete, onError) {
|
|
16
34
|
const url = `${config.baseUrl.replace(/\/$/, "")}/v1/chat/completions`;
|
|
35
|
+
const payloadMessages = [...messages];
|
|
36
|
+
if (payloadMessages.length > 0 && payloadMessages[0].role !== "system") {
|
|
37
|
+
payloadMessages.unshift({ role: "system", content: getWorkspaceContext() });
|
|
38
|
+
}
|
|
17
39
|
try {
|
|
18
40
|
const response = await fetch(url, {
|
|
19
41
|
method: "POST",
|
|
@@ -23,7 +45,7 @@ export async function streamChatCompletion(config, model, prompt, onData, onComp
|
|
|
23
45
|
},
|
|
24
46
|
body: JSON.stringify({
|
|
25
47
|
model,
|
|
26
|
-
messages:
|
|
48
|
+
messages: payloadMessages,
|
|
27
49
|
stream: true,
|
|
28
50
|
}),
|
|
29
51
|
});
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { CONFIG_FILE, ENV_API_KEY, ENV_BASE_URL } from "./constants.js";
|
|
3
|
+
import { CONFIG_FILE, HISTORY_FILE, ENV_API_KEY, ENV_BASE_URL } from "./constants.js";
|
|
4
4
|
export function loadConfig() {
|
|
5
5
|
// 1. Check for Environment Variables overriding everything else
|
|
6
6
|
const envApiKey = process.env[ENV_API_KEY];
|
|
@@ -31,3 +31,20 @@ export function saveConfig(config) {
|
|
|
31
31
|
}
|
|
32
32
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
33
33
|
}
|
|
34
|
+
export function loadHistory() {
|
|
35
|
+
try {
|
|
36
|
+
if (fs.existsSync(HISTORY_FILE)) {
|
|
37
|
+
const data = fs.readFileSync(HISTORY_FILE, "utf-8");
|
|
38
|
+
return JSON.parse(data);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch (error) { }
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
export function saveHistory(history) {
|
|
45
|
+
const dir = path.dirname(HISTORY_FILE);
|
|
46
|
+
if (!fs.existsSync(dir)) {
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2), "utf-8");
|
|
50
|
+
}
|
package/dist/constants.js
CHANGED
|
@@ -6,6 +6,7 @@ export const DEFAULT_MODEL = "gpt-4o-mini";
|
|
|
6
6
|
// System Paths
|
|
7
7
|
export const CONFIG_DIR = path.join(os.homedir(), ".swiftrouter-cli");
|
|
8
8
|
export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
9
|
+
export const HISTORY_FILE = path.join(CONFIG_DIR, "history.json");
|
|
9
10
|
// Environment Variable Keys
|
|
10
11
|
export const ENV_API_KEY = "SWIFTROUTER_API_KEY";
|
|
11
12
|
export const ENV_BASE_URL = "SWIFTROUTER_BASE_URL";
|
package/dist/ui/Chat.js
CHANGED
|
@@ -1,74 +1,186 @@
|
|
|
1
1
|
import React, { useState, useEffect } from "react";
|
|
2
|
-
import { render, Box, Text, useInput } from "ink";
|
|
3
|
-
import { streamChatCompletion } from "../api/client.js";
|
|
2
|
+
import { render, Box, Text, useInput, Static } from "ink";
|
|
3
|
+
import { streamChatCompletion, fetchModels } from "../api/client.js";
|
|
4
|
+
import { loadHistory, saveHistory } from "../config.js";
|
|
5
|
+
import { marked } from "marked";
|
|
6
|
+
import TerminalRenderer from "marked-terminal";
|
|
7
|
+
import { exec } from "child_process";
|
|
8
|
+
import SelectInput from "ink-select-input";
|
|
9
|
+
marked.setOptions({
|
|
10
|
+
// @ts-ignore
|
|
11
|
+
renderer: new TerminalRenderer()
|
|
12
|
+
});
|
|
4
13
|
const Chat = ({ config, model, initialPrompt }) => {
|
|
5
14
|
const [messages, setMessages] = useState([
|
|
6
|
-
{ role: "user", content: initialPrompt },
|
|
15
|
+
{ id: 0, role: "user", content: initialPrompt },
|
|
7
16
|
]);
|
|
8
17
|
const [streamingContent, setStreamingContent] = useState("");
|
|
9
18
|
const [isStreaming, setIsStreaming] = useState(true);
|
|
10
19
|
const [error, setError] = useState(null);
|
|
11
20
|
const [input, setInput] = useState("");
|
|
21
|
+
const [history, setHistory] = useState([]);
|
|
22
|
+
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
23
|
+
const [pendingCommand, setPendingCommand] = useState(null);
|
|
24
|
+
const [isExecuting, setIsExecuting] = useState(false);
|
|
25
|
+
const [activeModel, setActiveModel] = useState(model);
|
|
26
|
+
const [isSelectingModel, setIsSelectingModel] = useState(false);
|
|
27
|
+
const [modelOptions, setModelOptions] = useState([]);
|
|
28
|
+
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
setHistory(loadHistory());
|
|
31
|
+
}, []);
|
|
12
32
|
useEffect(() => {
|
|
13
33
|
if (!isStreaming)
|
|
14
34
|
return;
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
return;
|
|
18
|
-
streamChatCompletion(config, model, lastUserMessage.content, (text) => setStreamingContent((prev) => prev + text), () => {
|
|
35
|
+
const messagesPayload = messages.map(m => ({ role: m.role, content: m.content }));
|
|
36
|
+
streamChatCompletion(config, activeModel, messagesPayload, (text) => setStreamingContent((prev) => prev + text), () => {
|
|
19
37
|
setMessages((prev) => [
|
|
20
38
|
...prev,
|
|
21
|
-
{ role: "assistant", content: streamingContent },
|
|
39
|
+
{ id: prev.length, role: "assistant", content: streamingContent },
|
|
22
40
|
]);
|
|
41
|
+
const bashMatch = streamingContent.match(/```(?:bash|sh)\n([\s\S]*?)\n```/);
|
|
42
|
+
if (bashMatch) {
|
|
43
|
+
setPendingCommand(bashMatch[1].trim());
|
|
44
|
+
}
|
|
23
45
|
setStreamingContent("");
|
|
24
46
|
setIsStreaming(false);
|
|
25
47
|
}, (err) => {
|
|
26
48
|
setError(err);
|
|
27
49
|
setIsStreaming(false);
|
|
28
50
|
});
|
|
29
|
-
}, [messages, isStreaming, config,
|
|
51
|
+
}, [messages, isStreaming, config, activeModel]);
|
|
30
52
|
useInput((char, key) => {
|
|
53
|
+
if (isSelectingModel)
|
|
54
|
+
return;
|
|
31
55
|
if (isStreaming)
|
|
32
56
|
return;
|
|
57
|
+
if (pendingCommand && !isExecuting) {
|
|
58
|
+
if (char.toLowerCase() === "y" || key.return) {
|
|
59
|
+
setIsExecuting(true);
|
|
60
|
+
exec(pendingCommand, { cwd: process.cwd() }, (err, stdout, stderr) => {
|
|
61
|
+
const output = err ? stderr : stdout;
|
|
62
|
+
setMessages((prev) => [
|
|
63
|
+
...prev,
|
|
64
|
+
{ id: prev.length, role: "system", content: `Execution Output:\n${output || "Done"}` }
|
|
65
|
+
]);
|
|
66
|
+
setPendingCommand(null);
|
|
67
|
+
setIsExecuting(false);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
else if (char.toLowerCase() === "n" || key.escape) {
|
|
71
|
+
setPendingCommand(null);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
33
75
|
if (key.return) {
|
|
34
|
-
|
|
35
|
-
|
|
76
|
+
const trimmed = input.trim();
|
|
77
|
+
if (trimmed === "/exit" || trimmed === "/quit") {
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
if (trimmed === "/clear") {
|
|
81
|
+
setMessages([]);
|
|
36
82
|
setInput("");
|
|
83
|
+
setHistoryIndex(-1);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (trimmed === "/models") {
|
|
87
|
+
setIsSelectingModel(true);
|
|
88
|
+
setIsLoadingModels(true);
|
|
89
|
+
setInput("");
|
|
90
|
+
fetchModels(config).then(models => {
|
|
91
|
+
const options = models.map(m => ({ label: m.id || m.name, value: m.id || m.name }));
|
|
92
|
+
setModelOptions(options.length ? options : [{ label: "mock-model", value: "mock-model" }]);
|
|
93
|
+
setIsLoadingModels(false);
|
|
94
|
+
}).catch(err => {
|
|
95
|
+
setError(err);
|
|
96
|
+
setIsSelectingModel(false);
|
|
97
|
+
setIsLoadingModels(false);
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (trimmed.length > 0) {
|
|
102
|
+
const newPrompt = trimmed;
|
|
103
|
+
setMessages((prev) => [...prev, { id: prev.length, role: "user", content: newPrompt }]);
|
|
104
|
+
const newHistory = [...history, newPrompt];
|
|
105
|
+
setHistory(newHistory);
|
|
106
|
+
saveHistory(newHistory);
|
|
107
|
+
setInput("");
|
|
108
|
+
setHistoryIndex(-1);
|
|
37
109
|
setIsStreaming(true);
|
|
38
110
|
}
|
|
39
111
|
}
|
|
112
|
+
else if (key.upArrow) {
|
|
113
|
+
if (history.length > 0 && historyIndex < history.length - 1) {
|
|
114
|
+
const newIdx = historyIndex + 1;
|
|
115
|
+
setHistoryIndex(newIdx);
|
|
116
|
+
setInput(history[history.length - 1 - newIdx]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else if (key.downArrow) {
|
|
120
|
+
if (historyIndex > 0) {
|
|
121
|
+
const newIdx = historyIndex - 1;
|
|
122
|
+
setHistoryIndex(newIdx);
|
|
123
|
+
setInput(history[history.length - 1 - newIdx]);
|
|
124
|
+
}
|
|
125
|
+
else if (historyIndex === 0) {
|
|
126
|
+
setHistoryIndex(-1);
|
|
127
|
+
setInput("");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
40
130
|
else if (key.backspace || key.delete) {
|
|
41
131
|
setInput((prev) => prev.slice(0, -1));
|
|
42
132
|
}
|
|
43
|
-
else {
|
|
133
|
+
else if (char) {
|
|
44
134
|
setInput((prev) => prev + char);
|
|
45
135
|
}
|
|
46
136
|
});
|
|
47
|
-
return (React.createElement(
|
|
48
|
-
React.createElement(Box, { marginBottom: 1 },
|
|
49
|
-
React.createElement(Text, { bold: true, color: "cyan" }, "SwiftRouter CLI"),
|
|
50
|
-
React.createElement(Text, { dimColor: true },
|
|
51
|
-
" - Chatting with ",
|
|
52
|
-
model)),
|
|
53
|
-
messages.map((msg, idx) => (React.createElement(Box, { key: idx, flexDirection: "column", marginBottom: 1 },
|
|
137
|
+
return (React.createElement(React.Fragment, null,
|
|
138
|
+
React.createElement(Static, { items: messages }, msg => (React.createElement(Box, { key: msg.id, flexDirection: "column", marginBottom: 1 },
|
|
54
139
|
React.createElement(Text, { bold: true, color: msg.role === "user" ? "blue" : "green" },
|
|
55
140
|
msg.role === "user" ? "You" : "Assistant",
|
|
56
141
|
":"),
|
|
57
|
-
React.createElement(Text, null, msg.content)))),
|
|
58
|
-
|
|
59
|
-
React.createElement(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
142
|
+
React.createElement(Text, null, msg.role === "user" ? msg.content : marked.parse(msg.content).trim())))),
|
|
143
|
+
React.createElement(Box, { flexDirection: "row", width: "100%", marginTop: 1 },
|
|
144
|
+
React.createElement(Box, { flexDirection: "column", width: "70%", paddingRight: 2 },
|
|
145
|
+
isStreaming && (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
|
|
146
|
+
React.createElement(Text, { bold: true, color: "green" }, "Assistant:"),
|
|
147
|
+
React.createElement(Text, null, marked.parse(streamingContent).trim()),
|
|
148
|
+
React.createElement(Text, { dimColor: true }, "..."))),
|
|
149
|
+
isSelectingModel ? (React.createElement(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", padding: 1, marginTop: 1 },
|
|
150
|
+
React.createElement(Text, { color: "magenta", bold: true }, "Select Active Model"),
|
|
151
|
+
isLoadingModels ? (React.createElement(Text, { color: "yellow" }, "Fetching models from SwiftRouter API...")) : (
|
|
152
|
+
// @ts-ignore
|
|
153
|
+
React.createElement(SelectInput, { items: modelOptions, onSelect: (item) => {
|
|
154
|
+
setActiveModel(item.value);
|
|
155
|
+
setIsSelectingModel(false);
|
|
156
|
+
} })))) : (React.createElement(React.Fragment, null,
|
|
157
|
+
!isStreaming && !pendingCommand && !isExecuting && (React.createElement(Box, { flexDirection: "row" },
|
|
158
|
+
React.createElement(Text, { bold: true, color: "blue" }, "You: "),
|
|
159
|
+
React.createElement(Text, null, input),
|
|
160
|
+
React.createElement(Text, { dimColor: true }, " \u2588"))),
|
|
161
|
+
pendingCommand && !isExecuting && (React.createElement(Box, { flexDirection: "row", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1 },
|
|
162
|
+
React.createElement(Text, { color: "yellow" }, "Execute suggested command? "),
|
|
163
|
+
React.createElement(Text, { bold: true }, pendingCommand),
|
|
164
|
+
React.createElement(Text, { dimColor: true }, " [Y/n]"))),
|
|
165
|
+
isExecuting && (React.createElement(Box, { flexDirection: "row", marginTop: 1 },
|
|
166
|
+
React.createElement(Text, { bold: true, color: "yellow" }, "Executing command..."))))),
|
|
167
|
+
error && (React.createElement(Box, { marginTop: 1 },
|
|
168
|
+
React.createElement(Text, { color: "red" },
|
|
169
|
+
"Error: ",
|
|
170
|
+
error.message)))),
|
|
171
|
+
React.createElement(Box, { flexDirection: "column", width: "30%", borderStyle: "round", borderColor: "cyan", padding: 1, paddingX: 2 },
|
|
172
|
+
React.createElement(Text, { bold: true, color: "cyan", underline: true }, "SwiftRouter Context"),
|
|
173
|
+
React.createElement(Box, { marginY: 1, flexDirection: "column" },
|
|
174
|
+
React.createElement(Text, { color: "gray" }, "System Base URL:"),
|
|
175
|
+
React.createElement(Text, null, config.baseUrl)),
|
|
176
|
+
React.createElement(Box, { marginY: 1, flexDirection: "column" },
|
|
177
|
+
React.createElement(Text, { color: "gray" }, "Active Model:"),
|
|
178
|
+
React.createElement(Text, null, activeModel)),
|
|
179
|
+
React.createElement(Box, { marginY: 1, flexDirection: "column" },
|
|
180
|
+
React.createElement(Text, { color: "gray" }, "History Threads:"),
|
|
181
|
+
React.createElement(Text, null,
|
|
182
|
+
history.length,
|
|
183
|
+
" Prompts Saved"))))));
|
|
72
184
|
};
|
|
73
185
|
export function startChat(config, model, prompt) {
|
|
74
186
|
render(React.createElement(Chat, { config: config, model: model, initialPrompt: prompt }));
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "swiftroutercli",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "The official SwiftRouter Command Line Interface using React Ink Components",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
@@ -16,11 +16,15 @@
|
|
|
16
16
|
"author": "",
|
|
17
17
|
"license": "ISC",
|
|
18
18
|
"dependencies": {
|
|
19
|
+
"@types/marked-terminal": "^6.1.1",
|
|
19
20
|
"chalk": "^4.1.2",
|
|
20
21
|
"commander": "^14.0.3",
|
|
21
22
|
"dotenv": "^17.3.1",
|
|
22
23
|
"eventsource-parser": "^3.0.6",
|
|
23
24
|
"ink": "^6.8.0",
|
|
25
|
+
"ink-select-input": "^6.2.0",
|
|
26
|
+
"marked": "^15.0.12",
|
|
27
|
+
"marked-terminal": "^7.3.0",
|
|
24
28
|
"ora": "^5.4.1",
|
|
25
29
|
"react": "^19.2.4",
|
|
26
30
|
"terminal-image": "^4.2.0",
|
package/src/api/client.ts
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import { Config, loadConfig } from "../config.js";
|
|
2
2
|
import { createParser } from "eventsource-parser";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
function getWorkspaceContext(): string {
|
|
7
|
+
const cwd = process.cwd();
|
|
8
|
+
let context = `Current Workspace: ${cwd}\n`;
|
|
9
|
+
const filesToRead = ["README.md", "AGENTS.md", "package.json"];
|
|
10
|
+
for (const file of filesToRead) {
|
|
11
|
+
const filePath = path.join(cwd, file);
|
|
12
|
+
if (fs.existsSync(filePath)) {
|
|
13
|
+
try {
|
|
14
|
+
const content = fs.readFileSync(filePath, "utf-8").slice(0, 1000);
|
|
15
|
+
context += `\n--- File: ${file} ---\n${content}\n`;
|
|
16
|
+
} catch (e) { }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return context;
|
|
20
|
+
}
|
|
21
|
+
|
|
3
22
|
|
|
4
23
|
export async function fetchModels(config: Config): Promise<any[]> {
|
|
5
24
|
const url = `${config.baseUrl.replace(/\/$/, "")}/v1/models`;
|
|
@@ -20,13 +39,18 @@ export async function fetchModels(config: Config): Promise<any[]> {
|
|
|
20
39
|
export async function streamChatCompletion(
|
|
21
40
|
config: Config,
|
|
22
41
|
model: string,
|
|
23
|
-
|
|
42
|
+
messages: { role: string; content: string }[],
|
|
24
43
|
onData: (text: string) => void,
|
|
25
44
|
onComplete: () => void,
|
|
26
45
|
onError: (error: Error) => void
|
|
27
46
|
) {
|
|
28
47
|
const url = `${config.baseUrl.replace(/\/$/, "")}/v1/chat/completions`;
|
|
29
48
|
|
|
49
|
+
const payloadMessages = [...messages];
|
|
50
|
+
if (payloadMessages.length > 0 && payloadMessages[0].role !== "system") {
|
|
51
|
+
payloadMessages.unshift({ role: "system", content: getWorkspaceContext() });
|
|
52
|
+
}
|
|
53
|
+
|
|
30
54
|
try {
|
|
31
55
|
const response = await fetch(url, {
|
|
32
56
|
method: "POST",
|
|
@@ -36,7 +60,7 @@ export async function streamChatCompletion(
|
|
|
36
60
|
},
|
|
37
61
|
body: JSON.stringify({
|
|
38
62
|
model,
|
|
39
|
-
messages:
|
|
63
|
+
messages: payloadMessages,
|
|
40
64
|
stream: true,
|
|
41
65
|
}),
|
|
42
66
|
});
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { CONFIG_FILE, ENV_API_KEY, ENV_BASE_URL } from "./constants.js";
|
|
3
|
+
import { CONFIG_FILE, HISTORY_FILE, ENV_API_KEY, ENV_BASE_URL } from "./constants.js";
|
|
4
4
|
|
|
5
5
|
export interface Config {
|
|
6
6
|
apiKey: string;
|
|
@@ -42,3 +42,21 @@ export function saveConfig(config: Config) {
|
|
|
42
42
|
}
|
|
43
43
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
export function loadHistory(): string[] {
|
|
47
|
+
try {
|
|
48
|
+
if (fs.existsSync(HISTORY_FILE)) {
|
|
49
|
+
const data = fs.readFileSync(HISTORY_FILE, "utf-8");
|
|
50
|
+
return JSON.parse(data) as string[];
|
|
51
|
+
}
|
|
52
|
+
} catch (error) { }
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function saveHistory(history: string[]) {
|
|
57
|
+
const dir = path.dirname(HISTORY_FILE);
|
|
58
|
+
if (!fs.existsSync(dir)) {
|
|
59
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2), "utf-8");
|
|
62
|
+
}
|
package/src/constants.ts
CHANGED
|
@@ -8,6 +8,7 @@ export const DEFAULT_MODEL = "gpt-4o-mini";
|
|
|
8
8
|
// System Paths
|
|
9
9
|
export const CONFIG_DIR = path.join(os.homedir(), ".swiftrouter-cli");
|
|
10
10
|
export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
11
|
+
export const HISTORY_FILE = path.join(CONFIG_DIR, "history.json");
|
|
11
12
|
|
|
12
13
|
// Environment Variable Keys
|
|
13
14
|
export const ENV_API_KEY = "SWIFTROUTER_API_KEY";
|
package/src/ui/Chat.tsx
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import React, { useState, useEffect } from "react";
|
|
2
|
-
import { render, Box, Text, useInput } from "ink";
|
|
3
|
-
import { streamChatCompletion } from "../api/client.js";
|
|
4
|
-
import { Config } from "../config.js";
|
|
2
|
+
import { render, Box, Text, useInput, Static } from "ink";
|
|
3
|
+
import { streamChatCompletion, fetchModels } from "../api/client.js";
|
|
4
|
+
import { Config, loadHistory, saveHistory } from "../config.js";
|
|
5
|
+
import { marked } from "marked";
|
|
6
|
+
import TerminalRenderer from "marked-terminal";
|
|
7
|
+
import { exec } from "child_process";
|
|
8
|
+
import SelectInput from "ink-select-input";
|
|
9
|
+
|
|
10
|
+
interface ModelOption {
|
|
11
|
+
label: string;
|
|
12
|
+
value: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
marked.setOptions({
|
|
16
|
+
// @ts-ignore
|
|
17
|
+
renderer: new TerminalRenderer()
|
|
18
|
+
});
|
|
5
19
|
|
|
6
20
|
interface ChatProps {
|
|
7
21
|
config: Config;
|
|
@@ -10,30 +24,48 @@ interface ChatProps {
|
|
|
10
24
|
}
|
|
11
25
|
|
|
12
26
|
const Chat: React.FC<ChatProps> = ({ config, model, initialPrompt }) => {
|
|
13
|
-
const [messages, setMessages] = useState<{ role: string; content: string }[]>([
|
|
14
|
-
{ role: "user", content: initialPrompt },
|
|
27
|
+
const [messages, setMessages] = useState<{ id: number; role: string; content: string }[]>([
|
|
28
|
+
{ id: 0, role: "user", content: initialPrompt },
|
|
15
29
|
]);
|
|
16
30
|
const [streamingContent, setStreamingContent] = useState("");
|
|
17
31
|
const [isStreaming, setIsStreaming] = useState(true);
|
|
18
32
|
const [error, setError] = useState<Error | null>(null);
|
|
19
33
|
const [input, setInput] = useState("");
|
|
34
|
+
const [history, setHistory] = useState<string[]>([]);
|
|
35
|
+
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
36
|
+
const [pendingCommand, setPendingCommand] = useState<string | null>(null);
|
|
37
|
+
const [isExecuting, setIsExecuting] = useState(false);
|
|
38
|
+
|
|
39
|
+
const [activeModel, setActiveModel] = useState(model);
|
|
40
|
+
const [isSelectingModel, setIsSelectingModel] = useState(false);
|
|
41
|
+
const [modelOptions, setModelOptions] = useState<ModelOption[]>([]);
|
|
42
|
+
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
setHistory(loadHistory());
|
|
46
|
+
}, []);
|
|
20
47
|
|
|
21
48
|
useEffect(() => {
|
|
22
49
|
if (!isStreaming) return;
|
|
23
50
|
|
|
24
|
-
const
|
|
25
|
-
if (lastUserMessage.role !== "user") return;
|
|
51
|
+
const messagesPayload = messages.map(m => ({ role: m.role, content: m.content }));
|
|
26
52
|
|
|
27
53
|
streamChatCompletion(
|
|
28
54
|
config,
|
|
29
|
-
|
|
30
|
-
|
|
55
|
+
activeModel,
|
|
56
|
+
messagesPayload,
|
|
31
57
|
(text) => setStreamingContent((prev) => prev + text),
|
|
32
58
|
() => {
|
|
33
59
|
setMessages((prev) => [
|
|
34
60
|
...prev,
|
|
35
|
-
{ role: "assistant", content: streamingContent },
|
|
61
|
+
{ id: prev.length, role: "assistant", content: streamingContent },
|
|
36
62
|
]);
|
|
63
|
+
|
|
64
|
+
const bashMatch = streamingContent.match(/```(?:bash|sh)\n([\s\S]*?)\n```/);
|
|
65
|
+
if (bashMatch) {
|
|
66
|
+
setPendingCommand(bashMatch[1].trim());
|
|
67
|
+
}
|
|
68
|
+
|
|
37
69
|
setStreamingContent("");
|
|
38
70
|
setIsStreaming(false);
|
|
39
71
|
},
|
|
@@ -42,68 +74,180 @@ const Chat: React.FC<ChatProps> = ({ config, model, initialPrompt }) => {
|
|
|
42
74
|
setIsStreaming(false);
|
|
43
75
|
}
|
|
44
76
|
);
|
|
45
|
-
}, [messages, isStreaming, config,
|
|
77
|
+
}, [messages, isStreaming, config, activeModel]);
|
|
46
78
|
|
|
47
79
|
useInput((char: string, key: any) => {
|
|
80
|
+
if (isSelectingModel) return;
|
|
48
81
|
if (isStreaming) return;
|
|
49
82
|
|
|
83
|
+
if (pendingCommand && !isExecuting) {
|
|
84
|
+
if (char.toLowerCase() === "y" || key.return) {
|
|
85
|
+
setIsExecuting(true);
|
|
86
|
+
exec(pendingCommand, { cwd: process.cwd() }, (err, stdout, stderr) => {
|
|
87
|
+
const output = err ? stderr : stdout;
|
|
88
|
+
setMessages((prev) => [
|
|
89
|
+
...prev,
|
|
90
|
+
{ id: prev.length, role: "system", content: `Execution Output:\n${output || "Done"}` }
|
|
91
|
+
]);
|
|
92
|
+
setPendingCommand(null);
|
|
93
|
+
setIsExecuting(false);
|
|
94
|
+
});
|
|
95
|
+
} else if (char.toLowerCase() === "n" || key.escape) {
|
|
96
|
+
setPendingCommand(null);
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
50
101
|
if (key.return) {
|
|
51
|
-
|
|
52
|
-
|
|
102
|
+
const trimmed = input.trim();
|
|
103
|
+
if (trimmed === "/exit" || trimmed === "/quit") {
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
106
|
+
if (trimmed === "/clear") {
|
|
107
|
+
setMessages([]);
|
|
108
|
+
setInput("");
|
|
109
|
+
setHistoryIndex(-1);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (trimmed === "/models") {
|
|
113
|
+
setIsSelectingModel(true);
|
|
114
|
+
setIsLoadingModels(true);
|
|
115
|
+
setInput("");
|
|
116
|
+
fetchModels(config).then(models => {
|
|
117
|
+
const options = models.map(m => ({ label: m.id || m.name, value: m.id || m.name }));
|
|
118
|
+
setModelOptions(options.length ? options : [{ label: "mock-model", value: "mock-model" }]);
|
|
119
|
+
setIsLoadingModels(false);
|
|
120
|
+
}).catch(err => {
|
|
121
|
+
setError(err);
|
|
122
|
+
setIsSelectingModel(false);
|
|
123
|
+
setIsLoadingModels(false);
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (trimmed.length > 0) {
|
|
129
|
+
const newPrompt = trimmed;
|
|
130
|
+
setMessages((prev) => [...prev, { id: prev.length, role: "user", content: newPrompt }]);
|
|
131
|
+
|
|
132
|
+
const newHistory = [...history, newPrompt];
|
|
133
|
+
setHistory(newHistory);
|
|
134
|
+
saveHistory(newHistory);
|
|
135
|
+
|
|
53
136
|
setInput("");
|
|
137
|
+
setHistoryIndex(-1);
|
|
54
138
|
setIsStreaming(true);
|
|
55
139
|
}
|
|
140
|
+
} else if (key.upArrow) {
|
|
141
|
+
if (history.length > 0 && historyIndex < history.length - 1) {
|
|
142
|
+
const newIdx = historyIndex + 1;
|
|
143
|
+
setHistoryIndex(newIdx);
|
|
144
|
+
setInput(history[history.length - 1 - newIdx]);
|
|
145
|
+
}
|
|
146
|
+
} else if (key.downArrow) {
|
|
147
|
+
if (historyIndex > 0) {
|
|
148
|
+
const newIdx = historyIndex - 1;
|
|
149
|
+
setHistoryIndex(newIdx);
|
|
150
|
+
setInput(history[history.length - 1 - newIdx]);
|
|
151
|
+
} else if (historyIndex === 0) {
|
|
152
|
+
setHistoryIndex(-1);
|
|
153
|
+
setInput("");
|
|
154
|
+
}
|
|
56
155
|
} else if (key.backspace || key.delete) {
|
|
57
156
|
setInput((prev) => prev.slice(0, -1));
|
|
58
|
-
} else {
|
|
157
|
+
} else if (char) {
|
|
59
158
|
setInput((prev) => prev + char);
|
|
60
159
|
}
|
|
61
160
|
});
|
|
62
161
|
|
|
63
162
|
return (
|
|
64
|
-
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
163
|
+
<>
|
|
164
|
+
<Static items={messages}>
|
|
165
|
+
{msg => (
|
|
166
|
+
<Box key={msg.id} flexDirection="column" marginBottom={1}>
|
|
167
|
+
<Text bold color={msg.role === "user" ? "blue" : "green"}>
|
|
168
|
+
{msg.role === "user" ? "You" : "Assistant"}:
|
|
169
|
+
</Text>
|
|
170
|
+
<Text>{msg.role === "user" ? msg.content : (marked.parse(msg.content) as string).trim()}</Text>
|
|
171
|
+
</Box>
|
|
172
|
+
)}
|
|
173
|
+
</Static>
|
|
71
174
|
|
|
72
|
-
|
|
73
|
-
<Box
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
175
|
+
<Box flexDirection="row" width="100%" marginTop={1}>
|
|
176
|
+
<Box flexDirection="column" width="70%" paddingRight={2}>
|
|
177
|
+
{isStreaming && (
|
|
178
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
179
|
+
<Text bold color="green">Assistant:</Text>
|
|
180
|
+
<Text>{(marked.parse(streamingContent) as string).trim()}</Text>
|
|
181
|
+
<Text dimColor>...</Text>
|
|
182
|
+
</Box>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
{isSelectingModel ? (
|
|
186
|
+
<Box flexDirection="column" borderStyle="round" borderColor="magenta" padding={1} marginTop={1}>
|
|
187
|
+
<Text color="magenta" bold>Select Active Model</Text>
|
|
188
|
+
{isLoadingModels ? (
|
|
189
|
+
<Text color="yellow">Fetching models from SwiftRouter API...</Text>
|
|
190
|
+
) : (
|
|
191
|
+
// @ts-ignore
|
|
192
|
+
<SelectInput
|
|
193
|
+
items={modelOptions}
|
|
194
|
+
onSelect={(item: any) => {
|
|
195
|
+
setActiveModel(item.value);
|
|
196
|
+
setIsSelectingModel(false);
|
|
197
|
+
}}
|
|
198
|
+
/>
|
|
199
|
+
)}
|
|
200
|
+
</Box>
|
|
201
|
+
) : (
|
|
202
|
+
<>
|
|
203
|
+
{!isStreaming && !pendingCommand && !isExecuting && (
|
|
204
|
+
<Box flexDirection="row">
|
|
205
|
+
<Text bold color="blue">You: </Text>
|
|
206
|
+
<Text>{input}</Text>
|
|
207
|
+
<Text dimColor> █</Text>
|
|
208
|
+
</Box>
|
|
209
|
+
)}
|
|
210
|
+
|
|
211
|
+
{pendingCommand && !isExecuting && (
|
|
212
|
+
<Box flexDirection="row" borderStyle="round" borderColor="yellow" paddingX={1} marginTop={1}>
|
|
213
|
+
<Text color="yellow">Execute suggested command? </Text>
|
|
214
|
+
<Text bold>{pendingCommand}</Text>
|
|
215
|
+
<Text dimColor> [Y/n]</Text>
|
|
216
|
+
</Box>
|
|
217
|
+
)}
|
|
90
218
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
219
|
+
{isExecuting && (
|
|
220
|
+
<Box flexDirection="row" marginTop={1}>
|
|
221
|
+
<Text bold color="yellow">Executing command...</Text>
|
|
222
|
+
</Box>
|
|
223
|
+
)}
|
|
224
|
+
</>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
{error && (
|
|
228
|
+
<Box marginTop={1}>
|
|
229
|
+
<Text color="red">Error: {error.message}</Text>
|
|
230
|
+
</Box>
|
|
231
|
+
)}
|
|
94
232
|
</Box>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
</
|
|
102
|
-
<
|
|
103
|
-
|
|
233
|
+
|
|
234
|
+
<Box flexDirection="column" width="30%" borderStyle="round" borderColor="cyan" padding={1} paddingX={2}>
|
|
235
|
+
<Text bold color="cyan" underline>SwiftRouter Context</Text>
|
|
236
|
+
<Box marginY={1} flexDirection="column">
|
|
237
|
+
<Text color="gray">System Base URL:</Text>
|
|
238
|
+
<Text>{config.baseUrl}</Text>
|
|
239
|
+
</Box>
|
|
240
|
+
<Box marginY={1} flexDirection="column">
|
|
241
|
+
<Text color="gray">Active Model:</Text>
|
|
242
|
+
<Text>{activeModel}</Text>
|
|
243
|
+
</Box>
|
|
244
|
+
<Box marginY={1} flexDirection="column">
|
|
245
|
+
<Text color="gray">History Threads:</Text>
|
|
246
|
+
<Text>{history.length} Prompts Saved</Text>
|
|
247
|
+
</Box>
|
|
104
248
|
</Box>
|
|
105
|
-
|
|
106
|
-
|
|
249
|
+
</Box>
|
|
250
|
+
</>
|
|
107
251
|
);
|
|
108
252
|
};
|
|
109
253
|
|