feat: AI-assisted compilation and code generation
- New src/ai_assist.nim module with DeepSeek/OpenAI/MiMo API support - AI explains compiler errors automatically when compilation fails - New CLI command: cljnim ai '<description>' for code generation - REPL :ai command for interactive AI assistance - API keys read from environment vars (DEEPSEEK_API_KEY, OPENAI_API_KEY, MIMO_API_KEY) - Tests for prompt building and response formatting - Updated README with AI integration docs
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
# AI Assistance for Clojure/Nim Compiler
|
||||
# Supports DeepSeek API and OpenAI-compatible APIs (Xiaomi MiMo, etc.)
|
||||
# API keys are read from environment variables — never hardcoded.
|
||||
|
||||
import std/[httpclient, json, os, strutils, uri]
|
||||
|
||||
type
|
||||
AiProvider* = enum
|
||||
aiDeepSeek
|
||||
aiOpenAiCompatible
|
||||
|
||||
AiConfig* = object
|
||||
provider*: AiProvider
|
||||
apiKey*: string
|
||||
baseUrl*: string
|
||||
model*: string
|
||||
timeoutMs*: int
|
||||
|
||||
AiResponse* = object
|
||||
ok*: bool
|
||||
suggestion*: string
|
||||
rawJson*: string
|
||||
|
||||
proc detectConfig*(): AiConfig =
|
||||
## Auto-detect AI configuration from environment variables
|
||||
result.timeoutMs = 15000
|
||||
|
||||
# DeepSeek
|
||||
let deepseekKey = getEnv("DEEPSEEK_API_KEY", "")
|
||||
if deepseekKey.len > 0:
|
||||
return AiConfig(
|
||||
provider: aiDeepSeek,
|
||||
apiKey: deepseekKey,
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
model: getEnv("DEEPSEEK_MODEL", "deepseek-chat"),
|
||||
timeoutMs: parseInt(getEnv("AI_TIMEOUT_MS", "15000"))
|
||||
)
|
||||
|
||||
# OpenAI-compatible (Xiaomi MiMo, OpenRouter, etc.)
|
||||
let openaiKey = getEnv("OPENAI_API_KEY", "")
|
||||
if openaiKey.len > 0:
|
||||
return AiConfig(
|
||||
provider: aiOpenAiCompatible,
|
||||
apiKey: openaiKey,
|
||||
baseUrl: getEnv("OPENAI_BASE_URL", "https://api.openai.com"),
|
||||
model: getEnv("OPENAI_MODEL", "gpt-4o-mini"),
|
||||
timeoutMs: parseInt(getEnv("AI_TIMEOUT_MS", "15000"))
|
||||
)
|
||||
|
||||
# Xiaomi MiMo (OpenAI-compatible)
|
||||
let mimoKey = getEnv("MIMO_API_KEY", "")
|
||||
if mimoKey.len > 0:
|
||||
return AiConfig(
|
||||
provider: aiOpenAiCompatible,
|
||||
apiKey: mimoKey,
|
||||
baseUrl: getEnv("MIMO_BASE_URL", "https://api.mi-mo.ai"),
|
||||
model: getEnv("MIMO_MODEL", "mimo-chat"),
|
||||
timeoutMs: parseInt(getEnv("AI_TIMEOUT_MS", "15000"))
|
||||
)
|
||||
|
||||
# No API key found
|
||||
return AiConfig(provider: aiDeepSeek, apiKey: "", baseUrl: "", model: "", timeoutMs: 0)
|
||||
|
||||
proc hasAiConfig*(): bool =
|
||||
detectConfig().apiKey.len > 0
|
||||
|
||||
proc buildErrorPrompt*(errorMsg, sourceCode, fileName: string): string =
|
||||
## Build a prompt for the AI to analyze a compiler error
|
||||
result = """You are an expert Clojure/Nim compiler assistant. The user got a compilation error.
|
||||
|
||||
**File:** """ & fileName & """
|
||||
|
||||
**Source code:**
|
||||
```clojure
|
||||
""" & sourceCode & """
|
||||
```
|
||||
|
||||
**Compiler error:**
|
||||
```
|
||||
""" & errorMsg & """
|
||||
```
|
||||
|
||||
Please explain the error in simple terms and suggest a fix. Keep your response under 200 words.
|
||||
If the error is in Clojure code, show the corrected Clojure snippet.
|
||||
Respond in the same language as the user's source code comments (Bulgarian or English).
|
||||
"""
|
||||
|
||||
proc buildGenerationPrompt*(description: string): string =
|
||||
## Build a prompt for AI code generation
|
||||
result = """You are an expert Clojure programmer. Generate a Clojure function based on this description:
|
||||
|
||||
""" & description & """
|
||||
|
||||
Requirements:
|
||||
- Use idiomatic Clojure
|
||||
- Include docstring
|
||||
- Use loop/recur instead of recursion if possible
|
||||
- Return ONLY the Clojure code, no explanations
|
||||
"""
|
||||
|
||||
proc callAiApi*(config: AiConfig, prompt: string): AiResponse =
|
||||
## Call the AI API and return the response
|
||||
if config.apiKey.len == 0:
|
||||
return AiResponse(ok: false, suggestion: "No AI API key configured. Set DEEPSEEK_API_KEY, OPENAI_API_KEY, or MIMO_API_KEY environment variable.")
|
||||
|
||||
let client = newHttpClient(timeout = config.timeoutMs)
|
||||
defer: client.close()
|
||||
|
||||
let url = config.baseUrl & "/v1/chat/completions"
|
||||
let body = %*{
|
||||
"model": config.model,
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 800
|
||||
}
|
||||
|
||||
client.headers["Authorization"] = "Bearer " & config.apiKey
|
||||
client.headers["Content-Type"] = "application/json"
|
||||
|
||||
try:
|
||||
let resp = client.post(url, body = $body)
|
||||
let respBody = resp.body
|
||||
result.rawJson = respBody
|
||||
|
||||
if resp.code.int != 200:
|
||||
return AiResponse(ok: false, suggestion: "AI API error (HTTP " & $resp.code.int & "): " & respBody)
|
||||
|
||||
let jsonResp = parseJson(respBody)
|
||||
if jsonResp.hasKey("choices") and jsonResp["choices"].len > 0:
|
||||
let content = jsonResp["choices"][0]["message"]["content"].getStr("")
|
||||
return AiResponse(ok: true, suggestion: content, rawJson: respBody)
|
||||
else:
|
||||
return AiResponse(ok: false, suggestion: "Unexpected AI API response format", rawJson: respBody)
|
||||
|
||||
except CatchableError as e:
|
||||
return AiResponse(ok: false, suggestion: "AI request failed: " & e.msg)
|
||||
|
||||
proc explainError*(errorMsg, sourceCode, fileName: string): AiResponse =
|
||||
## High-level helper: explain a compiler error using AI
|
||||
let config = detectConfig()
|
||||
let prompt = buildErrorPrompt(errorMsg, sourceCode, fileName)
|
||||
return callAiApi(config, prompt)
|
||||
|
||||
proc generateCode*(description: string): AiResponse =
|
||||
## High-level helper: generate Clojure code from description
|
||||
let config = detectConfig()
|
||||
let prompt = buildGenerationPrompt(description)
|
||||
return callAiApi(config, prompt)
|
||||
|
||||
proc formatSuggestion*(response: AiResponse): string =
|
||||
## Format AI response for terminal display
|
||||
if not response.ok:
|
||||
return "💡 AI: " & response.suggestion
|
||||
|
||||
var res = "💡 AI Suggestion:\n"
|
||||
for line in response.suggestion.splitLines():
|
||||
res.add(" " & line & "\n")
|
||||
return res
|
||||
+57
-9
@@ -1,6 +1,6 @@
|
||||
# Clojure/Nim — AI-First Clojure Compiler
|
||||
import os, osproc, strutils, times
|
||||
import reader, emitter, types, repl, macros, deps
|
||||
import reader, emitter, types, repl, macros, deps, ai_assist
|
||||
|
||||
proc getLibPath(): string =
|
||||
let appDir = getAppDir()
|
||||
@@ -44,7 +44,7 @@ proc extractRequires(forms: seq[CljVal]): seq[(string, string)] =
|
||||
alias = req.items[2].symName
|
||||
result.add((libName, alias))
|
||||
|
||||
proc compileFile*(inputPath: string, outputPath: string, extraPaths: seq[string] = @[]) =
|
||||
proc compileFileInternal(inputPath: string, outputPath: string, extraPaths: seq[string] = @[], libMode: bool = false) =
|
||||
let source = readFile(inputPath)
|
||||
let forms = reader.readAll(source)
|
||||
|
||||
@@ -97,11 +97,17 @@ proc compileFile*(inputPath: string, outputPath: string, extraPaths: seq[string]
|
||||
f.items[0].kind == ckSymbol and f.items[0].symName == "ns"):
|
||||
allForms.add(f)
|
||||
|
||||
let nimCode = emitter.emitProgram(allForms)
|
||||
let nimCode = if libMode: emitter.emitProgramLib(allForms) else: emitter.emitProgram(allForms)
|
||||
writeFile(outputPath, nimCode)
|
||||
echo "Generated: ", outputPath
|
||||
|
||||
proc nimCompile(nimPath: string, binPath: string, release: bool = false): int =
|
||||
proc compileFile*(inputPath: string, outputPath: string, extraPaths: seq[string] = @[]) =
|
||||
compileFileInternal(inputPath, outputPath, extraPaths, false)
|
||||
|
||||
proc compileFileLib*(inputPath: string, outputPath: string, extraPaths: seq[string] = @[]) =
|
||||
compileFileInternal(inputPath, outputPath, extraPaths, true)
|
||||
|
||||
proc nimCompile(nimPath: string, binPath: string, release: bool = false): tuple[exitCode: int, output: string] =
|
||||
let libPath = getLibPath()
|
||||
var cmd = "nim c"
|
||||
if release:
|
||||
@@ -109,7 +115,8 @@ proc nimCompile(nimPath: string, binPath: string, release: bool = false): int =
|
||||
cmd.add(" --path:" & quoteShell(libPath))
|
||||
cmd.add(" -o:" & quoteShell(binPath) & " " & quoteShell(nimPath))
|
||||
echo "Compiling: ", cmd
|
||||
execCmd(cmd)
|
||||
let (output, exitCode) = execCmdEx(cmd)
|
||||
return (exitCode, output)
|
||||
|
||||
proc makeTempDir(): string =
|
||||
let pid = getCurrentProcessId()
|
||||
@@ -143,9 +150,14 @@ proc runFile*(inputPath: string) =
|
||||
|
||||
if useCache:
|
||||
echo "Using cached: ", cacheNimPath
|
||||
let compileResult = nimCompile(cacheNimPath, cacheBinPath)
|
||||
let (compileResult, compileOut) = nimCompile(cacheNimPath, cacheBinPath)
|
||||
if compileResult != 0:
|
||||
stderr.writeLine("Compilation failed (cached)")
|
||||
stderr.writeLine(compileOut)
|
||||
if ai_assist.hasAiConfig():
|
||||
let source = readFile(inputPath)
|
||||
let aiRes = ai_assist.explainError(compileOut, source, inputPath)
|
||||
stderr.writeLine(ai_assist.formatSuggestion(aiRes))
|
||||
quit(1)
|
||||
try: removeDir(buildDir) except: discard
|
||||
let runResult = execCmd(cacheBinPath)
|
||||
@@ -153,9 +165,14 @@ proc runFile*(inputPath: string) =
|
||||
|
||||
compileFile(inputPath, nimPath, depPaths)
|
||||
|
||||
let compileResult = nimCompile(nimPath, binPath)
|
||||
let (compileResult, compileOut) = nimCompile(nimPath, binPath)
|
||||
if compileResult != 0:
|
||||
stderr.writeLine("Compilation failed")
|
||||
stderr.writeLine(compileOut)
|
||||
if ai_assist.hasAiConfig():
|
||||
let source = readFile(inputPath)
|
||||
let aiRes = ai_assist.explainError(compileOut, source, inputPath)
|
||||
stderr.writeLine(ai_assist.formatSuggestion(aiRes))
|
||||
try: removeDir(buildDir) except: discard
|
||||
quit(1)
|
||||
|
||||
@@ -188,6 +205,7 @@ proc main() =
|
||||
echo " cljnim repl [--json] Start REPL (human or AI mode)"
|
||||
echo " cljnim deps Resolve dependencies from deps.edn"
|
||||
echo " cljnim -e '<code>' Evaluate expression"
|
||||
echo " cljnim ai '<description>' Generate Clojure code with AI"
|
||||
echo ""
|
||||
echo "REPL Commands:"
|
||||
echo " :quit, :q Exit REPL"
|
||||
@@ -215,15 +233,19 @@ proc main() =
|
||||
let forms = reader.readAll(code)
|
||||
let nimCode = emitter.emitProgram(forms)
|
||||
writeFile(nimPath, nimCode)
|
||||
let compileResult = nimCompile(nimPath, binPath)
|
||||
let (compileResult, compileOut) = nimCompile(nimPath, binPath)
|
||||
if compileResult != 0:
|
||||
stderr.writeLine("Compilation failed")
|
||||
stderr.writeLine(compileOut)
|
||||
if ai_assist.hasAiConfig():
|
||||
let aiRes = ai_assist.explainError(compileOut, code, "-e expression")
|
||||
stderr.writeLine(ai_assist.formatSuggestion(aiRes))
|
||||
try: removeDir(buildDir) except: discard
|
||||
quit(1)
|
||||
let runResult = execCmd(binPath)
|
||||
try: removeDir(buildDir) except: discard
|
||||
quit(runResult)
|
||||
|
||||
|
||||
case cmd
|
||||
of "compile":
|
||||
if args.len < 2:
|
||||
@@ -232,6 +254,14 @@ proc main() =
|
||||
let inputPath = args[1]
|
||||
let outputPath = if args.len >= 3: args[2] else: inputPath.changeFileExt("nim")
|
||||
compileFile(inputPath, outputPath)
|
||||
|
||||
of "compile-lib":
|
||||
if args.len < 2:
|
||||
stderr.writeLine("Error: Missing input file")
|
||||
quit(1)
|
||||
let inputPath = args[1]
|
||||
let outputPath = if args.len >= 3: args[2] else: inputPath.changeFileExt("nim")
|
||||
compileFileLib(inputPath, outputPath)
|
||||
|
||||
of "run":
|
||||
if args.len < 2:
|
||||
@@ -298,6 +328,24 @@ proc main() =
|
||||
else:
|
||||
echo "No dependencies to resolve"
|
||||
|
||||
of "ai":
|
||||
if not ai_assist.hasAiConfig():
|
||||
stderr.writeLine("Error: No AI API key configured.")
|
||||
stderr.writeLine("Set DEEPSEEK_API_KEY, OPENAI_API_KEY, or MIMO_API_KEY environment variable.")
|
||||
quit(1)
|
||||
if args.len < 2:
|
||||
stderr.writeLine("Error: Missing description for AI generation")
|
||||
stderr.writeLine("Usage: cljnim ai 'function that reverses a list'")
|
||||
quit(1)
|
||||
let description = args[1]
|
||||
echo "🤖 Asking AI to generate code..."
|
||||
let aiRes = ai_assist.generateCode(description)
|
||||
if aiRes.ok:
|
||||
echo aiRes.suggestion
|
||||
else:
|
||||
stderr.writeLine(aiRes.suggestion)
|
||||
quit(1)
|
||||
|
||||
else:
|
||||
stderr.writeLine("Unknown command: " & cmd)
|
||||
quit(1)
|
||||
|
||||
+30
-11
@@ -1,6 +1,6 @@
|
||||
# AI-First REPL for Clojure/Nim
|
||||
import os, osproc, strutils, json, times, net
|
||||
import reader, emitter, types, macros, eval
|
||||
import reader, emitter, types, macros, eval, ai_assist
|
||||
|
||||
type
|
||||
ReplMode* = enum
|
||||
@@ -193,13 +193,18 @@ proc evalForm*(state: var ReplState, formStr: string): JsonNode =
|
||||
let elapsed = (epochTime() - startTime) * 1000
|
||||
|
||||
if not ok:
|
||||
var errObj = %*{
|
||||
"type": "compiler/error",
|
||||
"message": error,
|
||||
"form": formStr
|
||||
}
|
||||
if ai_assist.hasAiConfig():
|
||||
let aiRes = ai_assist.explainError(error, formStr, "REPL input")
|
||||
if aiRes.ok:
|
||||
errObj["ai_suggestion"] = %aiRes.suggestion
|
||||
return %*{
|
||||
"status": "error",
|
||||
"error": {
|
||||
"type": "compiler/error",
|
||||
"message": error,
|
||||
"form": formStr
|
||||
},
|
||||
"error": errObj,
|
||||
"meta": {
|
||||
"ns": state.ns,
|
||||
"ms": elapsed
|
||||
@@ -262,6 +267,7 @@ proc runHumanRepl*(state: var ReplState) =
|
||||
echo ":defs List defined vars"
|
||||
echo ":clear Clear all definitions"
|
||||
echo ":ns Show current namespace"
|
||||
echo ":ai <desc> Ask AI to generate Clojure code"
|
||||
of ":defs":
|
||||
for d in state.defs:
|
||||
if d.kind == ckList and d.items.len > 1 and d.items[1].kind == ckSymbol:
|
||||
@@ -272,11 +278,25 @@ proc runHumanRepl*(state: var ReplState) =
|
||||
of ":ns":
|
||||
echo state.ns
|
||||
else:
|
||||
let res = evalForm(state, cmd)
|
||||
if res["status"].getStr() == "ok":
|
||||
echo "=> " & res["result"]["printed"].getStr()
|
||||
if cmd.startsWith(":ai "):
|
||||
let description = cmd[4..^1].strip()
|
||||
if description.len == 0:
|
||||
echo "Usage: :ai <description>"
|
||||
elif not ai_assist.hasAiConfig():
|
||||
echo "No AI API key configured. Set DEEPSEEK_API_KEY, OPENAI_API_KEY, or MIMO_API_KEY."
|
||||
else:
|
||||
echo "🤖 Thinking..."
|
||||
let aiRes = ai_assist.generateCode(description)
|
||||
echo ai_assist.formatSuggestion(aiRes)
|
||||
else:
|
||||
echo "Error: " & res["error"]["message"].getStr()
|
||||
let res = evalForm(state, cmd)
|
||||
if res["status"].getStr() == "ok":
|
||||
echo "=> " & res["result"]["printed"].getStr()
|
||||
else:
|
||||
echo "Error: " & res["error"]["message"].getStr()
|
||||
if res["error"].hasKey("ai_suggestion"):
|
||||
echo ""
|
||||
echo ai_assist.formatSuggestion(AiResponse(ok: true, suggestion: res["error"]["ai_suggestion"].getStr("")))
|
||||
|
||||
proc runJsonRepl*(state: var ReplState) =
|
||||
var readyMsg = %*{ "status": "ready", "ns": state.ns, "mode": "json" }
|
||||
@@ -385,7 +405,6 @@ proc handleClient(state: var ReplState, client: Socket) =
|
||||
var readyMsg = $(%*{ "status": "ready", "ns": state.ns, "mode": "nrepl" }) & "\n"
|
||||
client.send(readyMsg)
|
||||
|
||||
var buf = ""
|
||||
while true:
|
||||
try:
|
||||
var line = ""
|
||||
|
||||
Reference in New Issue
Block a user