localcoder 0.1.1__tar.gz → 0.2.1__tar.gz
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.
- {localcoder-0.1.1 → localcoder-0.2.1}/PKG-INFO +1 -1
- {localcoder-0.1.1 → localcoder-0.2.1}/pyproject.toml +1 -1
- {localcoder-0.1.1 → localcoder-0.2.1}/src/localcoder/localcoder_agent.py +176 -0
- localcoder-0.2.1/src/localcoder/templates/ai-app/.env.local +21 -0
- localcoder-0.2.1/src/localcoder/templates/ai-app/next.config.ts +3 -0
- localcoder-0.2.1/src/localcoder/templates/ai-app/package.json +23 -0
- localcoder-0.2.1/src/localcoder/templates/ai-app/postcss.config.mjs +6 -0
- localcoder-0.2.1/src/localcoder/templates/ai-app/src/app/api/ai/route.ts +88 -0
- localcoder-0.2.1/src/localcoder/templates/ai-app/src/app/globals.css +36 -0
- localcoder-0.2.1/src/localcoder/templates/ai-app/src/app/layout.tsx +19 -0
- localcoder-0.2.1/src/localcoder/templates/ai-app/src/app/page.tsx +22 -0
- localcoder-0.2.1/src/localcoder/templates/ai-app/src/components/Chat.tsx +124 -0
- localcoder-0.2.1/src/localcoder/templates/ai-app/tsconfig.json +21 -0
- {localcoder-0.1.1 → localcoder-0.2.1}/tests/test_basic.py +44 -0
- {localcoder-0.1.1 → localcoder-0.2.1}/uv.lock +1 -1
- {localcoder-0.1.1 → localcoder-0.2.1}/.gitignore +0 -0
- {localcoder-0.1.1 → localcoder-0.2.1}/LICENSE +0 -0
- {localcoder-0.1.1 → localcoder-0.2.1}/README.md +0 -0
- {localcoder-0.1.1 → localcoder-0.2.1}/src/localcoder/__init__.py +0 -0
- {localcoder-0.1.1 → localcoder-0.2.1}/src/localcoder/__main__.py +0 -0
- {localcoder-0.1.1 → localcoder-0.2.1}/src/localcoder/agent.py +0 -0
- {localcoder-0.1.1 → localcoder-0.2.1}/src/localcoder/backends.py +0 -0
- {localcoder-0.1.1 → localcoder-0.2.1}/src/localcoder/bench.py +0 -0
- {localcoder-0.1.1 → localcoder-0.2.1}/src/localcoder/cli.py +0 -0
- {localcoder-0.1.1 → localcoder-0.2.1}/src/localcoder/localcoder_display.py +0 -0
- {localcoder-0.1.1 → localcoder-0.2.1}/src/localcoder/setup.py +0 -0
- {localcoder-0.1.1 → localcoder-0.2.1}/src/localcoder/tui.py +0 -0
- {localcoder-0.1.1 → localcoder-0.2.1}/src/localcoder/voice.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: localcoder
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Local AI coding agent — auto-installs, auto-serves, zero config. Works with Gemma 4, Qwen 3.5, and any model via llama.cpp or Ollama.
|
|
5
5
|
Project-URL: Homepage, https://github.com/AnassKartit/localcoder
|
|
6
6
|
Project-URL: Repository, https://github.com/AnassKartit/localcoder
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "localcoder"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Local AI coding agent — auto-installs, auto-serves, zero config. Works with Gemma 4, Qwen 3.5, and any model via llama.cpp or Ollama."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -2673,6 +2673,7 @@ def main(argv=None):
|
|
|
2673
2673
|
"/yolo": "Same as /bypass",
|
|
2674
2674
|
"/log": "View debug log",
|
|
2675
2675
|
"/think": "Toggle reasoning: none → low → medium → high",
|
|
2676
|
+
"/deploy": "Generate & deploy an AI-powered React app",
|
|
2676
2677
|
"/exit": "Exit",
|
|
2677
2678
|
}
|
|
2678
2679
|
|
|
@@ -2987,6 +2988,9 @@ def main(argv=None):
|
|
|
2987
2988
|
continue
|
|
2988
2989
|
if task in ("/exit", "/quit"):
|
|
2989
2990
|
break
|
|
2991
|
+
if task == "/deploy" or task.startswith("/deploy "):
|
|
2992
|
+
_handle_deploy(task, messages, perms, system, console)
|
|
2993
|
+
continue
|
|
2990
2994
|
|
|
2991
2995
|
console.print(Rule(style="dim"))
|
|
2992
2996
|
|
|
@@ -3030,6 +3034,178 @@ def main(argv=None):
|
|
|
3030
3034
|
_cleanup_on_exit()
|
|
3031
3035
|
|
|
3032
3036
|
|
|
3037
|
+
def _handle_deploy(task, messages, perms, system, console):
|
|
3038
|
+
"""Generate and deploy an AI-powered React app from template."""
|
|
3039
|
+
import shutil
|
|
3040
|
+
from rich.rule import Rule
|
|
3041
|
+
|
|
3042
|
+
parts = task.split(None, 1)
|
|
3043
|
+
description = parts[1] if len(parts) > 1 else None
|
|
3044
|
+
|
|
3045
|
+
console.print(f"\n [bold #e07a5f]🚀 Deploy — AI App Generator[/]\n")
|
|
3046
|
+
|
|
3047
|
+
# ── Templates: LLM only writes system prompt + page description ──
|
|
3048
|
+
TEMPLATES = {
|
|
3049
|
+
"1": ("chatbot", "AI Chatbot", "You are a helpful AI assistant. Be concise and friendly.", "Ask me anything..."),
|
|
3050
|
+
"2": ("ingredients", "Ingredients Analyzer", "You are a food ingredients expert. When given a list of ingredients or a food product name, analyze each ingredient: explain what it is, whether it's natural or artificial, any health concerns, allergens, and give an overall healthiness rating from 1-10. Be specific and scientific but easy to understand.", "Paste ingredients list or food product name..."),
|
|
3051
|
+
"3": ("writer", "AI Writer", "You are a professional writer. Rewrite, summarize, translate, or improve any text the user provides. Match the requested tone and style. Be creative but accurate.", "Paste text to rewrite, summarize, or translate..."),
|
|
3052
|
+
"4": ("code-review", "Code Reviewer", "You are a senior software engineer. Review the code provided: find bugs, security issues, performance problems, and suggest improvements. Be specific with line references. Rate code quality 1-10.", "Paste code to review..."),
|
|
3053
|
+
"5": ("csv-analyzer","CSV Data Analyzer", "You are a data analyst. When given CSV data, analyze it: identify patterns, outliers, trends, and provide summary statistics. Give actionable insights. Format numbers clearly.", "Paste CSV data or describe your dataset..."),
|
|
3054
|
+
"6": ("custom", "Custom App", None, None),
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
if not description:
|
|
3058
|
+
console.print(f" [bold]Pick a template:[/]\n")
|
|
3059
|
+
for k, (tid, title, _, _) in TEMPLATES.items():
|
|
3060
|
+
console.print(f" [bold cyan]{k}[/] {title}")
|
|
3061
|
+
console.print()
|
|
3062
|
+
|
|
3063
|
+
try:
|
|
3064
|
+
choice = input(" > ").strip()
|
|
3065
|
+
except (EOFError, KeyboardInterrupt):
|
|
3066
|
+
return
|
|
3067
|
+
|
|
3068
|
+
if choice in TEMPLATES:
|
|
3069
|
+
tid, title, sys_prompt, placeholder = TEMPLATES[choice]
|
|
3070
|
+
if tid == "custom":
|
|
3071
|
+
try:
|
|
3072
|
+
description = input(" App description: ").strip()
|
|
3073
|
+
sys_prompt = input(" System prompt for AI: ").strip()
|
|
3074
|
+
placeholder = input(" Input placeholder: ").strip() or "Type here..."
|
|
3075
|
+
title = description.split(".")[0][:40]
|
|
3076
|
+
except (EOFError, KeyboardInterrupt):
|
|
3077
|
+
return
|
|
3078
|
+
if not description or not sys_prompt:
|
|
3079
|
+
return
|
|
3080
|
+
else:
|
|
3081
|
+
description = title
|
|
3082
|
+
else:
|
|
3083
|
+
description = choice
|
|
3084
|
+
sys_prompt = f"You are an AI assistant for: {choice}. Help the user with their request."
|
|
3085
|
+
placeholder = "Type here..."
|
|
3086
|
+
title = choice[:40]
|
|
3087
|
+
|
|
3088
|
+
else:
|
|
3089
|
+
# /deploy "ingredients analyzer"
|
|
3090
|
+
title = description[:40]
|
|
3091
|
+
sys_prompt = f"You are an AI expert for: {description}. Help the user with detailed, accurate responses."
|
|
3092
|
+
placeholder = "Type here..."
|
|
3093
|
+
|
|
3094
|
+
# App name
|
|
3095
|
+
try:
|
|
3096
|
+
default_name = re.sub(r'[^a-z0-9-]', '-', title.lower().replace(" ", "-"))
|
|
3097
|
+
app_name = input(f" App name [{default_name}]: ").strip() or default_name
|
|
3098
|
+
except (EOFError, KeyboardInterrupt):
|
|
3099
|
+
return
|
|
3100
|
+
app_name = re.sub(r'[^a-z0-9-]', '-', app_name.lower())
|
|
3101
|
+
app_dir = os.path.join(CWD, app_name)
|
|
3102
|
+
|
|
3103
|
+
console.print(f"\n [bold]App:[/] {app_name}")
|
|
3104
|
+
console.print(f" [bold]Title:[/] {title}")
|
|
3105
|
+
console.print(f" [bold]API:[/] {API_BASE}")
|
|
3106
|
+
console.print(Rule(style="dim"))
|
|
3107
|
+
|
|
3108
|
+
# ── Copy template ──
|
|
3109
|
+
template_dir = os.path.join(os.path.dirname(__file__), "templates", "ai-app")
|
|
3110
|
+
if not os.path.isdir(template_dir):
|
|
3111
|
+
console.print(f" [red]Template not found: {template_dir}[/]")
|
|
3112
|
+
return
|
|
3113
|
+
|
|
3114
|
+
console.print(f" [dim]Scaffolding {app_name}...[/]")
|
|
3115
|
+
if os.path.exists(app_dir):
|
|
3116
|
+
try:
|
|
3117
|
+
ans = input(f" {app_name}/ exists. Overwrite? (y/n): ").strip().lower()
|
|
3118
|
+
except (EOFError, KeyboardInterrupt):
|
|
3119
|
+
return
|
|
3120
|
+
if ans not in ("y", "yes"):
|
|
3121
|
+
return
|
|
3122
|
+
shutil.rmtree(app_dir)
|
|
3123
|
+
|
|
3124
|
+
shutil.copytree(template_dir, app_dir)
|
|
3125
|
+
|
|
3126
|
+
# ── Fill in placeholders ──
|
|
3127
|
+
api_base = API_BASE.replace("/v1", "")
|
|
3128
|
+
replacements = {
|
|
3129
|
+
"{{APP_NAME}}": app_name,
|
|
3130
|
+
"{{APP_TITLE}}": title,
|
|
3131
|
+
"{{APP_DESCRIPTION}}": description,
|
|
3132
|
+
"{{SYSTEM_PROMPT}}": sys_prompt.replace("`", "\\`").replace("$", "\\$"),
|
|
3133
|
+
"{{PLACEHOLDER}}": placeholder,
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
for root, dirs, files in os.walk(app_dir):
|
|
3137
|
+
for fname in files:
|
|
3138
|
+
fpath = os.path.join(root, fname)
|
|
3139
|
+
if fname.endswith((".ts", ".tsx", ".json", ".css", ".mjs", ".local")):
|
|
3140
|
+
try:
|
|
3141
|
+
content = open(fpath).read()
|
|
3142
|
+
for k, v in replacements.items():
|
|
3143
|
+
content = content.replace(k, v)
|
|
3144
|
+
with open(fpath, "w") as f:
|
|
3145
|
+
f.write(content)
|
|
3146
|
+
except Exception:
|
|
3147
|
+
pass
|
|
3148
|
+
|
|
3149
|
+
# Fix .env.local with actual API base
|
|
3150
|
+
env_path = os.path.join(app_dir, ".env.local")
|
|
3151
|
+
env_content = open(env_path).read()
|
|
3152
|
+
env_content = env_content.replace("http://localhost:8089/v1", f"{api_base}/v1")
|
|
3153
|
+
with open(env_path, "w") as f:
|
|
3154
|
+
f.write(env_content)
|
|
3155
|
+
|
|
3156
|
+
file_count = sum(len(files) for _, _, files in os.walk(app_dir))
|
|
3157
|
+
console.print(f" [green]✓[/] Created {file_count} files in {app_name}/")
|
|
3158
|
+
|
|
3159
|
+
# ── npm install ──
|
|
3160
|
+
console.print(f" [dim]Installing dependencies...[/]")
|
|
3161
|
+
try:
|
|
3162
|
+
r = subprocess.run("npm install", shell=True, cwd=app_dir,
|
|
3163
|
+
capture_output=True, text=True, timeout=120)
|
|
3164
|
+
if r.returncode == 0:
|
|
3165
|
+
console.print(f" [green]✓[/] Dependencies installed")
|
|
3166
|
+
else:
|
|
3167
|
+
console.print(f" [yellow]npm install had warnings (may still work)[/]")
|
|
3168
|
+
except subprocess.TimeoutExpired:
|
|
3169
|
+
console.print(f" [yellow]npm install timed out — run manually[/]")
|
|
3170
|
+
except Exception as e:
|
|
3171
|
+
console.print(f" [red]npm install failed: {e}[/]")
|
|
3172
|
+
|
|
3173
|
+
# ── Summary ──
|
|
3174
|
+
console.print(f"\n [green bold]✓ {title} is ready![/]\n")
|
|
3175
|
+
console.print(f" [bold]Files:[/]")
|
|
3176
|
+
console.print(f" {app_name}/src/app/page.tsx [dim]← UI[/]")
|
|
3177
|
+
console.print(f" {app_name}/src/app/api/ai/route.ts [dim]← AI backend[/]")
|
|
3178
|
+
console.print(f" {app_name}/src/components/Chat.tsx [dim]← chat component[/]")
|
|
3179
|
+
console.print(f" {app_name}/.env.local [dim]← swap AI provider[/]")
|
|
3180
|
+
console.print(f"\n [bold]Run:[/] cd {app_name} && npm run dev")
|
|
3181
|
+
console.print(f" [bold]Open:[/] http://localhost:3000")
|
|
3182
|
+
console.print(f"\n [bold]Deploy anywhere:[/]")
|
|
3183
|
+
console.print(f" [dim]vercel[/] cd {app_name} && vercel")
|
|
3184
|
+
console.print(f" [dim]docker[/] docker build -t {app_name} {app_name}/")
|
|
3185
|
+
console.print(f" [dim]coolify[/] git push → auto-deploy")
|
|
3186
|
+
console.print(f"\n [bold]Switch AI backend:[/] edit {app_name}/.env.local")
|
|
3187
|
+
console.print(f" [dim]Local:[/] LLM_API_BASE=http://localhost:8089/v1")
|
|
3188
|
+
console.print(f" [dim]RunPod:[/] LLM_API_BASE=https://api.runpod.ai/v2/ID/openai/v1")
|
|
3189
|
+
console.print(f" [dim]OpenAI:[/] LLM_API_BASE=https://api.openai.com/v1")
|
|
3190
|
+
|
|
3191
|
+
# ── Start dev server? ──
|
|
3192
|
+
console.print()
|
|
3193
|
+
try:
|
|
3194
|
+
ans = input(" Start dev server? (y/n): ").strip().lower()
|
|
3195
|
+
except (EOFError, KeyboardInterrupt):
|
|
3196
|
+
ans = "n"
|
|
3197
|
+
|
|
3198
|
+
if ans in ("y", "yes"):
|
|
3199
|
+
console.print(f"\n [green]Starting on http://localhost:3000...[/]")
|
|
3200
|
+
console.print(f" [dim]Ctrl+C to stop[/]\n")
|
|
3201
|
+
try:
|
|
3202
|
+
subprocess.run("npm run dev", shell=True, cwd=app_dir)
|
|
3203
|
+
except KeyboardInterrupt:
|
|
3204
|
+
console.print(f"\n [dim]Dev server stopped[/]")
|
|
3205
|
+
else:
|
|
3206
|
+
console.print(f"\n [yellow]App directory not found. Check output above for errors.[/]")
|
|
3207
|
+
|
|
3208
|
+
|
|
3033
3209
|
def _cleanup_on_exit():
|
|
3034
3210
|
"""Ask user if they want to free GPU memory on exit."""
|
|
3035
3211
|
console.print()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# ── AI Backend — change ONE line to switch providers ──
|
|
2
|
+
|
|
3
|
+
# Local (default — llama-server or Ollama)
|
|
4
|
+
LLM_API_BASE=http://localhost:8089/v1
|
|
5
|
+
LLM_API_KEY=no-key-required
|
|
6
|
+
LLM_MODEL=local
|
|
7
|
+
|
|
8
|
+
# RunPod Serverless:
|
|
9
|
+
# LLM_API_BASE=https://api.runpod.ai/v2/YOUR_ENDPOINT_ID/openai/v1
|
|
10
|
+
# LLM_API_KEY=your-runpod-key
|
|
11
|
+
# LLM_MODEL=local
|
|
12
|
+
|
|
13
|
+
# OpenAI:
|
|
14
|
+
# LLM_API_BASE=https://api.openai.com/v1
|
|
15
|
+
# LLM_API_KEY=sk-...
|
|
16
|
+
# LLM_MODEL=gpt-4o
|
|
17
|
+
|
|
18
|
+
# Google Gemini:
|
|
19
|
+
# LLM_API_BASE=https://generativelanguage.googleapis.com/v1beta/openai
|
|
20
|
+
# LLM_API_KEY=your-google-key
|
|
21
|
+
# LLM_MODEL=gemini-2.5-flash
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{APP_NAME}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev --turbopack",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"next": "^15.3",
|
|
12
|
+
"react": "^19",
|
|
13
|
+
"react-dom": "^19"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@tailwindcss/postcss": "^4",
|
|
17
|
+
"tailwindcss": "^4",
|
|
18
|
+
"typescript": "^5",
|
|
19
|
+
"@types/react": "^19",
|
|
20
|
+
"@types/react-dom": "^19",
|
|
21
|
+
"@types/node": "^22"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
// ── Swappable AI Backend ──
|
|
4
|
+
// Change ONE env var to switch between:
|
|
5
|
+
// Local: LLM_API_BASE=http://localhost:8089/v1 (llama-server / Ollama)
|
|
6
|
+
// RunPod: LLM_API_BASE=https://api.runpod.ai/v2/YOUR_ID/openai/v1
|
|
7
|
+
// OpenAI: LLM_API_BASE=https://api.openai.com/v1
|
|
8
|
+
// Gemini: LLM_API_BASE=https://generativelanguage.googleapis.com/v1beta/openai
|
|
9
|
+
// Groq: LLM_API_BASE=https://api.groq.com/openai/v1
|
|
10
|
+
|
|
11
|
+
const API_BASE = process.env.LLM_API_BASE || "http://localhost:8089/v1";
|
|
12
|
+
const API_KEY = process.env.LLM_API_KEY || "no-key-required";
|
|
13
|
+
const MODEL = process.env.LLM_MODEL || "local";
|
|
14
|
+
|
|
15
|
+
// ── System prompt — THIS IS WHAT THE LLM CUSTOMIZES ──
|
|
16
|
+
const SYSTEM_PROMPT = `{{SYSTEM_PROMPT}}`;
|
|
17
|
+
|
|
18
|
+
export async function POST(req: NextRequest) {
|
|
19
|
+
const { message, history = [] } = await req.json();
|
|
20
|
+
|
|
21
|
+
const messages = [
|
|
22
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
23
|
+
...history.slice(-10),
|
|
24
|
+
{ role: "user", content: message },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch(`${API_BASE}/chat/completions`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: {
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
model: MODEL,
|
|
36
|
+
messages,
|
|
37
|
+
max_tokens: 2048,
|
|
38
|
+
temperature: 0.7,
|
|
39
|
+
stream: true,
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
const err = await response.text();
|
|
45
|
+
return NextResponse.json({ error: err }, { status: response.status });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Stream the response
|
|
49
|
+
const encoder = new TextEncoder();
|
|
50
|
+
const stream = new ReadableStream({
|
|
51
|
+
async start(controller) {
|
|
52
|
+
const reader = response.body!.getReader();
|
|
53
|
+
const decoder = new TextDecoder();
|
|
54
|
+
let buffer = "";
|
|
55
|
+
|
|
56
|
+
while (true) {
|
|
57
|
+
const { done, value } = await reader.read();
|
|
58
|
+
if (done) break;
|
|
59
|
+
|
|
60
|
+
buffer += decoder.decode(value, { stream: true });
|
|
61
|
+
const lines = buffer.split("\n");
|
|
62
|
+
buffer = lines.pop() || "";
|
|
63
|
+
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
if (!line.startsWith("data: ")) continue;
|
|
66
|
+
const data = line.slice(6).trim();
|
|
67
|
+
if (data === "[DONE]") continue;
|
|
68
|
+
try {
|
|
69
|
+
const json = JSON.parse(data);
|
|
70
|
+
const content = json.choices?.[0]?.delta?.content;
|
|
71
|
+
if (content) {
|
|
72
|
+
controller.enqueue(encoder.encode(content));
|
|
73
|
+
}
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
controller.close();
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return new Response(stream, {
|
|
82
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
83
|
+
});
|
|
84
|
+
} catch (error: unknown) {
|
|
85
|
+
const msg = error instanceof Error ? error.message : "AI request failed";
|
|
86
|
+
return NextResponse.json({ error: msg }, { status: 500 });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--bg: #0a0a0f;
|
|
5
|
+
--surface: #12121a;
|
|
6
|
+
--border: #1e1e2e;
|
|
7
|
+
--text: #e4e4e7;
|
|
8
|
+
--muted: #71717a;
|
|
9
|
+
--accent: #e07a5f;
|
|
10
|
+
--accent2: #81b29a;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
body {
|
|
14
|
+
background: var(--bg);
|
|
15
|
+
color: var(--text);
|
|
16
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.glass {
|
|
20
|
+
background: rgba(18, 18, 26, 0.8);
|
|
21
|
+
backdrop-filter: blur(12px);
|
|
22
|
+
border: 1px solid var(--border);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.glow {
|
|
26
|
+
box-shadow: 0 0 20px rgba(224, 122, 95, 0.1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@keyframes fadeIn {
|
|
30
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
31
|
+
to { opacity: 1; transform: translateY(0); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.animate-in {
|
|
35
|
+
animation: fadeIn 0.3s ease-out;
|
|
36
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import "./globals.css";
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: "{{APP_TITLE}}",
|
|
6
|
+
description: "{{APP_DESCRIPTION}}",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
10
|
+
return (
|
|
11
|
+
<html lang="en">
|
|
12
|
+
<body className="min-h-screen antialiased">
|
|
13
|
+
<div className="mx-auto max-w-3xl px-4 py-8">
|
|
14
|
+
{children}
|
|
15
|
+
</div>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import Chat from "@/components/Chat";
|
|
2
|
+
|
|
3
|
+
export default function Home() {
|
|
4
|
+
return (
|
|
5
|
+
<main>
|
|
6
|
+
<div className="mb-8">
|
|
7
|
+
<h1 className="text-3xl font-bold bg-gradient-to-r from-[var(--accent)] to-[var(--accent2)] bg-clip-text text-transparent">
|
|
8
|
+
{{APP_TITLE}}
|
|
9
|
+
</h1>
|
|
10
|
+
<p className="text-[var(--muted)] mt-2">{{APP_DESCRIPTION}}</p>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div className="glass rounded-2xl p-6 glow">
|
|
14
|
+
<Chat placeholder="{{PLACEHOLDER}}" />
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<p className="text-center text-xs text-[var(--muted)] mt-6">
|
|
18
|
+
Powered by local AI
|
|
19
|
+
</p>
|
|
20
|
+
</main>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useRef, useEffect, FormEvent } from "react";
|
|
3
|
+
|
|
4
|
+
type Message = { role: "user" | "assistant"; content: string };
|
|
5
|
+
|
|
6
|
+
export default function Chat({
|
|
7
|
+
placeholder = "Type a message...",
|
|
8
|
+
initialMessage,
|
|
9
|
+
}: {
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
initialMessage?: string;
|
|
12
|
+
}) {
|
|
13
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
14
|
+
const [input, setInput] = useState(initialMessage || "");
|
|
15
|
+
const [loading, setLoading] = useState(false);
|
|
16
|
+
const endRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
20
|
+
}, [messages]);
|
|
21
|
+
|
|
22
|
+
async function send(e: FormEvent) {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
if (!input.trim() || loading) return;
|
|
25
|
+
|
|
26
|
+
const userMsg: Message = { role: "user", content: input.trim() };
|
|
27
|
+
setMessages((prev) => [...prev, userMsg]);
|
|
28
|
+
setInput("");
|
|
29
|
+
setLoading(true);
|
|
30
|
+
|
|
31
|
+
const assistantMsg: Message = { role: "assistant", content: "" };
|
|
32
|
+
setMessages((prev) => [...prev, assistantMsg]);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch("/api/ai", {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
message: userMsg.content,
|
|
40
|
+
history: messages,
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const err = await res.json();
|
|
46
|
+
throw new Error(err.error || "Request failed");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const reader = res.body!.getReader();
|
|
50
|
+
const decoder = new TextDecoder();
|
|
51
|
+
let full = "";
|
|
52
|
+
|
|
53
|
+
while (true) {
|
|
54
|
+
const { done, value } = await reader.read();
|
|
55
|
+
if (done) break;
|
|
56
|
+
full += decoder.decode(value, { stream: true });
|
|
57
|
+
setMessages((prev) => {
|
|
58
|
+
const updated = [...prev];
|
|
59
|
+
updated[updated.length - 1] = { role: "assistant", content: full };
|
|
60
|
+
return updated;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
} catch (err: unknown) {
|
|
64
|
+
const msg = err instanceof Error ? err.message : "Something went wrong";
|
|
65
|
+
setMessages((prev) => {
|
|
66
|
+
const updated = [...prev];
|
|
67
|
+
updated[updated.length - 1] = {
|
|
68
|
+
role: "assistant",
|
|
69
|
+
content: `Error: ${msg}`,
|
|
70
|
+
};
|
|
71
|
+
return updated;
|
|
72
|
+
});
|
|
73
|
+
} finally {
|
|
74
|
+
setLoading(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="flex flex-col gap-4">
|
|
80
|
+
{/* Messages */}
|
|
81
|
+
<div className="space-y-3 min-h-[200px] max-h-[60vh] overflow-y-auto">
|
|
82
|
+
{messages.map((m, i) => (
|
|
83
|
+
<div
|
|
84
|
+
key={i}
|
|
85
|
+
className={`animate-in rounded-xl px-4 py-3 ${
|
|
86
|
+
m.role === "user"
|
|
87
|
+
? "glass ml-8"
|
|
88
|
+
: "bg-[var(--surface)] mr-8 border border-[var(--border)]"
|
|
89
|
+
}`}
|
|
90
|
+
>
|
|
91
|
+
<p className="text-xs font-medium mb-1 text-[var(--muted)]">
|
|
92
|
+
{m.role === "user" ? "You" : "AI"}
|
|
93
|
+
</p>
|
|
94
|
+
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
|
95
|
+
{m.content}
|
|
96
|
+
{loading && i === messages.length - 1 && m.role === "assistant" && (
|
|
97
|
+
<span className="inline-block w-2 h-4 bg-[var(--accent)] ml-1 animate-pulse" />
|
|
98
|
+
)}
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
))}
|
|
102
|
+
<div ref={endRef} />
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{/* Input */}
|
|
106
|
+
<form onSubmit={send} className="flex gap-2">
|
|
107
|
+
<input
|
|
108
|
+
value={input}
|
|
109
|
+
onChange={(e) => setInput(e.target.value)}
|
|
110
|
+
placeholder={placeholder}
|
|
111
|
+
disabled={loading}
|
|
112
|
+
className="flex-1 glass rounded-xl px-4 py-3 text-sm outline-none focus:ring-1 focus:ring-[var(--accent)] placeholder:text-[var(--muted)] disabled:opacity-50"
|
|
113
|
+
/>
|
|
114
|
+
<button
|
|
115
|
+
type="submit"
|
|
116
|
+
disabled={loading || !input.trim()}
|
|
117
|
+
className="px-5 py-3 rounded-xl bg-[var(--accent)] text-white font-medium text-sm hover:brightness-110 transition disabled:opacity-40"
|
|
118
|
+
>
|
|
119
|
+
{loading ? "..." : "Send"}
|
|
120
|
+
</button>
|
|
121
|
+
</form>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2017",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"paths": { "@/*": ["./src/*"] },
|
|
17
|
+
"plugins": [{ "name": "next" }]
|
|
18
|
+
},
|
|
19
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
|
20
|
+
"exclude": ["node_modules"]
|
|
21
|
+
}
|
|
@@ -206,5 +206,49 @@ class TestSandbox(unittest.TestCase):
|
|
|
206
206
|
self.assertFalse(self._is_path_blocked("/tmp/test.py"))
|
|
207
207
|
|
|
208
208
|
|
|
209
|
+
class TestDeploy(unittest.TestCase):
|
|
210
|
+
"""Deploy command tests."""
|
|
211
|
+
|
|
212
|
+
def test_deploy_function_exists(self):
|
|
213
|
+
from localcoder.localcoder_agent import _handle_deploy
|
|
214
|
+
self.assertTrue(callable(_handle_deploy))
|
|
215
|
+
|
|
216
|
+
def test_deploy_in_slash_commands(self):
|
|
217
|
+
"""Deploy should be registered as a slash command."""
|
|
218
|
+
import inspect
|
|
219
|
+
from localcoder.localcoder_agent import main
|
|
220
|
+
source = inspect.getsource(main)
|
|
221
|
+
self.assertIn("/deploy", source)
|
|
222
|
+
|
|
223
|
+
def test_deploy_templates(self):
|
|
224
|
+
"""Deploy should support template types."""
|
|
225
|
+
import inspect
|
|
226
|
+
from localcoder.localcoder_agent import _handle_deploy
|
|
227
|
+
source = inspect.getsource(_handle_deploy)
|
|
228
|
+
self.assertIn("chatbot", source)
|
|
229
|
+
self.assertIn("ingredients", source)
|
|
230
|
+
self.assertIn("code-review", source)
|
|
231
|
+
self.assertIn("custom", source)
|
|
232
|
+
|
|
233
|
+
def test_template_files_exist(self):
|
|
234
|
+
"""Template directory should contain all required files."""
|
|
235
|
+
template_dir = os.path.join(os.path.dirname(__file__), "..", "src", "localcoder", "templates", "ai-app")
|
|
236
|
+
self.assertTrue(os.path.isdir(template_dir), f"Template dir missing: {template_dir}")
|
|
237
|
+
required = ["package.json", "tsconfig.json", "next.config.ts", "postcss.config.mjs",
|
|
238
|
+
".env.local", "src/app/layout.tsx", "src/app/page.tsx",
|
|
239
|
+
"src/app/globals.css", "src/app/api/ai/route.ts", "src/components/Chat.tsx"]
|
|
240
|
+
for f in required:
|
|
241
|
+
self.assertTrue(os.path.exists(os.path.join(template_dir, f)), f"Missing: {f}")
|
|
242
|
+
|
|
243
|
+
def test_template_has_placeholders(self):
|
|
244
|
+
"""Template files should contain replaceable placeholders."""
|
|
245
|
+
template_dir = os.path.join(os.path.dirname(__file__), "..", "src", "localcoder", "templates", "ai-app")
|
|
246
|
+
page = open(os.path.join(template_dir, "src/app/page.tsx")).read()
|
|
247
|
+
self.assertIn("{{APP_TITLE}}", page)
|
|
248
|
+
route = open(os.path.join(template_dir, "src/app/api/ai/route.ts")).read()
|
|
249
|
+
self.assertIn("{{SYSTEM_PROMPT}}", route)
|
|
250
|
+
self.assertIn("LLM_API_BASE", route)
|
|
251
|
+
|
|
252
|
+
|
|
209
253
|
if __name__ == "__main__":
|
|
210
254
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|