activo 0.2.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/FINAL_SIMPLIFIED_SPEC.md +456 -0
- package/README.md +62 -0
- package/TODO.md +193 -0
- package/dist/cli/banner.d.ts +3 -0
- package/dist/cli/banner.d.ts.map +1 -0
- package/dist/cli/banner.js +30 -0
- package/dist/cli/banner.js.map +1 -0
- package/dist/cli/headless.d.ts +3 -0
- package/dist/cli/headless.d.ts.map +1 -0
- package/dist/cli/headless.js +34 -0
- package/dist/cli/headless.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +42 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/core/agent.d.ts +23 -0
- package/dist/core/agent.d.ts.map +1 -0
- package/dist/core/agent.js +171 -0
- package/dist/core/agent.js.map +1 -0
- package/dist/core/config.d.ts +24 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +66 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/llm/ollama.d.ts +30 -0
- package/dist/core/llm/ollama.d.ts.map +1 -0
- package/dist/core/llm/ollama.js +173 -0
- package/dist/core/llm/ollama.js.map +1 -0
- package/dist/core/mcp/client.d.ts +22 -0
- package/dist/core/mcp/client.d.ts.map +1 -0
- package/dist/core/mcp/client.js +116 -0
- package/dist/core/mcp/client.js.map +1 -0
- package/dist/core/tools/builtIn.d.ts +9 -0
- package/dist/core/tools/builtIn.d.ts.map +1 -0
- package/dist/core/tools/builtIn.js +219 -0
- package/dist/core/tools/builtIn.js.map +1 -0
- package/dist/core/tools/index.d.ts +13 -0
- package/dist/core/tools/index.d.ts.map +1 -0
- package/dist/core/tools/index.js +43 -0
- package/dist/core/tools/index.js.map +1 -0
- package/dist/core/tools/standards.d.ts +6 -0
- package/dist/core/tools/standards.d.ts.map +1 -0
- package/dist/core/tools/standards.js +215 -0
- package/dist/core/tools/standards.js.map +1 -0
- package/dist/core/tools/types.d.ts +34 -0
- package/dist/core/tools/types.d.ts.map +1 -0
- package/dist/core/tools/types.js +2 -0
- package/dist/core/tools/types.js.map +1 -0
- package/dist/ui/App.d.ts +10 -0
- package/dist/ui/App.d.ts.map +1 -0
- package/dist/ui/App.js +167 -0
- package/dist/ui/App.js.map +1 -0
- package/dist/ui/components/InputBox.d.ts +11 -0
- package/dist/ui/components/InputBox.d.ts.map +1 -0
- package/dist/ui/components/InputBox.js +7 -0
- package/dist/ui/components/InputBox.js.map +1 -0
- package/dist/ui/components/MessageList.d.ts +17 -0
- package/dist/ui/components/MessageList.d.ts.map +1 -0
- package/dist/ui/components/MessageList.js +18 -0
- package/dist/ui/components/MessageList.js.map +1 -0
- package/dist/ui/components/StatusBar.d.ts +9 -0
- package/dist/ui/components/StatusBar.d.ts.map +1 -0
- package/dist/ui/components/StatusBar.js +6 -0
- package/dist/ui/components/StatusBar.js.map +1 -0
- package/dist/ui/components/ToolStatus.d.ts +8 -0
- package/dist/ui/components/ToolStatus.d.ts.map +1 -0
- package/dist/ui/components/ToolStatus.js +7 -0
- package/dist/ui/components/ToolStatus.js.map +1 -0
- package/package.json +64 -0
- package/screenshot.png +0 -0
- package/src/cli/banner.ts +34 -0
- package/src/cli/headless.ts +37 -0
- package/src/cli/index.ts +53 -0
- package/src/core/agent.ts +235 -0
- package/src/core/config.ts +98 -0
- package/src/core/llm/ollama.ts +238 -0
- package/src/core/mcp/client.ts +143 -0
- package/src/core/tools/builtIn.ts +221 -0
- package/src/core/tools/index.ts +53 -0
- package/src/core/tools/standards.ts +246 -0
- package/src/core/tools/types.ts +37 -0
- package/src/ui/App.tsx +238 -0
- package/src/ui/components/InputBox.tsx +37 -0
- package/src/ui/components/MessageList.tsx +80 -0
- package/src/ui/components/StatusBar.tsx +36 -0
- package/src/ui/components/ToolStatus.tsx +38 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import pdfParse from "pdf-parse";
|
|
4
|
+
import { Tool, ToolResult } from "./types.js";
|
|
5
|
+
import { OllamaClient } from "../llm/ollama.js";
|
|
6
|
+
import { loadConfig } from "../config.js";
|
|
7
|
+
|
|
8
|
+
// Import PDF Tool
|
|
9
|
+
export const importPdfTool: Tool = {
|
|
10
|
+
name: "import_pdf_standards",
|
|
11
|
+
description: "Import development standards from a PDF file and convert to markdown rules.",
|
|
12
|
+
parameters: {
|
|
13
|
+
type: "object",
|
|
14
|
+
required: ["pdfPath"],
|
|
15
|
+
properties: {
|
|
16
|
+
pdfPath: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "Path to the PDF file",
|
|
19
|
+
},
|
|
20
|
+
outputDir: {
|
|
21
|
+
type: "string",
|
|
22
|
+
description: "Output directory (default: .activo/standards)",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
handler: async (args): Promise<ToolResult> => {
|
|
27
|
+
try {
|
|
28
|
+
const pdfPath = path.resolve(args.pdfPath as string);
|
|
29
|
+
const outputDir = path.resolve((args.outputDir as string) || ".activo/standards");
|
|
30
|
+
|
|
31
|
+
if (!fs.existsSync(pdfPath)) {
|
|
32
|
+
return { success: false, content: "", error: `PDF not found: ${pdfPath}` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Parse PDF
|
|
36
|
+
const dataBuffer = fs.readFileSync(pdfPath);
|
|
37
|
+
const data = await pdfParse(dataBuffer);
|
|
38
|
+
|
|
39
|
+
// Create output directory
|
|
40
|
+
if (!fs.existsSync(outputDir)) {
|
|
41
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Split into chunks (simple approach: by paragraphs)
|
|
45
|
+
const chunks = splitIntoChunks(data.text, 3000);
|
|
46
|
+
const filename = path.basename(pdfPath, ".pdf");
|
|
47
|
+
const extractionDate = new Date().toISOString().split("T")[0];
|
|
48
|
+
|
|
49
|
+
// Save chunks as markdown
|
|
50
|
+
const results: string[] = [];
|
|
51
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
52
|
+
const chunkFilename = `${String(i + 1).padStart(2, "0")}_${sanitize(filename)}.md`;
|
|
53
|
+
const chunkPath = path.join(outputDir, chunkFilename);
|
|
54
|
+
|
|
55
|
+
let md = `# ${filename} - Part ${i + 1}\n\n`;
|
|
56
|
+
md += `> Source: ${path.basename(pdfPath)}\n`;
|
|
57
|
+
md += `> Extracted: ${extractionDate}\n`;
|
|
58
|
+
md += `> Pages: ${data.numpages}\n\n`;
|
|
59
|
+
md += `---\n\n`;
|
|
60
|
+
md += chunks[i];
|
|
61
|
+
md += `\n\n---\n`;
|
|
62
|
+
md += `[Edit this file to add structured rules]\n`;
|
|
63
|
+
|
|
64
|
+
fs.writeFileSync(chunkPath, md);
|
|
65
|
+
results.push(chunkFilename);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Create index
|
|
69
|
+
const indexPath = path.join(outputDir, "_index.md");
|
|
70
|
+
let indexMd = `# Development Standards Index\n\n`;
|
|
71
|
+
indexMd += `> Source: ${path.basename(pdfPath)}\n`;
|
|
72
|
+
indexMd += `> Extracted: ${extractionDate}\n`;
|
|
73
|
+
indexMd += `> Files: ${results.length}\n\n`;
|
|
74
|
+
indexMd += `## Files\n\n`;
|
|
75
|
+
for (const r of results) {
|
|
76
|
+
indexMd += `- [${r}](./${r})\n`;
|
|
77
|
+
}
|
|
78
|
+
fs.writeFileSync(indexPath, indexMd);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
success: true,
|
|
82
|
+
content: `Imported ${results.length} files to ${outputDir}\n\nFiles:\n${results.join("\n")}\n\nNext: Edit the files to add structured rules with format:\n## RULE-001: Title\n- 심각도: error|warning|info\n- 규칙: description`,
|
|
83
|
+
};
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return { success: false, content: "", error: String(error) };
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// List Standards Tool
|
|
91
|
+
export const listStandardsTool: Tool = {
|
|
92
|
+
name: "list_standards",
|
|
93
|
+
description: "List all loaded development standards and rules.",
|
|
94
|
+
parameters: {
|
|
95
|
+
type: "object",
|
|
96
|
+
properties: {
|
|
97
|
+
directory: {
|
|
98
|
+
type: "string",
|
|
99
|
+
description: "Standards directory (default: .activo/standards)",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
handler: async (args): Promise<ToolResult> => {
|
|
104
|
+
try {
|
|
105
|
+
const dir = path.resolve((args.directory as string) || ".activo/standards");
|
|
106
|
+
|
|
107
|
+
if (!fs.existsSync(dir)) {
|
|
108
|
+
return { success: true, content: "No standards directory found. Import a PDF first." };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md") && f !== "_index.md");
|
|
112
|
+
if (files.length === 0) {
|
|
113
|
+
return { success: true, content: "No standard files found." };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let totalRules = 0;
|
|
117
|
+
const results: string[] = [];
|
|
118
|
+
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
const content = fs.readFileSync(path.join(dir, file), "utf-8");
|
|
121
|
+
const rules = content.match(/^## [A-Z]+-\d+/gm) || [];
|
|
122
|
+
totalRules += rules.length;
|
|
123
|
+
results.push(`📄 ${file}: ${rules.length} rules`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
success: true,
|
|
128
|
+
content: `Standards Directory: ${dir}\n\n${results.join("\n")}\n\nTotal: ${files.length} files, ${totalRules} rules`,
|
|
129
|
+
};
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return { success: false, content: "", error: String(error) };
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Check Code Quality Tool
|
|
137
|
+
export const checkQualityTool: Tool = {
|
|
138
|
+
name: "check_code_quality",
|
|
139
|
+
description: "Check code against loaded development standards.",
|
|
140
|
+
parameters: {
|
|
141
|
+
type: "object",
|
|
142
|
+
required: ["filepath"],
|
|
143
|
+
properties: {
|
|
144
|
+
filepath: {
|
|
145
|
+
type: "string",
|
|
146
|
+
description: "File or directory to check",
|
|
147
|
+
},
|
|
148
|
+
standardsDir: {
|
|
149
|
+
type: "string",
|
|
150
|
+
description: "Standards directory (default: .activo/standards)",
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
handler: async (args): Promise<ToolResult> => {
|
|
155
|
+
try {
|
|
156
|
+
const filepath = path.resolve(args.filepath as string);
|
|
157
|
+
const standardsDir = path.resolve((args.standardsDir as string) || ".activo/standards");
|
|
158
|
+
|
|
159
|
+
if (!fs.existsSync(filepath)) {
|
|
160
|
+
return { success: false, content: "", error: `Path not found: ${filepath}` };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Load standards
|
|
164
|
+
let standards = "";
|
|
165
|
+
if (fs.existsSync(standardsDir)) {
|
|
166
|
+
const files = fs.readdirSync(standardsDir).filter((f) => f.endsWith(".md") && f !== "_index.md");
|
|
167
|
+
for (const file of files) {
|
|
168
|
+
standards += fs.readFileSync(path.join(standardsDir, file), "utf-8") + "\n\n";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Get code
|
|
173
|
+
let code = "";
|
|
174
|
+
const stat = fs.statSync(filepath);
|
|
175
|
+
if (stat.isFile()) {
|
|
176
|
+
code = fs.readFileSync(filepath, "utf-8");
|
|
177
|
+
} else {
|
|
178
|
+
return { success: false, content: "", error: "Directory check not yet supported. Specify a file." };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Build analysis prompt
|
|
182
|
+
const prompt = buildAnalysisPrompt(code, filepath, standards);
|
|
183
|
+
|
|
184
|
+
// Call Ollama
|
|
185
|
+
const config = loadConfig();
|
|
186
|
+
const client = new OllamaClient(config.ollama);
|
|
187
|
+
|
|
188
|
+
const response = await client.chat([{ role: "user", content: prompt }]);
|
|
189
|
+
|
|
190
|
+
return { success: true, content: response.content };
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return { success: false, content: "", error: String(error) };
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Helper functions
|
|
198
|
+
function splitIntoChunks(text: string, maxSize: number): string[] {
|
|
199
|
+
const chunks: string[] = [];
|
|
200
|
+
const paragraphs = text.split(/\n\n+/);
|
|
201
|
+
let current = "";
|
|
202
|
+
|
|
203
|
+
for (const para of paragraphs) {
|
|
204
|
+
if (current.length + para.length > maxSize && current.length > 0) {
|
|
205
|
+
chunks.push(current.trim());
|
|
206
|
+
current = "";
|
|
207
|
+
}
|
|
208
|
+
current += para + "\n\n";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (current.trim()) {
|
|
212
|
+
chunks.push(current.trim());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return chunks.length > 0 ? chunks : [text];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function sanitize(name: string): string {
|
|
219
|
+
return name.replace(/[^a-zA-Z0-9가-힣\s-]/g, "").replace(/\s+/g, "_").slice(0, 50);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function buildAnalysisPrompt(code: string, filepath: string, standards: string): string {
|
|
223
|
+
const ext = path.extname(filepath);
|
|
224
|
+
const lang = { ".ts": "typescript", ".js": "javascript", ".java": "java", ".py": "python" }[ext] || "text";
|
|
225
|
+
|
|
226
|
+
let prompt = `당신은 코드 품질 전문가입니다. 아래 코드를 분석하세요.\n\n`;
|
|
227
|
+
|
|
228
|
+
if (standards) {
|
|
229
|
+
prompt += `[개발 표준 규칙]\n${standards.slice(0, 4000)}\n\n`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
prompt += `[분석 대상 코드]\n파일: ${filepath}\n\`\`\`${lang}\n${code.slice(0, 8000)}\n\`\`\`\n\n`;
|
|
233
|
+
prompt += `[점검 요청]\n`;
|
|
234
|
+
prompt += `1. 규칙 위반 사항 (있다면)\n`;
|
|
235
|
+
prompt += `2. 개선 제안\n`;
|
|
236
|
+
prompt += `3. 전반적인 코드 품질 평가\n`;
|
|
237
|
+
|
|
238
|
+
return prompt;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// All standards tools
|
|
242
|
+
export const standardsTools: Tool[] = [
|
|
243
|
+
importPdfTool,
|
|
244
|
+
listStandardsTool,
|
|
245
|
+
checkQualityTool,
|
|
246
|
+
];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface Tool {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
parameters: {
|
|
5
|
+
type: "object";
|
|
6
|
+
required?: string[];
|
|
7
|
+
properties: Record<string, ToolParameter>;
|
|
8
|
+
};
|
|
9
|
+
handler: (args: Record<string, unknown>) => Promise<ToolResult>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ToolParameter {
|
|
13
|
+
type: string;
|
|
14
|
+
description: string;
|
|
15
|
+
enum?: string[];
|
|
16
|
+
default?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ToolCall {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
arguments: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ToolResult {
|
|
26
|
+
success: boolean;
|
|
27
|
+
content: string;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ToolEvent {
|
|
32
|
+
type: "tool_use";
|
|
33
|
+
tool: string;
|
|
34
|
+
status: "start" | "complete" | "error";
|
|
35
|
+
args?: Record<string, unknown>;
|
|
36
|
+
result?: ToolResult;
|
|
37
|
+
}
|
package/src/ui/App.tsx
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
3
|
+
import { Config } from "../core/config.js";
|
|
4
|
+
import { OllamaClient, ChatMessage } from "../core/llm/ollama.js";
|
|
5
|
+
import { streamProcessMessage, AgentEvent } from "../core/agent.js";
|
|
6
|
+
import { InputBox } from "./components/InputBox.js";
|
|
7
|
+
import { MessageList } from "./components/MessageList.js";
|
|
8
|
+
import { StatusBar } from "./components/StatusBar.js";
|
|
9
|
+
import { ToolStatus } from "./components/ToolStatus.js";
|
|
10
|
+
|
|
11
|
+
interface AppProps {
|
|
12
|
+
initialPrompt?: string;
|
|
13
|
+
config: Config;
|
|
14
|
+
resume?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Message {
|
|
18
|
+
role: "user" | "assistant";
|
|
19
|
+
content: string;
|
|
20
|
+
toolCalls?: Array<{
|
|
21
|
+
tool: string;
|
|
22
|
+
status: "running" | "complete" | "error";
|
|
23
|
+
result?: string;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function App({ initialPrompt, config, resume }: AppProps): React.ReactElement {
|
|
28
|
+
const { exit } = useApp();
|
|
29
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
30
|
+
const [input, setInput] = useState("");
|
|
31
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
32
|
+
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
|
33
|
+
const [toolStatus, setToolStatus] = useState<"running" | "complete" | "error" | null>(null);
|
|
34
|
+
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
const [client] = useState(() => new OllamaClient(config.ollama));
|
|
36
|
+
const [exitPending, setExitPending] = useState(false);
|
|
37
|
+
const [cancelled, setCancelled] = useState(false);
|
|
38
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
39
|
+
|
|
40
|
+
// Handle Ctrl+C and ESC
|
|
41
|
+
useInput((inputChar, key) => {
|
|
42
|
+
// ESC key to cancel current operation
|
|
43
|
+
if (key.escape && isProcessing) {
|
|
44
|
+
if (abortControllerRef.current) {
|
|
45
|
+
abortControllerRef.current.abort();
|
|
46
|
+
setCancelled(true);
|
|
47
|
+
setIsProcessing(false);
|
|
48
|
+
setCurrentTool(null);
|
|
49
|
+
setToolStatus(null);
|
|
50
|
+
setError("Operation cancelled by user (ESC)");
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Ctrl+C to exit
|
|
56
|
+
if (key.ctrl && inputChar === "c") {
|
|
57
|
+
if (isProcessing && abortControllerRef.current) {
|
|
58
|
+
abortControllerRef.current.abort();
|
|
59
|
+
setCancelled(true);
|
|
60
|
+
setIsProcessing(false);
|
|
61
|
+
setCurrentTool(null);
|
|
62
|
+
setToolStatus(null);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (exitPending) {
|
|
66
|
+
exit();
|
|
67
|
+
} else {
|
|
68
|
+
setExitPending(true);
|
|
69
|
+
setTimeout(() => setExitPending(false), 1000);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Check Ollama connection on mount
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const checkConnection = async () => {
|
|
77
|
+
const connected = await client.isConnected();
|
|
78
|
+
if (!connected) {
|
|
79
|
+
setError(`Cannot connect to Ollama at ${config.ollama.baseUrl}`);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
checkConnection();
|
|
83
|
+
}, [client, config.ollama.baseUrl]);
|
|
84
|
+
|
|
85
|
+
// Process initial prompt
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (initialPrompt) {
|
|
88
|
+
handleSubmit(initialPrompt);
|
|
89
|
+
}
|
|
90
|
+
}, [initialPrompt]);
|
|
91
|
+
|
|
92
|
+
const handleSubmit = useCallback(async (text: string) => {
|
|
93
|
+
if (!text.trim() || isProcessing) return;
|
|
94
|
+
|
|
95
|
+
setInput("");
|
|
96
|
+
setIsProcessing(true);
|
|
97
|
+
setError(null);
|
|
98
|
+
setCancelled(false);
|
|
99
|
+
|
|
100
|
+
// Create AbortController for this request
|
|
101
|
+
const abortController = new AbortController();
|
|
102
|
+
abortControllerRef.current = abortController;
|
|
103
|
+
|
|
104
|
+
// Add user message
|
|
105
|
+
const userMessage: Message = { role: "user", content: text };
|
|
106
|
+
setMessages((prev) => [...prev, userMessage]);
|
|
107
|
+
|
|
108
|
+
// Convert messages to chat format
|
|
109
|
+
const history: ChatMessage[] = messages.map((m) => ({
|
|
110
|
+
role: m.role,
|
|
111
|
+
content: m.content,
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
// Create assistant message placeholder
|
|
115
|
+
const assistantMessage: Message = { role: "assistant", content: "", toolCalls: [] };
|
|
116
|
+
setMessages((prev) => [...prev, assistantMessage]);
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
let fullContent = "";
|
|
120
|
+
|
|
121
|
+
for await (const event of streamProcessMessage(text, history, client, config, abortController.signal)) {
|
|
122
|
+
// Check if cancelled
|
|
123
|
+
if (abortController.signal.aborted) {
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
switch (event.type) {
|
|
127
|
+
case "content":
|
|
128
|
+
fullContent += event.content || "";
|
|
129
|
+
setMessages((prev) => {
|
|
130
|
+
const updated = [...prev];
|
|
131
|
+
const last = updated[updated.length - 1];
|
|
132
|
+
if (last.role === "assistant") {
|
|
133
|
+
last.content = fullContent;
|
|
134
|
+
}
|
|
135
|
+
return updated;
|
|
136
|
+
});
|
|
137
|
+
break;
|
|
138
|
+
|
|
139
|
+
case "tool_use":
|
|
140
|
+
setCurrentTool(event.tool || null);
|
|
141
|
+
setToolStatus("running");
|
|
142
|
+
setMessages((prev) => {
|
|
143
|
+
const updated = [...prev];
|
|
144
|
+
const last = updated[updated.length - 1];
|
|
145
|
+
if (last.role === "assistant") {
|
|
146
|
+
last.toolCalls = [
|
|
147
|
+
...(last.toolCalls || []),
|
|
148
|
+
{ tool: event.tool!, status: "running" },
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
return updated;
|
|
152
|
+
});
|
|
153
|
+
break;
|
|
154
|
+
|
|
155
|
+
case "tool_result":
|
|
156
|
+
setToolStatus(event.status as "complete" | "error");
|
|
157
|
+
setMessages((prev) => {
|
|
158
|
+
const updated = [...prev];
|
|
159
|
+
const last = updated[updated.length - 1];
|
|
160
|
+
if (last.role === "assistant" && last.toolCalls) {
|
|
161
|
+
const toolCall = last.toolCalls.find((tc) => tc.tool === event.tool);
|
|
162
|
+
if (toolCall) {
|
|
163
|
+
toolCall.status = event.status as "complete" | "error";
|
|
164
|
+
toolCall.result = event.result?.content || event.result?.error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return updated;
|
|
168
|
+
});
|
|
169
|
+
setTimeout(() => {
|
|
170
|
+
setCurrentTool(null);
|
|
171
|
+
setToolStatus(null);
|
|
172
|
+
}, 500);
|
|
173
|
+
break;
|
|
174
|
+
|
|
175
|
+
case "error":
|
|
176
|
+
setError(event.error || "Unknown error");
|
|
177
|
+
break;
|
|
178
|
+
|
|
179
|
+
case "done":
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (!abortController.signal.aborted) {
|
|
185
|
+
setError(String(err));
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
setIsProcessing(false);
|
|
189
|
+
setCurrentTool(null);
|
|
190
|
+
setToolStatus(null);
|
|
191
|
+
abortControllerRef.current = null;
|
|
192
|
+
}
|
|
193
|
+
}, [messages, client, config, isProcessing]);
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<Box flexDirection="column" height="100%">
|
|
197
|
+
{/* Messages */}
|
|
198
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
199
|
+
<MessageList messages={messages} />
|
|
200
|
+
</Box>
|
|
201
|
+
|
|
202
|
+
{/* Tool Status */}
|
|
203
|
+
{currentTool && (
|
|
204
|
+
<ToolStatus tool={currentTool} status={toolStatus || "running"} />
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{/* Error */}
|
|
208
|
+
{error && (
|
|
209
|
+
<Box marginY={1}>
|
|
210
|
+
<Text color="red">⚠ {error}</Text>
|
|
211
|
+
</Box>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{/* Exit Warning */}
|
|
215
|
+
{exitPending && (
|
|
216
|
+
<Box marginY={1}>
|
|
217
|
+
<Text color="yellow">Press Ctrl+C again to exit</Text>
|
|
218
|
+
</Box>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{/* Input */}
|
|
222
|
+
<InputBox
|
|
223
|
+
value={input}
|
|
224
|
+
onChange={setInput}
|
|
225
|
+
onSubmit={handleSubmit}
|
|
226
|
+
isProcessing={isProcessing}
|
|
227
|
+
placeholder="Type your message..."
|
|
228
|
+
/>
|
|
229
|
+
|
|
230
|
+
{/* Status Bar */}
|
|
231
|
+
<StatusBar
|
|
232
|
+
model={config.ollama.model}
|
|
233
|
+
isProcessing={isProcessing}
|
|
234
|
+
messageCount={messages.length}
|
|
235
|
+
/>
|
|
236
|
+
</Box>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import TextInput from "ink-text-input";
|
|
4
|
+
|
|
5
|
+
interface InputBoxProps {
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
onSubmit: (value: string) => void;
|
|
9
|
+
isProcessing: boolean;
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function InputBox({
|
|
14
|
+
value,
|
|
15
|
+
onChange,
|
|
16
|
+
onSubmit,
|
|
17
|
+
isProcessing,
|
|
18
|
+
placeholder,
|
|
19
|
+
}: InputBoxProps): React.ReactElement {
|
|
20
|
+
return (
|
|
21
|
+
<Box borderStyle="round" borderColor={isProcessing ? "gray" : "cyan"} paddingX={1}>
|
|
22
|
+
<Text color="green" bold>
|
|
23
|
+
{isProcessing ? "⏳" : "❯"}{" "}
|
|
24
|
+
</Text>
|
|
25
|
+
{isProcessing ? (
|
|
26
|
+
<Text color="gray">Processing...</Text>
|
|
27
|
+
) : (
|
|
28
|
+
<TextInput
|
|
29
|
+
value={value}
|
|
30
|
+
onChange={onChange}
|
|
31
|
+
onSubmit={onSubmit}
|
|
32
|
+
placeholder={placeholder}
|
|
33
|
+
/>
|
|
34
|
+
)}
|
|
35
|
+
</Box>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
|
|
4
|
+
interface ToolCall {
|
|
5
|
+
tool: string;
|
|
6
|
+
status: "running" | "complete" | "error";
|
|
7
|
+
result?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Message {
|
|
11
|
+
role: "user" | "assistant";
|
|
12
|
+
content: string;
|
|
13
|
+
toolCalls?: ToolCall[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface MessageListProps {
|
|
17
|
+
messages: Message[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function MessageList({ messages }: MessageListProps): React.ReactElement {
|
|
21
|
+
if (messages.length === 0) {
|
|
22
|
+
return (
|
|
23
|
+
<Box marginY={1}>
|
|
24
|
+
<Text color="gray">Start a conversation by typing below...</Text>
|
|
25
|
+
</Box>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Box flexDirection="column">
|
|
31
|
+
{messages.map((message, index) => (
|
|
32
|
+
<MessageItem key={index} message={message} />
|
|
33
|
+
))}
|
|
34
|
+
</Box>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function MessageItem({ message }: { message: Message }): React.ReactElement {
|
|
39
|
+
const isUser = message.role === "user";
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Box flexDirection="column" marginY={1}>
|
|
43
|
+
{/* Role indicator */}
|
|
44
|
+
<Box>
|
|
45
|
+
<Text color={isUser ? "green" : "cyan"} bold>
|
|
46
|
+
{isUser ? "You" : "ACTIVO"}
|
|
47
|
+
</Text>
|
|
48
|
+
</Box>
|
|
49
|
+
|
|
50
|
+
{/* Tool calls */}
|
|
51
|
+
{message.toolCalls && message.toolCalls.length > 0 && (
|
|
52
|
+
<Box flexDirection="column" marginLeft={2} marginY={1}>
|
|
53
|
+
{message.toolCalls.map((tc, idx) => (
|
|
54
|
+
<Box key={idx}>
|
|
55
|
+
<Text color="gray">
|
|
56
|
+
{tc.status === "running" ? "🔄" : tc.status === "complete" ? "✓" : "✗"}{" "}
|
|
57
|
+
</Text>
|
|
58
|
+
<Text color={tc.status === "error" ? "red" : "yellow"}>{tc.tool}</Text>
|
|
59
|
+
{tc.status === "complete" && tc.result && (
|
|
60
|
+
<Text color="gray"> - {truncate(tc.result, 50)}</Text>
|
|
61
|
+
)}
|
|
62
|
+
</Box>
|
|
63
|
+
))}
|
|
64
|
+
</Box>
|
|
65
|
+
)}
|
|
66
|
+
|
|
67
|
+
{/* Content */}
|
|
68
|
+
{message.content && (
|
|
69
|
+
<Box marginLeft={2}>
|
|
70
|
+
<Text wrap="wrap">{message.content}</Text>
|
|
71
|
+
</Box>
|
|
72
|
+
)}
|
|
73
|
+
</Box>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function truncate(text: string, maxLength: number): string {
|
|
78
|
+
if (text.length <= maxLength) return text;
|
|
79
|
+
return text.slice(0, maxLength) + "...";
|
|
80
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
|
|
4
|
+
interface StatusBarProps {
|
|
5
|
+
model: string;
|
|
6
|
+
isProcessing: boolean;
|
|
7
|
+
messageCount: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function StatusBar({
|
|
11
|
+
model,
|
|
12
|
+
isProcessing,
|
|
13
|
+
messageCount,
|
|
14
|
+
}: StatusBarProps): React.ReactElement {
|
|
15
|
+
return (
|
|
16
|
+
<Box justifyContent="space-between" paddingX={1} marginTop={1}>
|
|
17
|
+
<Box>
|
|
18
|
+
<Text color="gray">Model: </Text>
|
|
19
|
+
<Text color="cyan">{model}</Text>
|
|
20
|
+
</Box>
|
|
21
|
+
|
|
22
|
+
<Box>
|
|
23
|
+
<Text color="gray">Messages: </Text>
|
|
24
|
+
<Text color="white">{messageCount}</Text>
|
|
25
|
+
</Box>
|
|
26
|
+
|
|
27
|
+
<Box>
|
|
28
|
+
{isProcessing ? (
|
|
29
|
+
<Text color="yellow">● Processing</Text>
|
|
30
|
+
) : (
|
|
31
|
+
<Text color="green">● Ready</Text>
|
|
32
|
+
)}
|
|
33
|
+
</Box>
|
|
34
|
+
</Box>
|
|
35
|
+
);
|
|
36
|
+
}
|