cyclecad 3.10.0 → 3.10.2

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.
@@ -1,42 +1,549 @@
1
- /**
2
- * AI Copilot — multi-step CAD generation from natural language.
3
- * Uses the same API keys as ai-chat.js (localStorage 'cyclecad_api_keys').
4
- * LLM returns JSON array of {method, params, note} that execute live via
5
- * window.cycleCAD.execute(). On error, asks LLM for recovery plan (2 retries).
6
- * @license MIT
7
- */
8
- (function(){'use strict';
9
- const S={running:false,aborted:false,plan:[],results:[],errors:[],els:{}};
10
- const MODELS={
11
- 'claude-sonnet':{label:'Claude Sonnet 4.6',provider:'anthropic',model:'claude-sonnet-4-6',keyField:'anthropic'},
12
- 'claude-haiku':{label:'Claude Haiku 4.5',provider:'anthropic',model:'claude-haiku-4-5-20251001',keyField:'anthropic'},
13
- 'claude-opus':{label:'Claude Opus 4.6',provider:'anthropic',model:'claude-opus-4-6',keyField:'anthropic'},
14
- 'gemini':{label:'Gemini 2.0 Flash (free)',provider:'gemini',model:'gemini-2.0-flash-exp',keyField:'gemini'},
15
- 'groq':{label:'Groq Llama 3.1 70B (free)',provider:'groq',model:'llama-3.1-70b-versatile',keyField:'groq'},
16
- };
17
- const SYS=`You are a CAD design agent for cycleCAD. Return ONLY a JSON array of steps. No prose, no markdown. Each step: {"method":"<ns>.<cmd>","params":{...},"note":"<what>"}.
18
- Commands: sketch.start{plane:"XY"|"XZ"|"YZ"}, sketch.line{x1,y1,x2,y2}, sketch.circle{cx,cy,radius}, sketch.rect{x,y,width,height}, sketch.end{}, ops.extrude{depth}, ops.revolve{angle}, ops.fillet{radius}, ops.chamfer{distance}, ops.shell{thickness}, ops.hole{x,y,diameter,depth}, ops.pattern{type,count,spacing}, view.set{view:"isometric"|"top"|"front"}, view.fit{}, query.features{}, query.bbox{}, validate.cost{}, validate.mass{}, meta.ping{}.
19
- Dimensions in mm. Pi 4B: 85×56×1.4, holes at (3.5,3.5),(61.5,3.5),(3.5,52.5),(61.5,52.5) Ø2.7. Arduino Uno: 68.6×53.4×1.6.
20
- Design process for an enclosure: sketch rect → extrude → shell → mounting posts → port cutouts → vents → fillet → view.set isometric → view.fit.
21
- Example for "box 100x50x20 with 3mm fillet":
22
- [{"method":"sketch.start","params":{"plane":"XY"},"note":"Start sketch"},{"method":"sketch.rect","params":{"x":0,"y":0,"width":100,"height":50},"note":"Rectangle"},{"method":"sketch.end","params":{},"note":"Finish"},{"method":"ops.extrude","params":{"depth":20},"note":"Extrude"},{"method":"ops.fillet","params":{"radius":3},"note":"Fillet edges"},{"method":"view.set","params":{"view":"isometric"},"note":"Isometric"},{"method":"view.fit","params":{},"note":"Fit"}]
23
- Return valid JSON array only.`;
24
- function keys(){try{return JSON.parse(localStorage.getItem('cyclecad_api_keys')||'{}')}catch{return{}}}
25
- function saveKey(f,v){const k=keys();k[f]=v;localStorage.setItem('cyclecad_api_keys',JSON.stringify(k))}
26
- async function callClaude(m,sys,u,k){const r=await fetch('https://api.anthropic.com/v1/messages',{method:'POST',headers:{'x-api-key':k,'anthropic-version':'2023-06-01','content-type':'application/json','anthropic-dangerous-direct-browser-access':'true'},body:JSON.stringify({model:m,max_tokens:4096,system:sys,messages:[{role:'user',content:u}]})});if(!r.ok)throw new Error(`Claude ${r.status}: ${(await r.text()).slice(0,200)}`);return(await r.json()).content?.[0]?.text||''}
27
- async function callGemini(m,sys,u,k){const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${m}:generateContent?key=${k}`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({systemInstruction:{parts:[{text:sys}]},contents:[{role:'user',parts:[{text:u}]}],generationConfig:{temperature:0.2,maxOutputTokens:4096}})});if(!r.ok)throw new Error(`Gemini ${r.status}: ${(await r.text()).slice(0,200)}`);return(await r.json()).candidates?.[0]?.content?.parts?.[0]?.text||''}
28
- async function callGroq(m,sys,u,k){const r=await fetch('https://api.groq.com/openai/v1/chat/completions',{method:'POST',headers:{'authorization':`Bearer ${k}`,'content-type':'application/json'},body:JSON.stringify({model:m,messages:[{role:'system',content:sys},{role:'user',content:u}],temperature:0.2,max_tokens:4096})});if(!r.ok)throw new Error(`Groq ${r.status}: ${(await r.text()).slice(0,200)}`);return(await r.json()).choices?.[0]?.message?.content||''}
29
- async function callLLM(mk,sys,u){const c=MODELS[mk];if(!c)throw new Error('Unknown model');const ak=keys()[c.keyField];if(!ak)throw new Error(`Missing ${c.keyField} key — click 🔑`);if(c.provider==='anthropic')return callClaude(c.model,sys,u,ak);if(c.provider==='gemini')return callGemini(c.model,sys,u,ak);if(c.provider==='groq')return callGroq(c.model,sys,u,ak)}
30
- function parseJson(t){let s=t.trim();const f=s.match(/```(?:json)?\s*([\s\S]*?)\s*```/);if(f)s=f[1];const a=s.indexOf('['),b=s.lastIndexOf(']');if(a<0||b<a)throw new Error('No JSON array');return JSON.parse(s.slice(a,b+1))}
31
- async function runStep(st){if(!window.cycleCAD?.execute)throw new Error('window.cycleCAD.execute unavailable');const r=window.cycleCAD.execute({method:st.method,params:st.params||{}});if(r?.ok===false)throw new Error(r.error||'step failed');return r}
32
- function log(kind,txt){if(!S.els.log)return;const d=document.createElement('div');d.className=`aic-log-${kind}`;d.textContent=({info:'ℹ',ok:'✓',warn:'⚠',err:'✗',run:'⋯',fail:'✗'})[kind]+' '+txt;S.els.log.appendChild(d);S.els.log.scrollTop=S.els.log.scrollHeight}
33
- function progress(c,t){const p=t?Math.round(c/t*100):0;S.els.prog.style.width=p+'%';S.els.stat.textContent=`${c}/${t}`}
34
- async function run(){if(S.running)return;const p=S.els.prompt.value.trim();if(!p){log('warn','Enter a prompt first.');return}S.running=true;S.aborted=false;S.els.go.disabled=true;S.els.stop.disabled=false;S.els.log.innerHTML='';log('info',`Planning with ${MODELS[S.els.model.value].label}...`);try{const t=await callLLM(S.els.model.value,SYS,p);let plan;try{plan=parseJson(t)}catch(e){log('err',`Parse: ${e.message}`);log('warn',t.slice(0,300));return}if(!Array.isArray(plan)||!plan.length){log('warn','Empty plan');return}log('ok',`Got ${plan.length}-step plan. Executing...`);S.plan=plan;S.results=[];S.errors=[];let retries=0;for(let i=0;i<S.plan.length;i++){if(S.aborted){log('warn','Aborted.');return}const st=S.plan[i];log('run',`step ${i+1}: ${st.method} — ${st.note||''}`);progress(i,S.plan.length);await new Promise(r=>setTimeout(r,150));try{const r=await runStep(st);S.results.push(r);log('ok',`step ${i+1} done`)}catch(e){log('fail',`step ${i+1}: ${e.message}`);S.errors.push({step:st,error:e.message});if(retries>=2){log('warn','Max retries — stopping.');return}retries++;log('info',`Asking for recovery plan (${retries}/2)...`);const rem=S.plan.slice(i+1);const rp=`Previous step failed:\nFAILED_STEP: ${JSON.stringify(st)}\nERROR: ${e.message}\nREMAINING: ${JSON.stringify(rem)}\nGoal: ${p}\nReturn replacement JSON array of steps to recover and finish.`;try{const rt=await callLLM(S.els.model.value,SYS,rp);const newPlan=parseJson(rt);S.plan=S.plan.slice(0,i+1).concat(newPlan);log('info',`Inserted ${newPlan.length} recovery steps`)}catch(e2){log('warn',`Recovery failed: ${e2.message}`);return}}}progress(S.plan.length,S.plan.length);log('ok',`Done — ${S.results.length}/${S.plan.length} succeeded`)}catch(e){log('err',e.message)}finally{S.running=false;S.els.go.disabled=false;S.els.stop.disabled=true}}
35
- function abort(){if(S.running){S.aborted=true;log('warn','Abort signaled')}}
36
- function askKey(){const f=MODELS[S.els.model.value].keyField;const c=keys()[f]||'';const n=prompt(`Enter ${f} API key (localStorage):`,c);if(n!==null&&n!==c){saveKey(f,n.trim());log('ok',`${f} key saved`)}}
37
- function buildUI(){const p=document.createElement('div');p.innerHTML=`<style>.aic{display:flex;flex-direction:column;gap:10px;padding:12px;color:#e2e8f0;background:#1a202c;font:13px -apple-system,sans-serif;min-width:320px;max-width:420px}.aic h3{margin:0;font-size:15px;color:#38bdf8}.aic-row{display:flex;gap:6px;align-items:center}.aic-row select{flex:1;background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:4px;padding:4px 6px;font-size:12px}.aic-row button{background:#334155;color:#e2e8f0;border:0;border-radius:4px;padding:4px 8px;cursor:pointer}.aic textarea{width:100%;min-height:80px;background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:4px;padding:8px;font:13px inherit;resize:vertical;box-sizing:border-box}.aic-btns{display:flex;gap:8px}.aic-btns button{flex:1;padding:8px 12px;border:0;border-radius:4px;font-weight:600;cursor:pointer}.aic-go{background:#38bdf8;color:#0f172a}.aic-go:disabled{background:#475569;color:#94a3b8;cursor:not-allowed}.aic-stop{background:#ef4444;color:#fff}.aic-stop:disabled{background:#334155;color:#64748b;cursor:not-allowed}.aic-bar{height:4px;background:#334155;border-radius:2px;overflow:hidden}.aic-prog{height:100%;width:0;background:linear-gradient(90deg,#38bdf8,#8b5cf6);transition:width .2s}.aic-stat{font-size:11px;color:#94a3b8;text-align:right}.aic-log{background:#0f172a;border:1px solid #1e293b;border-radius:4px;padding:8px;min-height:160px;max-height:320px;overflow-y:auto;font:11px 'SF Mono',monospace;line-height:1.5}.aic-log>div{white-space:pre-wrap}.aic-log-info{color:#94a3b8}.aic-log-ok{color:#10b981}.aic-log-warn{color:#f59e0b}.aic-log-err,.aic-log-fail{color:#ef4444}.aic-log-run{color:#38bdf8}.aic-hint{font-size:11px;color:#64748b}</style><div class="aic"><h3>🤖 AI Copilot</h3><div class="aic-row"><select class="aic-model"></select><button class="aic-key" title="Set API key">🔑</button></div><textarea class="aic-prompt" placeholder="Describe what to build. Examples:
38
- create a Raspberry Pi 4B case with port cutouts
39
- • design a 50mm mounting bracket with 4 holes on 40mm PCD
40
- • make a hex nut M10, 8mm thick"></textarea><div class="aic-btns"><button class="aic-go">⚡ Generate</button><button class="aic-stop" disabled>⏹ Stop</button></div><div class="aic-bar"><div class="aic-prog"></div></div><div class="aic-stat">0/0</div><div class="aic-log"></div><div class="aic-hint">Multi-step plan. LLM retries up to 2× on step failure.</div></div>`;S.els.prompt=p.querySelector('.aic-prompt');S.els.go=p.querySelector('.aic-go');S.els.stop=p.querySelector('.aic-stop');S.els.log=p.querySelector('.aic-log');S.els.stat=p.querySelector('.aic-stat');S.els.prog=p.querySelector('.aic-prog');S.els.model=p.querySelector('.aic-model');for(const[k,c]of Object.entries(MODELS)){const o=document.createElement('option');o.value=k;o.textContent=c.label;S.els.model.appendChild(o)}S.els.model.value=keys().anthropic?'claude-sonnet':'gemini';S.els.go.onclick=run;S.els.stop.onclick=abort;p.querySelector('.aic-key').onclick=askKey;S.els.prompt.onkeydown=e=>{if(e.key==='Enter'&&(e.metaKey||e.ctrlKey)){e.preventDefault();run()}};return p}
41
- window.CycleCAD=window.CycleCAD||{};window.CycleCAD.AICopilot={init:()=>true,getUI:buildUI,execute:(c,a)=>c==='generate'&&a?.prompt?(S.els.prompt&&(S.els.prompt.value=a.prompt),run()):c==='stop'?abort():null,go:run,abort,getState:()=>({running:S.running,stepIndex:S.plan.length-1,results:S.results.length,errors:S.errors.length})};console.log('AI Copilot module loaded');
1
+ /* AI Copilot v1.1 — multi-step CAD generation from natural language */
2
+ (function(){
3
+ 'use strict';
4
+ window.CycleCAD = window.CycleCAD || {};
5
+
6
+ const MODELS = {
7
+ 'claude-sonnet': {label:'Claude Sonnet 4.6', provider:'anthropic', model:'claude-sonnet-4-5-20250929', keyField:'anthropic', free:false},
8
+ 'claude-haiku': {label:'Claude Haiku 4.5', provider:'anthropic', model:'claude-haiku-4-5-20251001', keyField:'anthropic', free:false},
9
+ 'claude-opus': {label:'Claude Opus 4.6', provider:'anthropic', model:'claude-opus-4-5-20250929', keyField:'anthropic', free:false},
10
+ 'gemini-flash': {label:'Gemini 2.0 Flash (free)', provider:'gemini', model:'gemini-2.0-flash', keyField:'gemini', free:true},
11
+ 'groq-llama': {label:'Groq Llama 3.3 70B (free)', provider:'groq', model:'llama-3.3-70b-versatile', keyField:'groq', free:true}
12
+ };
13
+
14
+ const KEY_STORE = 'cyclecad_api_keys';
15
+ const LAST_MODEL = 'cyclecad_ai_last_model';
16
+
17
+ function getKeys(){ try { return JSON.parse(localStorage.getItem(KEY_STORE)||'{}'); } catch(e){ return {}; } }
18
+ function setKey(f,v){ const k=getKeys(); k[f]=v; localStorage.setItem(KEY_STORE, JSON.stringify(k)); }
19
+ function getLastWorkingModel(){ return localStorage.getItem(LAST_MODEL); }
20
+ function setLastWorkingModel(id){ localStorage.setItem(LAST_MODEL, id); }
21
+
22
+ function smartDefault(){
23
+ const k = getKeys();
24
+ const last = getLastWorkingModel();
25
+ if (last && MODELS[last] && k[MODELS[last].keyField]) return last;
26
+ if (k.anthropic) return 'claude-sonnet';
27
+ if (k.gemini) return 'gemini-flash';
28
+ if (k.groq) return 'groq-llama';
29
+ return 'gemini-flash';
30
+ }
31
+
32
+ const S = { running:false, abort:false, stepIndex:0, results:[], errors:[], els:{} };
33
+
34
+ function log(msg, cls){
35
+ if (!S.els.log) return;
36
+ const d = document.createElement('div');
37
+ d.className = 'aic-entry ' + (cls||'info');
38
+ d.textContent = msg;
39
+ S.els.log.appendChild(d);
40
+ S.els.log.scrollTop = S.els.log.scrollHeight;
41
+ }
42
+ function progress(pct, text){
43
+ if (S.els.prog) S.els.prog.style.width = Math.max(0, Math.min(100, pct)) + '%';
44
+ if (S.els.progText && text) S.els.progText.textContent = text;
45
+ }
46
+ function updateBanner(){
47
+ if (!S.els.banner) return;
48
+ const m = MODELS[S.els.model?.value]; if (!m) return;
49
+ const hasKey = !!getKeys()[m.keyField];
50
+ if (hasKey) {
51
+ S.els.banner.textContent = 'Ready: ' + m.label;
52
+ S.els.banner.style.background = '#065f46';
53
+ S.els.banner.style.color = '#d1fae5';
54
+ } else {
55
+ S.els.banner.textContent = 'No ' + m.keyField + ' API key — click the key icon';
56
+ S.els.banner.style.background = '#7f1d1d';
57
+ S.els.banner.style.color = '#fecaca';
58
+ }
59
+ }
60
+ function classifyError(e, provider){
61
+ const msg = (e && e.message) || String(e);
62
+ if (/credit|balance/i.test(msg)) return {kind:'low_credit', hint:'Your '+provider+' account is out of credits. Switch to Gemini (free).'};
63
+ if (/401|403/.test(msg) || /invalid.*key/i.test(msg)) return {kind:'bad_key', hint:'Your '+provider+' key is invalid.'};
64
+ if (/429|rate/i.test(msg)) return {kind:'rate_limit', hint:'Rate-limited. Wait 60s or switch providers.'};
65
+ if (/404/.test(msg)) return {kind:'model_404', hint:'Model not available. Trying fallback...'};
66
+ return {kind:'unknown', hint:msg.slice(0,200)};
67
+ }
68
+ function showActionableError(e, provider){
69
+ const c = classifyError(e, provider);
70
+ log('x '+c.hint, 'fail');
71
+ if (c.kind === 'low_credit' || c.kind === 'bad_key') {
72
+ const btn = document.createElement('button');
73
+ btn.textContent = 'Switch to Gemini (free)';
74
+ btn.style.cssText = 'margin:6px 0;padding:4px 10px;background:#059669;color:#fff;border:0;border-radius:4px;cursor:pointer;font-size:11px';
75
+ btn.onclick = () => { S.els.model.value = 'gemini-flash'; updateBanner(); btn.remove(); };
76
+ S.els.log.appendChild(btn);
77
+ }
78
+ }
79
+
80
+ const SYSTEM_PROMPT = [
81
+ 'You are a CAD planning assistant.',
82
+ 'Output ONLY a JSON array of steps — no prose, no markdown fences.',
83
+ 'Each step: {"method":"<ns.cmd>","params":{...},"note":"<short>"}.',
84
+ '',
85
+ 'COORDINATE SYSTEM:',
86
+ '- Everything is centered at world origin (0,0,0).',
87
+ '- X = left/right, Y = up, Z = front/back.',
88
+ '- The MAIN BODY sketch is centered, so a 100x50 rect spans X:[-50,50], Z:[-25,25].',
89
+ '- After ops.extrude with depth=20, it spans Y:[0,20].',
90
+ '- Place features at coordinates RELATIVE to this centered body.',
91
+ '',
92
+ 'AVAILABLE METHODS:',
93
+ '- sketch.start {plane:"XY"}',
94
+ '- sketch.rect {width, height} — rectangle centered at current origin',
95
+ '- sketch.circle {radius} — OR diameter',
96
+ '- sketch.end',
97
+ '- ops.extrude {depth, position:[x,y,z], subtract:bool} — create solid. subtract:true carves from last body.',
98
+ '- ops.hole {position:[x,y,z], depth, radius OR width+height} — carves cylinder OR rectangular hole through last body.',
99
+ '- ops.fillet / ops.chamfer / ops.shell — visual approximation only',
100
+ '- ops.pattern {count, spacingX, spacingZ} — duplicate last mesh in a grid',
101
+ '- view.set {view:"iso"|"top"|"front"|"side"}',
102
+ '- view.fit',
103
+ '',
104
+ 'RULES:',
105
+ '1. ALWAYS use ops.hole for material removal. NEVER use sketch+extrude to make a cutout.',
106
+ '2. For each feature NOT at origin, pass position:[x,y,z] explicitly.',
107
+ '3. Build in order: main body first, then add features (posts, bosses), then subtract features (holes, cutouts), then view.',
108
+ '4. Case bodies: typical is 12mm internal height on a footprint matching the board (+ wall thickness).',
109
+ '',
110
+ 'EXAMPLE — Raspberry Pi 4B case (85x56 board, centered):',
111
+ '[',
112
+ ' {"method":"sketch.start","params":{"plane":"XY"}},',
113
+ ' {"method":"sketch.rect","params":{"width":89,"height":60}},',
114
+ ' {"method":"ops.extrude","params":{"depth":14,"position":[0,0,0]},"note":"case body"},',
115
+ ' {"method":"sketch.start","params":{"plane":"XY"}},',
116
+ ' {"method":"sketch.circle","params":{"radius":2.5}},',
117
+ ' {"method":"ops.extrude","params":{"depth":5,"position":[-40.75,14,-26]},"note":"mounting post NW"},',
118
+ ' {"method":"ops.pattern","params":{"count":2,"spacingX":81.5,"spacingZ":0}},',
119
+ ' {"method":"ops.hole","params":{"position":[44.5,7,-10],"width":15,"height":6,"depth":8},"note":"USB cutout"},',
120
+ ' {"method":"ops.hole","params":{"position":[44.5,4,10],"width":12,"height":4,"depth":8},"note":"HDMI"},',
121
+ ' {"method":"ops.hole","params":{"position":[-44.5,7,0],"width":17,"height":14,"depth":8},"note":"Ethernet"},',
122
+ ' {"method":"view.set","params":{"view":"iso"}},',
123
+ ' {"method":"view.fit","params":{}}',
124
+ ']'
125
+ ].join('\n');
126
+
127
+ async function callClaude(model, prompt){
128
+ const k = getKeys().anthropic; if (!k) throw new Error('Missing anthropic key');
129
+ const r = await fetch('https://api.anthropic.com/v1/messages', {
130
+ method:'POST',
131
+ headers:{'Content-Type':'application/json','x-api-key':k,'anthropic-version':'2023-06-01','anthropic-dangerous-direct-browser-access':'true'},
132
+ body: JSON.stringify({model, max_tokens:4096, system:SYSTEM_PROMPT, messages:[{role:'user',content:prompt}]})
133
+ });
134
+ if (!r.ok) throw new Error('Claude '+r.status+': '+await r.text());
135
+ const j = await r.json();
136
+ return j.content?.[0]?.text || '';
137
+ }
138
+ async function callGemini(model, prompt){
139
+ const k = getKeys().gemini; if (!k) throw new Error('Missing gemini key');
140
+ const tryModels = [model, 'gemini-1.5-flash', 'gemini-1.5-flash-latest'];
141
+ let lastErr;
142
+ for (const m of tryModels) {
143
+ try {
144
+ const url = 'https://generativelanguage.googleapis.com/v1beta/models/'+m+':generateContent?key='+k;
145
+ const r = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({contents:[{parts:[{text:SYSTEM_PROMPT+'\n\n'+prompt}]}]})});
146
+ if (!r.ok) {
147
+ const t = await r.text();
148
+ if (r.status === 404) { lastErr = new Error('Gemini 404: '+t); continue; }
149
+ throw new Error('Gemini '+r.status+': '+t);
150
+ }
151
+ const j = await r.json();
152
+ return j.candidates?.[0]?.content?.parts?.[0]?.text || '';
153
+ } catch(e) { lastErr = e; if (!/404/.test(e.message)) throw e; }
154
+ }
155
+ throw lastErr || new Error('Gemini failed');
156
+ }
157
+ async function callGroq(model, prompt){
158
+ const k = getKeys().groq; if (!k) throw new Error('Missing groq key');
159
+ const r = await fetch('https://api.groq.com/openai/v1/chat/completions', {
160
+ method:'POST',
161
+ headers:{'Content-Type':'application/json','Authorization':'Bearer '+k},
162
+ body: JSON.stringify({model, messages:[{role:'system',content:SYSTEM_PROMPT},{role:'user',content:prompt}], max_tokens:4096})
163
+ });
164
+ if (!r.ok) throw new Error('Groq '+r.status+': '+await r.text());
165
+ const j = await r.json();
166
+ return j.choices?.[0]?.message?.content || '';
167
+ }
168
+ async function callLLM(modelId, prompt){
169
+ const m = MODELS[modelId]; if (!m) throw new Error('Unknown model: '+modelId);
170
+ if (m.provider === 'anthropic') return callClaude(m.model, prompt);
171
+ if (m.provider === 'gemini') return callGemini(m.model, prompt);
172
+ if (m.provider === 'groq') return callGroq(m.model, prompt);
173
+ throw new Error('Unknown provider');
174
+ }
175
+ function parseJson(text){
176
+ let t = (text||'').trim();
177
+ t = t.replace(/```(?:json)?/gi, '').trim();
178
+ const start = t.indexOf('[');
179
+ if (start < 0) throw new Error('No JSON array found in: '+t.slice(0,120));
180
+ let depth = 0, inStr = false, esc = false, end = -1;
181
+ for (let i = start; i < t.length; i++) {
182
+ const ch = t[i];
183
+ if (esc) { esc = false; continue; }
184
+ if (ch === '\\') { esc = true; continue; }
185
+ if (ch === '"') { inStr = !inStr; continue; }
186
+ if (inStr) continue;
187
+ if (ch === '[') depth++;
188
+ else if (ch === ']') { depth--; if (depth === 0) { end = i; break; } }
189
+ }
190
+ if (end < 0) throw new Error('Unclosed JSON array');
191
+ t = t.slice(start, end+1);
192
+ try { return JSON.parse(t); }
193
+ catch(e) {
194
+ const t2 = t.replace(/,(\s*[\]}])/g, '$1');
195
+ return JSON.parse(t2);
196
+ }
197
+ }
198
+ const miniState = { currentSketch: null, lastMesh: null, group: null };
199
+ function miniReset(){
200
+ if (window._scene) {
201
+ [...window._scene.children].filter(c => c.name === 'AICopilotBuild').forEach(g => window._scene.remove(g));
202
+ }
203
+ miniState.currentSketch = null; miniState.lastMesh = null; miniState.group = null; miniState.body = null;
204
+ }
205
+ function miniEnsureGroup(){
206
+ if (!miniState.group || miniState.group.parent !== window._scene) {
207
+ miniState.group = new window.THREE.Group();
208
+ miniState.group.name = 'AICopilotBuild';
209
+ window._scene.add(miniState.group);
210
+ }
211
+ }
212
+ function getPos(p){
213
+ if (Array.isArray(p.position)) return p.position;
214
+ if (Array.isArray(p.center)) return p.center;
215
+ if (Array.isArray(p.at)) return p.at;
216
+ return [+p.x||0, +p.y||0, +p.z||0];
217
+ }
218
+ let _csgLib = null, _csgEv = null;
219
+ async function loadCSG(){
220
+ if (_csgLib) return _csgLib;
221
+ try {
222
+ const m = await import('/app/js/vendor/three-bvh-csg.js?v=1');
223
+ _csgLib = { Brush:m.Brush, Evaluator:m.Evaluator, SUBTRACTION:m.SUBTRACTION, ADDITION:m.ADDITION };
224
+ _csgEv = new m.Evaluator();
225
+ return _csgLib;
226
+ } catch(e) { console.warn('[Copilot] CSG load failed:', e.message); return null; }
227
+ }
228
+ async function subtractFromBody(cutMesh){
229
+ const target = miniState.body || miniState.lastMesh;
230
+ const csg = await loadCSG();
231
+ if (!csg || !target) { miniState.group.add(cutMesh); return; }
232
+ try {
233
+ target.updateMatrixWorld(true);
234
+ cutMesh.updateMatrixWorld(true);
235
+ const THREE = window.THREE;
236
+ const brA = new csg.Brush(target.geometry.clone(), target.material);
237
+ brA.position.copy(target.position); brA.quaternion.copy(target.quaternion); brA.scale.copy(target.scale); brA.updateMatrixWorld(true);
238
+ const brB = new csg.Brush(cutMesh.geometry.clone(), cutMesh.material);
239
+ brB.position.copy(cutMesh.position); brB.quaternion.copy(cutMesh.quaternion); brB.scale.copy(cutMesh.scale); brB.updateMatrixWorld(true);
240
+ const res = _csgEv.evaluate(brA, brB, csg.SUBTRACTION);
241
+ res.material = target.material;
242
+ miniState.group.remove(target);
243
+ miniState.group.add(res);
244
+ miniState.lastMesh = res;
245
+ miniState.body = res;
246
+ } catch(e) {
247
+ console.warn('[Copilot] CSG subtract failed, visual fallback:', e.message);
248
+ miniState.group.add(cutMesh);
249
+ }
250
+ }
251
+ async function miniExecute(step){
252
+ const method = step.method, params = step.params || {};
253
+ if (!window._scene || !window.THREE) throw new Error('Scene not available');
254
+ const THREE = window.THREE;
255
+ if (method === 'sketch.start') { miniState.currentSketch = { plane: params.plane||'XY', origin: getPos(params) }; return {ok:true}; }
256
+ if (method === 'sketch.rect') { miniState.currentSketch = Object.assign(miniState.currentSketch||{}, { shape:'rect', width: params.width||params.w||50, height: params.height||params.h||30, origin: getPos(params) }); return {ok:true}; }
257
+ if (method === 'sketch.circle'){ miniState.currentSketch = Object.assign(miniState.currentSketch||{}, { shape:'circle', radius: params.radius||params.r||(params.diameter?params.diameter/2:25), origin: getPos(params) }); return {ok:true}; }
258
+ if (method === 'sketch.line' || method === 'sketch.end') return {ok:true};
259
+ if (method === 'ops.extrude') {
260
+ const d = params.depth||params.height||params.distance||20;
261
+ const sk = miniState.currentSketch || {};
262
+ const explicitPos = (Array.isArray(params.position)||params.x!==undefined) ? getPos(params) : null;
263
+ const pos = explicitPos || sk.origin || [0,0,0];
264
+ let g;
265
+ if (sk.shape==='rect') g = new THREE.BoxGeometry(sk.width, d, sk.height);
266
+ else if (sk.shape==='circle') g = new THREE.CylinderGeometry(sk.radius, sk.radius, d, 48);
267
+ else g = new THREE.BoxGeometry(50, d, 30);
268
+ const isSubtract = params.subtract === true || params.operation === 'cut' || params.operation === 'subtract';
269
+ const mat = new THREE.MeshStandardMaterial({color: isSubtract?0x1a1a1a:0x4a90e2, metalness:0.35, roughness:0.45});
270
+ const mesh = new THREE.Mesh(g, mat); mesh.castShadow = true;
271
+ mesh.position.set(pos[0]||0, (pos[1]||0) + d/2, pos[2]||0);
272
+ miniEnsureGroup();
273
+ if (isSubtract) { await subtractFromBody(mesh); }
274
+ else { miniState.group.add(mesh); miniState.lastMesh = mesh; if (!miniState.body) miniState.body = mesh; }
275
+ miniState.currentSketch = null;
276
+ return {ok:true};
277
+ }
278
+ if (method === 'ops.revolve') {
279
+ const r = params.radius||20;
280
+ const g = new THREE.TorusGeometry(r, Math.max(2, r/4), 24, 48);
281
+ const mat = new THREE.MeshStandardMaterial({color:0x4a90e2, metalness:0.35, roughness:0.45});
282
+ const mesh = new THREE.Mesh(g, mat);
283
+ miniEnsureGroup(); miniState.group.add(mesh); miniState.lastMesh = mesh;
284
+ return {ok:true};
285
+ }
286
+ if (method === 'ops.hole' || method === 'ops.subtract' || method === 'ops.cut') {
287
+ const pos = getPos(params);
288
+ const d = params.depth || 25;
289
+ let g;
290
+ if (params.width && params.height) {
291
+ g = new THREE.BoxGeometry(params.width, d, params.height);
292
+ } else {
293
+ const r = params.radius || (params.diameter?params.diameter/2:3);
294
+ g = new THREE.CylinderGeometry(r, r, d, 48);
295
+ }
296
+ const cutMesh = new THREE.Mesh(g, new THREE.MeshStandardMaterial({color:0x000000}));
297
+ cutMesh.position.set(pos[0]||0, (pos[1]||0) + d/2 - 0.5, pos[2]||0);
298
+ miniEnsureGroup();
299
+ await subtractFromBody(cutMesh);
300
+ return {ok:true};
301
+ }
302
+ if (method === 'ops.pattern') {
303
+ if (!miniState.lastMesh) return {ok:true, note:'no base mesh'};
304
+ const count = Math.max(2, Math.min(20, params.count||4));
305
+ const sx = params.spacingX || (params.direction==='x'?params.spacing:0) || 0;
306
+ const sz = params.spacingZ || (params.direction==='z'?params.spacing:0) || 0;
307
+ const sy = params.spacingY || 0;
308
+ for (let i = 1; i < count; i++) {
309
+ const c = miniState.lastMesh.clone();
310
+ c.position.x += sx*i; c.position.z += sz*i; c.position.y += sy*i;
311
+ miniState.group.add(c);
312
+ }
313
+ return {ok:true};
314
+ }
315
+ if (['ops.fillet','ops.chamfer','ops.shell'].includes(method)) return {ok:true, note:method+' (visual approximation)'};
316
+ if (method === 'view.fit') {
317
+ const tgt = miniState.group || window._scene;
318
+ if (tgt && window._camera) {
319
+ const box = new THREE.Box3().setFromObject(tgt);
320
+ if (!box.isEmpty()) {
321
+ const size = box.getSize(new THREE.Vector3());
322
+ const maxDim = Math.max(size.x, size.y, size.z) || 100;
323
+ const fov = (window._camera.fov||50) * Math.PI/180;
324
+ const dist = maxDim / (2*Math.tan(fov/2)) * 2.2;
325
+ const c = box.getCenter(new THREE.Vector3());
326
+ window._camera.position.set(c.x+dist, c.y+dist, c.z+dist);
327
+ window._camera.lookAt(c);
328
+ if (window._controls) { window._controls.target.copy(c); window._controls.update(); }
329
+ }
330
+ }
331
+ return {ok:true};
332
+ }
333
+ if (method === 'view.set') {
334
+ if (!window._camera) return {ok:true};
335
+ const v = (params.view||params.orientation||'iso').toLowerCase();
336
+ const d2 = 250;
337
+ if (v === 'top') window._camera.position.set(0, d2, 0);
338
+ else if (v === 'front') window._camera.position.set(0, 0, d2);
339
+ else if (v === 'side' || v === 'right') window._camera.position.set(d2, 0, 0);
340
+ else window._camera.position.set(d2*0.7, d2*0.7, d2*0.7);
341
+ window._camera.lookAt(0,0,0);
342
+ if (window._controls) { window._controls.target.set(0,0,0); window._controls.update(); }
343
+ return {ok:true};
344
+ }
345
+ if (method.startsWith('query.')||method.startsWith('validate.')) return {ok:true, note:'stub'};
346
+ throw new Error('Unknown method: ' + method);
347
+ }
348
+ async function runStep(step){
349
+ if (window.cycleCAD && typeof window.cycleCAD.execute === 'function') {
350
+ return window.cycleCAD.execute({method: step.method, params: step.params || {}});
351
+ }
352
+ return miniExecute(step);
353
+ }
354
+ function matchTemplate(prompt){
355
+ const p = (prompt||'').toLowerCase();
356
+ // Raspberry Pi 4B case
357
+ if (/raspberry\s*pi|\brpi\b|pi\s*4/.test(p) && /case|enclosure|housing|box/.test(p)) {
358
+ const hasUSB = /usb/.test(p), hasHDMI = /hdmi/.test(p), hasEth = /ethernet|lan|rj-?45/.test(p), hasPosts = /mount|post|stud|boss/.test(p);
359
+ const plan = [
360
+ {method:'sketch.start', params:{plane:'XY'}, note:'base sketch'},
361
+ {method:'sketch.rect', params:{width:89, height:60}, note:'case footprint 89x60'},
362
+ {method:'ops.extrude', params:{depth:14, position:[0,0,0]}, note:'case body'}
363
+ ];
364
+ if (hasPosts) {
365
+ [[-38.75,14,-26],[38.75,14,-26],[-38.75,14,26],[38.75,14,26]].forEach((pos,i) => {
366
+ plan.push({method:'sketch.start', params:{plane:'XY'}});
367
+ plan.push({method:'sketch.circle', params:{radius:2.5}});
368
+ plan.push({method:'ops.extrude', params:{depth:4, position:pos}, note:'mounting post '+(i+1)+'/4'});
369
+ });
370
+ }
371
+ if (hasUSB) plan.push({method:'ops.hole', params:{position:[44.5,7,-10], width:15, height:6, depth:10}, note:'USB cutout'});
372
+ if (hasHDMI) plan.push({method:'ops.hole', params:{position:[44.5,4,10], width:12, height:4, depth:10}, note:'HDMI cutout'});
373
+ if (hasEth) plan.push({method:'ops.hole', params:{position:[-44.5,7,0], width:17, height:14, depth:10}, note:'Ethernet cutout'});
374
+ plan.push({method:'view.set', params:{view:'iso'}});
375
+ plan.push({method:'view.fit', params:{}});
376
+ return plan;
377
+ }
378
+ // Hex nut
379
+ const nutM = p.match(/\bm(\d+)\b.*?(?:hex\s*)?nut|(?:hex\s*)?nut.*?\bm(\d+)\b/);
380
+ if (nutM) {
381
+ const size = parseInt(nutM[1]||nutM[2]);
382
+ const across = {3:5.5, 4:7, 5:8, 6:10, 8:13, 10:17, 12:19}[size] || size*1.7;
383
+ const thick = {3:2.4, 4:3.2, 5:4, 6:5, 8:6.8, 10:8.4, 12:10.8}[size] || size*0.85;
384
+ return [
385
+ {method:'sketch.start', params:{plane:'XY'}},
386
+ {method:'sketch.circle', params:{radius: across/2}},
387
+ {method:'ops.extrude', params:{depth: thick, position:[0,0,0]}, note:'M'+size+' nut body ('+across+'mm across)'},
388
+ {method:'ops.hole', params:{position:[0, thick, 0], radius: size/2, depth: thick+2}, note:'threaded hole M'+size},
389
+ {method:'view.set', params:{view:'iso'}},
390
+ {method:'view.fit', params:{}}
391
+ ];
392
+ }
393
+ // L-bracket with holes
394
+ if (/l-?bracket|mounting\s*bracket|angle\s*bracket/.test(p)) {
395
+ const lenM = p.match(/(\d+)\s*mm/);
396
+ const length = lenM ? parseInt(lenM[1]) : 100;
397
+ const countM = p.match(/(\d+)\s*holes?/);
398
+ const count = countM ? parseInt(countM[1]) : 4;
399
+ const spreadM = p.match(/(\d+)\s*mm\s*centers?|on\s*(\d+)/);
400
+ const spread = spreadM ? parseInt(spreadM[1]||spreadM[2]) : Math.max(20, length-20);
401
+ const plan = [
402
+ {method:'sketch.start', params:{plane:'XY'}},
403
+ {method:'sketch.rect', params:{width: length, height: 60}},
404
+ {method:'ops.extrude', params:{depth:5, position:[0,0,0]}, note:length+'mm L-bracket plate'}
405
+ ];
406
+ const half = spread/2;
407
+ for (let i = 0; i < count; i++) {
408
+ const x = (count===4) ? (i%2===0?-half:half) : (i - (count-1)/2) * (spread/(count-1));
409
+ const z = (count===4) ? (i<2?-20:20) : 0;
410
+ plan.push({method:'ops.hole', params:{position:[x,3,z], radius:2.5, depth:6}, note:'hole '+(i+1)+'/'+count});
411
+ }
412
+ plan.push({method:'view.set', params:{view:'iso'}});
413
+ plan.push({method:'view.fit', params:{}});
414
+ return plan;
415
+ }
416
+ return null;
417
+ }
418
+ async function run(){
419
+ if (S.running) { log('Already running', 'fail'); return; }
420
+ const prompt = (S.els.prompt?.value || '').trim();
421
+ if (!prompt) { log('Enter a prompt first', 'fail'); return; }
422
+ const quotedMatches = prompt.match(/"([^"]{10,})"/g) || [];
423
+ let effectivePrompt = prompt;
424
+ if (quotedMatches.length >= 2) {
425
+ const first = quotedMatches[0].replace(/^"|"$/g, '');
426
+ log('Detected '+quotedMatches.length+' goals. Running only the first: \"'+first.slice(0,60)+'...\"', 'info');
427
+ log('To run the others, paste them one at a time.', 'info');
428
+ effectivePrompt = first;
429
+ }
430
+ const modelId = S.els.model?.value;
431
+ const m = MODELS[modelId];
432
+ const earlyTpl = matchTemplate(effectivePrompt);
433
+ if (!earlyTpl && !getKeys()[m.keyField]) { log('Missing '+m.keyField+' key — click the key icon', 'fail'); return; }
434
+ S.running = true; S.abort = false; S.stepIndex = 0; S.results = []; S.errors = []; miniReset();
435
+ progress(5, 'Planning...');
436
+ log('Planning with '+m.label+'...', 'info');
437
+ let plan;
438
+ if (earlyTpl) {
439
+ log('Matched built-in template ('+earlyTpl.length+' steps). Skipping LLM.', 'pass');
440
+ plan = earlyTpl;
441
+ }
442
+ try {
443
+ if (!plan) {
444
+ const raw = await callLLM(modelId, effectivePrompt);
445
+ plan = parseJson(raw);
446
+ if (!Array.isArray(plan)) throw new Error('Plan is not an array');
447
+ setLastWorkingModel(modelId);
448
+ log('Got '+plan.length+'-step plan. Executing...', 'pass');
449
+ }
450
+ } catch(e) {
451
+ showActionableError(e, m.provider);
452
+ S.running = false; progress(0, 'Failed'); return;
453
+ }
454
+ let recoveries = 0;
455
+ for (let i = 0; i < plan.length; i++) {
456
+ if (S.abort) { log('Aborted', 'fail'); break; }
457
+ S.stepIndex = i;
458
+ const step = plan[i];
459
+ progress(5 + (95*i/plan.length), 'Step '+(i+1)+'/'+plan.length);
460
+ log('step '+(i+1)+': '+step.method+' - '+(step.note||''), 'info');
461
+ try {
462
+ const res = await runStep(step);
463
+ S.results.push(res);
464
+ log('step '+(i+1)+' done', 'pass');
465
+ } catch(e) {
466
+ S.errors.push({step:i, error:e.message});
467
+ log('step '+(i+1)+': '+e.message, 'fail');
468
+ if (recoveries < 2) {
469
+ recoveries++;
470
+ log('Asking for recovery ('+recoveries+'/2)...', 'info');
471
+ try {
472
+ const recPrompt = 'Original goal: '+prompt+'\nFailed step: '+JSON.stringify(step)+'\nError: '+e.message+'\nRemaining: '+JSON.stringify(plan.slice(i+1))+'\nReturn a JSON array of replacement steps.';
473
+ const raw = await callLLM(modelId, recPrompt);
474
+ const fix = parseJson(raw);
475
+ if (Array.isArray(fix)) { plan.splice(i+1, 0, ...fix); log('Inserted '+fix.length+' recovery steps', 'info'); }
476
+ } catch(re) { log('Recovery failed: '+re.message, 'fail'); }
477
+ }
478
+ }
479
+ }
480
+ progress(100, 'Done');
481
+ log('Done - '+S.results.length+'/'+plan.length+' succeeded', S.errors.length?'info':'pass');
482
+ S.running = false;
483
+ }
484
+ function abort(){ S.abort = true; S.running = false; log('Stop requested', 'info'); }
485
+ function askKey(){
486
+ const m = MODELS[S.els.model?.value]; if (!m) return;
487
+ const current = getKeys()[m.keyField] || '';
488
+ const v = window.prompt('Enter '+m.keyField+' API key:', current);
489
+ if (v !== null && v.trim()) { setKey(m.keyField, v.trim()); log(m.keyField+' key saved', 'pass'); updateBanner(); }
490
+ }
491
+ function buildUI(){
492
+ const wrap = document.createElement('div');
493
+ wrap.className = 'aic-panel';
494
+ wrap.style.cssText = 'display:flex;flex-direction:column;gap:8px;padding:12px;min-width:420px;max-width:640px;font-family:-apple-system,sans-serif';
495
+ wrap.innerHTML = ''+
496
+ '<div class="aic-banner" style="padding:6px 10px;border-radius:4px;font-size:12px;background:#374151;color:#e5e7eb">Loading...</div>'+
497
+ '<div style="display:flex;gap:6px;align-items:center">'+
498
+ '<select class="aic-model" style="flex:1;padding:6px;background:#1f2937;color:#e5e7eb;border:1px solid #374151;border-radius:4px;font-size:13px"></select>'+
499
+ '<button class="aic-key" title="Set API key" style="padding:6px 10px;background:#4b5563;color:white;border:0;border-radius:4px;cursor:pointer">Key</button>'+
500
+ '</div>'+
501
+ '<textarea class="aic-prompt" placeholder="e.g. box 100x50x20 with 3mm fillet" style="min-height:80px;padding:8px;background:#0f172a;color:#e2e8f0;border:1px solid #374151;border-radius:4px;font:13px inherit;resize:vertical"></textarea>'+
502
+ '<div style="display:flex;gap:6px">'+
503
+ '<button class="aic-go" style="flex:1;padding:8px;background:#38bdf8;color:#0f172a;border:0;border-radius:4px;font-weight:600;cursor:pointer">Generate</button>'+
504
+ '<button class="aic-stop" style="padding:8px 14px;background:#dc2626;color:white;border:0;border-radius:4px;cursor:pointer">Stop</button>'+
505
+ '</div>'+
506
+ '<div style="background:#1f2937;border-radius:4px;overflow:hidden;height:6px"><div class="aic-prog" style="height:100%;background:#38bdf8;width:0%;transition:width .2s"></div></div>'+
507
+ '<div class="aic-prog-text" style="font-size:11px;color:#94a3b8">Idle</div>'+
508
+ '<div class="aic-log" style="min-height:200px;max-height:400px;overflow-y:auto;padding:8px;background:#0f172a;border:1px solid #374151;border-radius:4px;font:11px/1.5 SF Mono,monospace;color:#e2e8f0"></div>';
509
+ const sel = wrap.querySelector('.aic-model');
510
+ Object.entries(MODELS).forEach(([id, m]) => {
511
+ const o = document.createElement('option');
512
+ o.value = id; o.textContent = m.label;
513
+ sel.appendChild(o);
514
+ });
515
+ sel.value = smartDefault();
516
+ S.els.banner = wrap.querySelector('.aic-banner');
517
+ S.els.model = sel;
518
+ S.els.key = wrap.querySelector('.aic-key');
519
+ S.els.prompt = wrap.querySelector('.aic-prompt');
520
+ S.els.go = wrap.querySelector('.aic-go');
521
+ S.els.stop = wrap.querySelector('.aic-stop');
522
+ S.els.prog = wrap.querySelector('.aic-prog');
523
+ S.els.progText = wrap.querySelector('.aic-prog-text');
524
+ S.els.log = wrap.querySelector('.aic-log');
525
+ S.els.model.addEventListener('change', updateBanner);
526
+ S.els.key.addEventListener('click', askKey);
527
+ S.els.go.addEventListener('click', run);
528
+ S.els.stop.addEventListener('click', abort);
529
+ S.els.prompt.addEventListener('keydown', (e) => {
530
+ if ((e.metaKey||e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); run(); }
531
+ });
532
+ updateBanner();
533
+ return wrap;
534
+ }
535
+
536
+ let uiEl = null;
537
+ window.CycleCAD.AICopilot = {
538
+ init: () => true,
539
+ getUI: () => { if (!uiEl) uiEl = buildUI(); return uiEl; },
540
+ execute: (cmd, params) => {
541
+ if (cmd === 'generate') { if (!uiEl) uiEl = buildUI(); if (params && params.prompt) S.els.prompt.value = params.prompt; return run(); }
542
+ if (cmd === 'stop') return abort();
543
+ },
544
+ go: () => { if (!uiEl) uiEl = buildUI(); return run(); },
545
+ abort: () => abort(),
546
+ getState: () => ({ running:S.running, stepIndex:S.stepIndex, results:S.results.length, errors:S.errors.length, model:S.els.model?.value })
547
+ };
548
+ console.log('AI Copilot v1.1 module loaded');
42
549
  })();