localcoder 0.2.0__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.2.0 → localcoder-0.2.1}/PKG-INFO +1 -1
- {localcoder-0.2.0 → localcoder-0.2.1}/pyproject.toml +1 -1
- {localcoder-0.2.0 → localcoder-0.2.1}/src/localcoder/localcoder_agent.py +126 -127
- 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.2.0 → localcoder-0.2.1}/tests/test_basic.py +21 -2
- {localcoder-0.2.0 → localcoder-0.2.1}/uv.lock +1 -1
- {localcoder-0.2.0 → localcoder-0.2.1}/.gitignore +0 -0
- {localcoder-0.2.0 → localcoder-0.2.1}/LICENSE +0 -0
- {localcoder-0.2.0 → localcoder-0.2.1}/README.md +0 -0
- {localcoder-0.2.0 → localcoder-0.2.1}/src/localcoder/__init__.py +0 -0
- {localcoder-0.2.0 → localcoder-0.2.1}/src/localcoder/__main__.py +0 -0
- {localcoder-0.2.0 → localcoder-0.2.1}/src/localcoder/agent.py +0 -0
- {localcoder-0.2.0 → localcoder-0.2.1}/src/localcoder/backends.py +0 -0
- {localcoder-0.2.0 → localcoder-0.2.1}/src/localcoder/bench.py +0 -0
- {localcoder-0.2.0 → localcoder-0.2.1}/src/localcoder/cli.py +0 -0
- {localcoder-0.2.0 → localcoder-0.2.1}/src/localcoder/localcoder_display.py +0 -0
- {localcoder-0.2.0 → localcoder-0.2.1}/src/localcoder/setup.py +0 -0
- {localcoder-0.2.0 → localcoder-0.2.1}/src/localcoder/tui.py +0 -0
- {localcoder-0.2.0 → 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.2.
|
|
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.2.
|
|
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"
|
|
@@ -3035,30 +3035,29 @@ def main(argv=None):
|
|
|
3035
3035
|
|
|
3036
3036
|
|
|
3037
3037
|
def _handle_deploy(task, messages, perms, system, console):
|
|
3038
|
-
"""Generate and deploy an AI-powered React app."""
|
|
3039
|
-
|
|
3038
|
+
"""Generate and deploy an AI-powered React app from template."""
|
|
3039
|
+
import shutil
|
|
3040
3040
|
from rich.rule import Rule
|
|
3041
3041
|
|
|
3042
|
-
# Parse: /deploy or /deploy "description"
|
|
3043
3042
|
parts = task.split(None, 1)
|
|
3044
3043
|
description = parts[1] if len(parts) > 1 else None
|
|
3045
3044
|
|
|
3046
3045
|
console.print(f"\n [bold #e07a5f]🚀 Deploy — AI App Generator[/]\n")
|
|
3047
3046
|
|
|
3048
|
-
#
|
|
3047
|
+
# ── Templates: LLM only writes system prompt + page description ──
|
|
3049
3048
|
TEMPLATES = {
|
|
3050
|
-
"1": ("chatbot",
|
|
3051
|
-
"2": ("
|
|
3052
|
-
"3": ("writer",
|
|
3053
|
-
"4": ("
|
|
3054
|
-
"5": ("
|
|
3055
|
-
"6": ("custom",
|
|
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),
|
|
3056
3055
|
}
|
|
3057
3056
|
|
|
3058
3057
|
if not description:
|
|
3059
3058
|
console.print(f" [bold]Pick a template:[/]\n")
|
|
3060
|
-
for k, (tid,
|
|
3061
|
-
console.print(f" [bold cyan]{k}[/] {
|
|
3059
|
+
for k, (tid, title, _, _) in TEMPLATES.items():
|
|
3060
|
+
console.print(f" [bold cyan]{k}[/] {title}")
|
|
3062
3061
|
console.print()
|
|
3063
3062
|
|
|
3064
3063
|
try:
|
|
@@ -3067,142 +3066,142 @@ def _handle_deploy(task, messages, perms, system, console):
|
|
|
3067
3066
|
return
|
|
3068
3067
|
|
|
3069
3068
|
if choice in TEMPLATES:
|
|
3070
|
-
tid,
|
|
3069
|
+
tid, title, sys_prompt, placeholder = TEMPLATES[choice]
|
|
3071
3070
|
if tid == "custom":
|
|
3072
3071
|
try:
|
|
3073
|
-
description = input("
|
|
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]
|
|
3074
3076
|
except (EOFError, KeyboardInterrupt):
|
|
3075
3077
|
return
|
|
3076
|
-
if not description:
|
|
3078
|
+
if not description or not sys_prompt:
|
|
3077
3079
|
return
|
|
3078
3080
|
else:
|
|
3079
|
-
description =
|
|
3081
|
+
description = title
|
|
3080
3082
|
else:
|
|
3081
|
-
description = choice
|
|
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]
|
|
3082
3087
|
|
|
3083
|
-
|
|
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
|
|
3084
3095
|
try:
|
|
3085
|
-
|
|
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
|
|
3086
3098
|
except (EOFError, KeyboardInterrupt):
|
|
3087
3099
|
return
|
|
3088
|
-
if not app_name:
|
|
3089
|
-
app_name = description.split()[0].lower().replace(" ", "-") + "-app"
|
|
3090
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"))
|
|
3091
3107
|
|
|
3092
|
-
#
|
|
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 ──
|
|
3093
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
|
+
}
|
|
3094
3135
|
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
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
|
|
3099
3148
|
|
|
3100
|
-
#
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
- AI-powered backend using OpenAI-compatible API
|
|
3107
|
-
- All code must be COMPLETE — no placeholders, no TODOs, no "add your code here"
|
|
3108
|
-
- App must work immediately after `npm install && npm run dev`
|
|
3109
|
-
|
|
3110
|
-
AI BACKEND PATTERN — use this exact pattern for all AI calls:
|
|
3111
|
-
```typescript
|
|
3112
|
-
// src/app/api/chat/route.ts (or similar)
|
|
3113
|
-
const response = await fetch(process.env.LLM_API_BASE + '/chat/completions', {{
|
|
3114
|
-
method: 'POST',
|
|
3115
|
-
headers: {{ 'Content-Type': 'application/json' }},
|
|
3116
|
-
body: JSON.stringify({{
|
|
3117
|
-
model: process.env.LLM_MODEL || 'local',
|
|
3118
|
-
messages: [...],
|
|
3119
|
-
stream: true,
|
|
3120
|
-
}}),
|
|
3121
|
-
}});
|
|
3122
|
-
```
|
|
3123
|
-
|
|
3124
|
-
ENV VARS (create .env.local):
|
|
3125
|
-
```
|
|
3126
|
-
LLM_API_BASE={api_base}/v1
|
|
3127
|
-
LLM_MODEL={MODEL}
|
|
3128
|
-
```
|
|
3129
|
-
|
|
3130
|
-
This makes the app work with:
|
|
3131
|
-
- Local: llama-server on localhost:8089 (default)
|
|
3132
|
-
- Cloud: RunPod Serverless, OpenAI, Groq — just change LLM_API_BASE
|
|
3133
|
-
|
|
3134
|
-
DIRECTORY STRUCTURE — create ALL files:
|
|
3135
|
-
{app_name}/
|
|
3136
|
-
package.json
|
|
3137
|
-
tsconfig.json
|
|
3138
|
-
next.config.ts
|
|
3139
|
-
tailwind.config.ts
|
|
3140
|
-
postcss.config.mjs
|
|
3141
|
-
.env.local
|
|
3142
|
-
.env.example
|
|
3143
|
-
src/app/layout.tsx
|
|
3144
|
-
src/app/page.tsx
|
|
3145
|
-
src/app/globals.css
|
|
3146
|
-
src/app/api/chat/route.ts (or appropriate API route)
|
|
3147
|
-
src/components/ (React components)
|
|
3148
|
-
public/ (static assets if needed)
|
|
3149
|
-
|
|
3150
|
-
IMPORTANT:
|
|
3151
|
-
1. Use write_file for EVERY file. Create the full directory.
|
|
3152
|
-
2. After writing all files, run: cd {app_name} && npm install
|
|
3153
|
-
3. Then show the user how to start: npm run dev
|
|
3154
|
-
4. Make the UI genuinely beautiful — not generic. Use gradients, shadows, animations.
|
|
3155
|
-
5. The app should feel like a polished product, not a tutorial demo."""
|
|
3156
|
-
|
|
3157
|
-
deploy_prompt = f"""Create a complete AI-powered React app:
|
|
3158
|
-
|
|
3159
|
-
**App name:** {app_name}
|
|
3160
|
-
**Description:** {description}
|
|
3161
|
-
|
|
3162
|
-
Generate ALL files now. Make it production-quality with a beautiful UI.
|
|
3163
|
-
After creating all files, run npm install to verify it works."""
|
|
3164
|
-
|
|
3165
|
-
# Fresh conversation for deploy
|
|
3166
|
-
deploy_messages = [
|
|
3167
|
-
{"role": "system", "content": deploy_system},
|
|
3168
|
-
{"role": "user", "content": deploy_prompt},
|
|
3169
|
-
]
|
|
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)
|
|
3170
3155
|
|
|
3171
|
-
|
|
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}/")
|
|
3172
3158
|
|
|
3159
|
+
# ── npm install ──
|
|
3160
|
+
console.print(f" [dim]Installing dependencies...[/]")
|
|
3173
3161
|
try:
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
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"
|
|
3178
3197
|
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
console.print(f"\n [green]✓[/] App created: [bold]{app_dir}[/]")
|
|
3183
|
-
console.print(f"\n [bold]Run locally:[/]")
|
|
3184
|
-
console.print(f" [cyan]cd {app_name} && npm run dev[/]")
|
|
3185
|
-
console.print(f"\n [bold]Deploy options:[/]")
|
|
3186
|
-
console.print(f" [dim]Vercel:[/] cd {app_name} && vercel")
|
|
3187
|
-
console.print(f" [dim]Coolify:[/] git push (auto-deploy)")
|
|
3188
|
-
console.print(f" [dim]Docker:[/] docker compose up")
|
|
3189
|
-
|
|
3190
|
-
# Offer to start dev server
|
|
3191
|
-
console.print()
|
|
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")
|
|
3192
3201
|
try:
|
|
3193
|
-
|
|
3194
|
-
except
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
if ans in ("y", "yes"):
|
|
3198
|
-
console.print(f"\n [green]Starting {app_name} on http://localhost:3000...[/]\n")
|
|
3199
|
-
try:
|
|
3200
|
-
subprocess.run(
|
|
3201
|
-
"npm run dev",
|
|
3202
|
-
shell=True, cwd=app_dir,
|
|
3203
|
-
)
|
|
3204
|
-
except KeyboardInterrupt:
|
|
3205
|
-
console.print(f"\n [dim]Dev server stopped[/]")
|
|
3202
|
+
subprocess.run("npm run dev", shell=True, cwd=app_dir)
|
|
3203
|
+
except KeyboardInterrupt:
|
|
3204
|
+
console.print(f"\n [dim]Dev server stopped[/]")
|
|
3206
3205
|
else:
|
|
3207
3206
|
console.print(f"\n [yellow]App directory not found. Check output above for errors.[/]")
|
|
3208
3207
|
|
|
@@ -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
|
+
}
|
|
@@ -226,10 +226,29 @@ class TestDeploy(unittest.TestCase):
|
|
|
226
226
|
from localcoder.localcoder_agent import _handle_deploy
|
|
227
227
|
source = inspect.getsource(_handle_deploy)
|
|
228
228
|
self.assertIn("chatbot", source)
|
|
229
|
-
self.assertIn("
|
|
230
|
-
self.assertIn("
|
|
229
|
+
self.assertIn("ingredients", source)
|
|
230
|
+
self.assertIn("code-review", source)
|
|
231
231
|
self.assertIn("custom", source)
|
|
232
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
|
+
|
|
233
252
|
|
|
234
253
|
if __name__ == "__main__":
|
|
235
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
|