cyclecad 3.10.1 → 3.10.3

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,274 +1,647 @@
1
- limit',hint:'Rate-limited. Wait 60s or switch providers.'};
2
- }
3
- if(m.includes('missing')&&m.includes('key')){
4
- return{kind:'no_key',hint:'Click 🔑 to add your API key.'};
5
- }
6
- return{kind:'other',hint:''};
7
- }
1
+ /* AI Copilot v1.1 multi-step CAD generation from natural language */
2
+ (function(){
3
+ 'use strict';
4
+ window.CycleCAD = window.CycleCAD || {};
8
5
 
9
- 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,400)}`);return(await r.json()).content?.[0]?.text||''}
10
- async function callGemini(m,sys,u,k){
11
- // Try the requested model, then fall back to gemini-1.5-flash if the model is not found on v1beta
12
- const fallbacks=[m,'gemini-1.5-flash','gemini-1.5-flash-latest'];
13
- let lastErr;
14
- for(const model of fallbacks){
15
- try{
16
- const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}: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}})});
17
- if(r.ok)return(await r.json()).candidates?.[0]?.content?.parts?.[0]?.text||'';
18
- const errTxt=(await r.text()).slice(0,400);
19
- lastErr=`Gemini ${r.status}: ${errTxt}`;
20
- // Only fall through on 404; other errors (401, 429) should surface immediately
21
- if(r.status!==404)throw new Error(lastErr);
22
- }catch(e){
23
- if(!e.message.startsWith('Gemini 404'))throw e;
24
- lastErr=e.message;
25
- }
26
- }
27
- throw new Error(lastErr||'All Gemini fallbacks failed');
28
- }
29
- 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,400)}`);return(await r.json()).choices?.[0]?.message?.content||''}
30
- 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 🔑 to add one`);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)}
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
+ };
31
13
 
32
- 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))}
33
- 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}
14
+ const KEY_STORE = 'cyclecad_api_keys';
15
+ const LAST_MODEL = 'cyclecad_ai_last_model';
34
16
 
35
- function log(kind,txt){
36
- if(!S.els.log)return;
37
- const d=document.createElement('div');
38
- d.className=`aic-log-${kind}`;
39
- d.textContent=({info:'ℹ',ok:'✓',warn:'⚠',err:'✗',run:'⋯',fail:'✗'})[kind]+' '+txt;
40
- S.els.log.appendChild(d);
41
- S.els.log.scrollTop=S.els.log.scrollHeight;
42
- }
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); }
43
21
 
44
- function showActionableError(errMsg){
45
- const cls=classifyError(errMsg);
46
- log('err',errMsg);
47
- if(cls.hint){
48
- const d=document.createElement('div');
49
- d.className='aic-hint-line';
50
- d.textContent='💡 '+cls.hint;
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;
51
39
  S.els.log.appendChild(d);
40
+ S.els.log.scrollTop = S.els.log.scrollHeight;
52
41
  }
53
- // Auto-suggest switching to a free model if current is paid and failed with no-credit/bad-key
54
- if(cls.kind==='no_credit'||cls.kind==='bad_key'){
55
- const cur=MODELS[S.els.model.value];
56
- if(cur&&!cur.free){
57
- const freeAvail=FREE_MODELS.filter(m=>keys()[MODELS[m].keyField]);
58
- if(freeAvail.length){
59
- const btn=document.createElement('button');
60
- btn.className='aic-switch-btn';
61
- btn.textContent=`⚡ Switch to ${MODELS[freeAvail[0]].label}`;
62
- btn.onclick=()=>{S.els.model.value=freeAvail[0];updateBanner();log('info',`Switched to ${MODELS[freeAvail[0]].label}`);btn.remove()};
63
- S.els.log.appendChild(btn);
64
- }else{
65
- const d=document.createElement('div');
66
- d.className='aic-hint-line';
67
- d.textContent='→ Get a free Gemini key at aistudio.google.com/apikey (or Groq at console.groq.com/keys), then click 🔑 to add it.';
68
- S.els.log.appendChild(d);
69
- }
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);
70
77
  }
71
78
  }
72
- S.els.log.scrollTop=S.els.log.scrollHeight;
73
- }
74
79
 
75
- 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}`}
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');
76
126
 
77
- function updateBanner(){
78
- if(!S.els.banner)return;
79
- const mk=S.els.model?.value;
80
- if(!mk){S.els.banner.style.display='none';return}
81
- const cfg=MODELS[mk];
82
- const ak=keys()[cfg.keyField];
83
- if(!ak){
84
- S.els.banner.className='aic-banner aic-banner-warn';
85
- S.els.banner.innerHTML=`⚠ No <b>${cfg.keyField}</b> key set. Click 🔑 to add one.`;
86
- S.els.banner.style.display='block';
87
- }else{
88
- S.els.banner.className='aic-banner aic-banner-ok';
89
- S.els.banner.innerHTML=` ✓ Ready — using <b>${cfg.label}</b>${cfg.free?' (free tier)':''}`;
90
- S.els.banner.style.display='block';
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 || '';
91
137
  }
92
- }
93
-
94
- async function run(){
95
- if(S.running)return;
96
- const p=S.els.prompt.value.trim();
97
- if(!p){log('warn','Enter a prompt first.');return}
98
- S.running=true;S.aborted=false;
99
- S.els.go.disabled=true;S.els.stop.disabled=false;
100
- S.els.log.innerHTML='';
101
- log('info',`Planning with ${MODELS[S.els.model.value].label}...`);
102
- try{
103
- const t=await callLLM(S.els.model.value,SYS,p);
104
- // Mark this model as working
105
- setLastWorkingModel(S.els.model.value);
106
- let plan;
107
- try{plan=parseJson(t)}catch(e){
108
- log('err',`Parse: ${e.message}`);
109
- log('warn',t.slice(0,300));
110
- return;
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};
111
314
  }
112
- if(!Array.isArray(plan)||!plan.length){log('warn','Empty plan');return}
113
- log('ok',`Got ${plan.length}-step plan. Executing...`);
114
- S.plan=plan;S.results=[];S.errors=[];
115
- let retries=0;
116
- for(let i=0;i<S.plan.length;i++){
117
- if(S.aborted){log('warn','Aborted.');return}
118
- const st=S.plan[i];
119
- log('run',`step ${i+1}: ${st.method} ${st.note||''}`);
120
- progress(i,S.plan.length);
121
- await new Promise(r=>setTimeout(r,150));
122
- try{
123
- const r=await runStep(st);
124
- S.results.push(r);
125
- log('ok',`step ${i+1} done`);
126
- }catch(e){
127
- log('fail',`step ${i+1}: ${e.message}`);
128
- S.errors.push({step:st,error:e.message});
129
- if(retries>=2){log('warn','Max retries — stopping.');return}
130
- retries++;
131
- log('info',`Asking for recovery plan (${retries}/2)...`);
132
- const rem=S.plan.slice(i+1);
133
- 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.`;
134
- try{
135
- const rt=await callLLM(S.els.model.value,SYS,rp);
136
- const newPlan=parseJson(rt);
137
- S.plan=S.plan.slice(0,i+1).concat(newPlan);
138
- log('info',`Inserted ${newPlan.length} recovery steps`);
139
- }catch(e2){
140
- showActionableError(`Recovery failed: ${e2.message}`);
141
- return;
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(); }
142
329
  }
143
330
  }
331
+ return {ok:true};
144
332
  }
145
- progress(S.plan.length,S.plan.length);
146
- log('ok',`Done ${S.results.length}/${S.plan.length} succeeded`);
147
- }catch(e){
148
- showActionableError(e.message);
149
- }finally{
150
- S.running=false;
151
- S.els.go.disabled=false;
152
- S.els.stop.disabled=true;
153
- updateBanner();
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);
154
347
  }
155
- }
156
-
157
- function abort(){if(S.running){S.aborted=true;log('warn','Abort signaled')}}
158
-
159
- function askKey(){
160
- const f=MODELS[S.els.model.value].keyField;
161
- const c=keys()[f]||'';
162
- const n=prompt(`Enter ${f} API key (stored in localStorage):`,c);
163
- if(n!==null&&n!==c){
164
- saveKey(f,n.trim());
165
- log('ok',`${f} key saved`);
166
- updateBanner();
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);
167
353
  }
168
- }
169
-
170
- function buildUI(){
171
- const p=document.createElement('div');
172
- p.innerHTML=`<style>
173
- .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}
174
- .aic h3{margin:0;font-size:15px;color:#38bdf8}
175
- .aic-banner{padding:6px 8px;border-radius:4px;font-size:11px;display:none}
176
- .aic-banner-ok{background:rgba(16,185,129,0.1);color:#10b981;border-left:2px solid #10b981}
177
- .aic-banner-warn{background:rgba(245,158,11,0.1);color:#f59e0b;border-left:2px solid #f59e0b}
178
- .aic-row{display:flex;gap:6px;align-items:center}
179
- .aic-row select{flex:1;background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:4px;padding:4px 6px;font-size:12px}
180
- .aic-row button{background:#334155;color:#e2e8f0;border:0;border-radius:4px;padding:4px 8px;cursor:pointer}
181
- .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}
182
- .aic-btns{display:flex;gap:8px}
183
- .aic-btns button{flex:1;padding:8px 12px;border:0;border-radius:4px;font-weight:600;cursor:pointer}
184
- .aic-go{background:#38bdf8;color:#0f172a}
185
- .aic-go:disabled{background:#475569;color:#94a3b8;cursor:not-allowed}
186
- .aic-stop{background:#ef4444;color:#fff}
187
- .aic-stop:disabled{background:#334155;color:#64748b;cursor:not-allowed}
188
- .aic-bar{height:4px;background:#334155;border-radius:2px;overflow:hidden}
189
- .aic-prog{height:100%;width:0;background:linear-gradient(90deg,#38bdf8,#8b5cf6);transition:width .2s}
190
- .aic-stat{font-size:11px;color:#94a3b8;text-align:right}
191
- .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}
192
- .aic-log>div{white-space:pre-wrap}
193
- .aic-log-info{color:#94a3b8}
194
- .aic-log-ok{color:#10b981}
195
- .aic-log-warn{color:#f59e0b}
196
- .aic-log-err,.aic-log-fail{color:#ef4444}
197
- .aic-log-run{color:#38bdf8}
198
- .aic-hint-line{color:#fbbf24;padding:4px 8px;margin-top:4px;background:rgba(251,191,36,0.08);border-radius:4px}
199
- .aic-switch-btn{display:block;margin:6px 0;padding:6px 10px;background:#38bdf8;color:#0f172a;border:0;border-radius:4px;font-weight:600;cursor:pointer;font-size:12px;width:auto}
200
- .aic-switch-btn:hover{background:#0ea5e9}
201
- .aic-hint{font-size:11px;color:#64748b}
202
- </style>
203
- <div class="aic">
204
- <h3>🤖 AI Copilot</h3>
205
- <div class="aic-banner"></div>
206
- <div class="aic-row">
207
- <select class="aic-model"></select>
208
- <button class="aic-key" title="Set API key">🔑</button>
209
- </div>
210
- <textarea class="aic-prompt" placeholder="Describe what to build. Examples:
211
- create a Raspberry Pi 4B case with port cutouts
212
- design a 50mm mounting bracket with 4 holes on 40mm PCD
213
- make a hex nut M10, 8mm thick"></textarea>
214
- <div class="aic-btns">
215
- <button class="aic-go">⚡ Generate</button>
216
- <button class="aic-stop" disabled>⏹ Stop</button>
217
- </div>
218
- <div class="aic-bar"><div class="aic-prog"></div></div>
219
- <div class="aic-stat">0/0</div>
220
- <div class="aic-log"></div>
221
- <div class="aic-hint">Multi-step plan. LLM retries up to 2× on step failure. Cmd/Ctrl+Enter to run.</div>
222
- </div>`;
223
- S.els.prompt=p.querySelector('.aic-prompt');
224
- S.els.go=p.querySelector('.aic-go');
225
- S.els.stop=p.querySelector('.aic-stop');
226
- S.els.log=p.querySelector('.aic-log');
227
- S.els.stat=p.querySelector('.aic-stat');
228
- S.els.prog=p.querySelector('.aic-prog');
229
- S.els.model=p.querySelector('.aic-model');
230
- S.els.banner=p.querySelector('.aic-banner');
231
-
232
- for(const[k_c]of Object.entries(MODELS)){
233
- const o=document.createElement('option');
234
- o.value=k_;o.textContent=c.label;
235
- S.els.model.appendChild(o);
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
+ // DIN 125 washer (M3-M12)
394
+ const washerM = p.match(/\bm(\d+)\s*washer|washer\s+m(\d+)|din\s*125\s*m?(\d+)/);
395
+ if (washerM) {
396
+ const size = parseInt(washerM[1]||washerM[2]||washerM[3]);
397
+ const outerR = ({3:3.5, 4:4.5, 5:5.3, 6:6.4, 8:8.4, 10:10.5, 12:13})[size] || size*1.2;
398
+ const thick = ({3:0.5, 4:0.8, 5:1, 6:1.6, 8:1.6, 10:2, 12:2.5})[size] || size*0.2;
399
+ const holeR = (size + 0.4) / 2;
400
+ return [
401
+ {method:'sketch.start', params:{plane:'XY'}},
402
+ {method:'sketch.circle', params:{radius: outerR}},
403
+ {method:'ops.extrude', params:{depth: thick, position:[0,0,0]}, note:'DIN 125 M'+size+' washer'},
404
+ {method:'ops.hole', params:{position:[0, thick, 0], radius: holeR, depth: thick+2}, note:'M'+size+' hole'},
405
+ {method:'view.set', params:{view:'iso'}},
406
+ {method:'view.fit', params:{}}
407
+ ];
408
+ }
409
+ // Flange with bolt circle
410
+ const flangeM = p.match(/flange/);
411
+ if (flangeM) {
412
+ const odM = p.match(/(\d+)\s*mm/);
413
+ const od = odM ? parseInt(odM[1]) : 80;
414
+ const nM = p.match(/(\d+)\s*(?:bolt\s*)?holes?/);
415
+ const nHoles = nM ? parseInt(nM[1]) : 4;
416
+ const pcdM = p.match(/pcd\s*(\d+)|bolt\s*circle\s*(\d+)/);
417
+ const pcd = pcdM ? parseInt(pcdM[1]||pcdM[2]) : Math.round(od*0.7);
418
+ const thick = 8;
419
+ const plan = [
420
+ {method:'sketch.start', params:{plane:'XY'}},
421
+ {method:'sketch.circle', params:{radius: od/2}},
422
+ {method:'ops.extrude', params:{depth: thick, position:[0,0,0]}, note:'flange body Ø'+od},
423
+ {method:'ops.hole', params:{position:[0, thick, 0], radius: Math.max(5, od/8), depth: thick+2}, note:'center bore'}
424
+ ];
425
+ for (let i = 0; i < nHoles; i++) {
426
+ const a = (i / nHoles) * Math.PI * 2;
427
+ const x = Math.cos(a) * pcd/2, z = Math.sin(a) * pcd/2;
428
+ plan.push({method:'ops.hole', params:{position:[x, thick/2, z], radius: 3, depth: thick+2}, note:'bolt hole '+(i+1)+'/'+nHoles});
429
+ }
430
+ plan.push({method:'view.set', params:{view:'iso'}});
431
+ plan.push({method:'view.fit', params:{}});
432
+ return plan;
433
+ }
434
+ // Threaded rod / stud
435
+ const rodM = p.match(/threaded\s*rod|m(\d+)\s*rod|m(\d+)\s*stud|studding/);
436
+ if (rodM) {
437
+ const sM = p.match(/m(\d+)/);
438
+ const size = sM ? parseInt(sM[1]) : 8;
439
+ const lM = p.match(/(\d+)\s*mm/);
440
+ const len = lM ? parseInt(lM[1]) : 100;
441
+ return [
442
+ {method:'sketch.start', params:{plane:'XY'}},
443
+ {method:'sketch.circle', params:{radius: size/2}},
444
+ {method:'ops.extrude', params:{depth: len, position:[0,0,0]}, note:'M'+size+' threaded rod, '+len+'mm long'},
445
+ {method:'view.set', params:{view:'iso'}},
446
+ {method:'view.fit', params:{}}
447
+ ];
448
+ }
449
+ // Mounting plate
450
+ const plateM = p.match(/mounting\s*plate|base\s*plate|flat\s*plate/);
451
+ if (plateM) {
452
+ const dimM = p.match(/(\d+)\s*x\s*(\d+)/);
453
+ const w = dimM ? parseInt(dimM[1]) : 120;
454
+ const h = dimM ? parseInt(dimM[2]) : 80;
455
+ const thick = 6;
456
+ const nM = p.match(/(\d+)\s*holes?/);
457
+ const nHoles = nM ? parseInt(nM[1]) : 4;
458
+ const plan = [
459
+ {method:'sketch.start', params:{plane:'XY'}},
460
+ {method:'sketch.rect', params:{width: w, height: h}},
461
+ {method:'ops.extrude', params:{depth: thick, position:[0,0,0]}, note: w+'x'+h+'x'+thick+' mounting plate'}
462
+ ];
463
+ if (nHoles === 4) {
464
+ const mx = w/2 - 10, mz = h/2 - 10;
465
+ [[-mx,-mz],[mx,-mz],[-mx,mz],[mx,mz]].forEach((pp,i) => {
466
+ plan.push({method:'ops.hole', params:{position:[pp[0], thick/2, pp[1]], radius:3, depth:thick+2}, note:'corner hole '+(i+1)});
467
+ });
468
+ } else {
469
+ for (let i = 0; i < nHoles; i++) {
470
+ const x = -w/2 + 10 + (i/(nHoles-1)) * (w-20);
471
+ plan.push({method:'ops.hole', params:{position:[x, thick/2, 0], radius:3, depth:thick+2}, note:'hole '+(i+1)});
472
+ }
473
+ }
474
+ plan.push({method:'view.set', params:{view:'iso'}});
475
+ plan.push({method:'view.fit', params:{}});
476
+ return plan;
477
+ }
478
+ // Generic box NxNxN with optional fillet
479
+ const boxM = p.match(/\bbox\b|\bblock\b|\bcube\b|\bcuboid\b/);
480
+ const boxDim = p.match(/(\d+)\s*[x×]\s*(\d+)\s*[x×]\s*(\d+)/);
481
+ if (boxM && boxDim) {
482
+ const w = parseInt(boxDim[1]), h = parseInt(boxDim[2]), d = parseInt(boxDim[3]);
483
+ return [
484
+ {method:'sketch.start', params:{plane:'XY'}},
485
+ {method:'sketch.rect', params:{width: w, height: d}},
486
+ {method:'ops.extrude', params:{depth: h, position:[0,0,0]}, note: w+'x'+h+'x'+d+' box'},
487
+ {method:'view.set', params:{view:'iso'}},
488
+ {method:'view.fit', params:{}}
489
+ ];
490
+ }
491
+ // L-bracket with holes
492
+ if (/l-?bracket|mounting\s*bracket|angle\s*bracket/.test(p)) {
493
+ const lenM = p.match(/(\d+)\s*mm/);
494
+ const length = lenM ? parseInt(lenM[1]) : 100;
495
+ const countM = p.match(/(\d+)\s*holes?/);
496
+ const count = countM ? parseInt(countM[1]) : 4;
497
+ const spreadM = p.match(/(\d+)\s*mm\s*centers?|on\s*(\d+)/);
498
+ const spread = spreadM ? parseInt(spreadM[1]||spreadM[2]) : Math.max(20, length-20);
499
+ const plan = [
500
+ {method:'sketch.start', params:{plane:'XY'}},
501
+ {method:'sketch.rect', params:{width: length, height: 60}},
502
+ {method:'ops.extrude', params:{depth:5, position:[0,0,0]}, note:length+'mm L-bracket plate'}
503
+ ];
504
+ const half = spread/2;
505
+ for (let i = 0; i < count; i++) {
506
+ const x = (count===4) ? (i%2===0?-half:half) : (i - (count-1)/2) * (spread/(count-1));
507
+ const z = (count===4) ? (i<2?-20:20) : 0;
508
+ plan.push({method:'ops.hole', params:{position:[x,3,z], radius:2.5, depth:6}, note:'hole '+(i+1)+'/'+count});
509
+ }
510
+ plan.push({method:'view.set', params:{view:'iso'}});
511
+ plan.push({method:'view.fit', params:{}});
512
+ return plan;
513
+ }
514
+ return null;
236
515
  }
237
-
238
- // Smart default: prefer last-working model, else first free model with a key,
239
- // else first paid model with a key, else gemini
240
- const storedKeys=keys();
241
- const lastWorking=getLastWorkingModel();
242
- if(lastWorking&&MODELS[lastWorking]&&storedKeys[MODELS[lastWorking].keyField]){
243
- S.els.model.value=lastWorking;
244
- }else{
245
- const firstUsable=Object.keys(MODELS).find(k=>storedKeys[MODELS[k].keyField]&&MODELS[k].free)
246
- ||Object.keys(MODELS).find(k=>storedKeys[MODELS[k].keyField])
247
- ||'gemini';
248
- S.els.model.value=firstUsable;
516
+ async function run(){
517
+ if (S.running) { log('Already running', 'fail'); return; }
518
+ const prompt = (S.els.prompt?.value || '').trim();
519
+ if (!prompt) { log('Enter a prompt first', 'fail'); return; }
520
+ const quotedMatches = prompt.match(/"([^"]{10,})"/g) || [];
521
+ let effectivePrompt = prompt;
522
+ if (quotedMatches.length >= 2) {
523
+ const first = quotedMatches[0].replace(/^"|"$/g, '');
524
+ log('Detected '+quotedMatches.length+' goals. Running only the first: \"'+first.slice(0,60)+'...\"', 'info');
525
+ log('To run the others, paste them one at a time.', 'info');
526
+ effectivePrompt = first;
527
+ }
528
+ const modelId = S.els.model?.value;
529
+ const m = MODELS[modelId];
530
+ const earlyTpl = matchTemplate(effectivePrompt);
531
+ if (!earlyTpl && !getKeys()[m.keyField]) { log('Missing '+m.keyField+' key — click the key icon', 'fail'); return; }
532
+ S.running = true; S.abort = false; S.stepIndex = 0; S.results = []; S.errors = []; miniReset();
533
+ progress(5, 'Planning...');
534
+ log('Planning with '+m.label+'...', 'info');
535
+ let plan;
536
+ if (earlyTpl) {
537
+ log('Matched built-in template ('+earlyTpl.length+' steps). Skipping LLM.', 'pass');
538
+ plan = earlyTpl;
539
+ }
540
+ try {
541
+ if (!plan) {
542
+ const raw = await callLLM(modelId, effectivePrompt);
543
+ plan = parseJson(raw);
544
+ if (!Array.isArray(plan)) throw new Error('Plan is not an array');
545
+ setLastWorkingModel(modelId);
546
+ log('Got '+plan.length+'-step plan. Executing...', 'pass');
547
+ }
548
+ } catch(e) {
549
+ showActionableError(e, m.provider);
550
+ S.running = false; progress(0, 'Failed'); return;
551
+ }
552
+ let recoveries = 0;
553
+ for (let i = 0; i < plan.length; i++) {
554
+ if (S.abort) { log('Aborted', 'fail'); break; }
555
+ S.stepIndex = i;
556
+ const step = plan[i];
557
+ progress(5 + (95*i/plan.length), 'Step '+(i+1)+'/'+plan.length);
558
+ log('step '+(i+1)+': '+step.method+' - '+(step.note||''), 'info');
559
+ try {
560
+ const res = await runStep(step);
561
+ S.results.push(res);
562
+ log('step '+(i+1)+' done', 'pass');
563
+ } catch(e) {
564
+ S.errors.push({step:i, error:e.message});
565
+ log('step '+(i+1)+': '+e.message, 'fail');
566
+ if (recoveries < 2) {
567
+ recoveries++;
568
+ log('Asking for recovery ('+recoveries+'/2)...', 'info');
569
+ try {
570
+ 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.';
571
+ const raw = await callLLM(modelId, recPrompt);
572
+ const fix = parseJson(raw);
573
+ if (Array.isArray(fix)) { plan.splice(i+1, 0, ...fix); log('Inserted '+fix.length+' recovery steps', 'info'); }
574
+ } catch(re) { log('Recovery failed: '+re.message, 'fail'); }
575
+ }
576
+ }
577
+ }
578
+ progress(100, 'Done');
579
+ log('Done - '+S.results.length+'/'+plan.length+' succeeded', S.errors.length?'info':'pass');
580
+ S.running = false;
581
+ }
582
+ function abort(){ S.abort = true; S.running = false; log('Stop requested', 'info'); }
583
+ function askKey(){
584
+ const m = MODELS[S.els.model?.value]; if (!m) return;
585
+ const current = getKeys()[m.keyField] || '';
586
+ const v = window.prompt('Enter '+m.keyField+' API key:', current);
587
+ if (v !== null && v.trim()) { setKey(m.keyField, v.trim()); log(m.keyField+' key saved', 'pass'); updateBanner(); }
588
+ }
589
+ function buildUI(){
590
+ const wrap = document.createElement('div');
591
+ wrap.className = 'aic-panel';
592
+ wrap.style.cssText = 'display:flex;flex-direction:column;gap:8px;padding:12px;min-width:420px;max-width:640px;font-family:-apple-system,sans-serif';
593
+ wrap.innerHTML = ''+
594
+ '<div class="aic-banner" style="padding:6px 10px;border-radius:4px;font-size:12px;background:#374151;color:#e5e7eb">Loading...</div>'+
595
+ '<div style="display:flex;gap:6px;align-items:center">'+
596
+ '<select class="aic-model" style="flex:1;padding:6px;background:#1f2937;color:#e5e7eb;border:1px solid #374151;border-radius:4px;font-size:13px"></select>'+
597
+ '<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>'+
598
+ '</div>'+
599
+ '<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>'+
600
+ '<div style="display:flex;gap:6px">'+
601
+ '<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>'+
602
+ '<button class="aic-stop" style="padding:8px 14px;background:#dc2626;color:white;border:0;border-radius:4px;cursor:pointer">Stop</button>'+
603
+ '</div>'+
604
+ '<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>'+
605
+ '<div class="aic-prog-text" style="font-size:11px;color:#94a3b8">Idle</div>'+
606
+ '<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>';
607
+ const sel = wrap.querySelector('.aic-model');
608
+ Object.entries(MODELS).forEach(([id, m]) => {
609
+ const o = document.createElement('option');
610
+ o.value = id; o.textContent = m.label;
611
+ sel.appendChild(o);
612
+ });
613
+ sel.value = smartDefault();
614
+ S.els.banner = wrap.querySelector('.aic-banner');
615
+ S.els.model = sel;
616
+ S.els.key = wrap.querySelector('.aic-key');
617
+ S.els.prompt = wrap.querySelector('.aic-prompt');
618
+ S.els.go = wrap.querySelector('.aic-go');
619
+ S.els.stop = wrap.querySelector('.aic-stop');
620
+ S.els.prog = wrap.querySelector('.aic-prog');
621
+ S.els.progText = wrap.querySelector('.aic-prog-text');
622
+ S.els.log = wrap.querySelector('.aic-log');
623
+ S.els.model.addEventListener('change', updateBanner);
624
+ S.els.key.addEventListener('click', askKey);
625
+ S.els.go.addEventListener('click', run);
626
+ S.els.stop.addEventListener('click', abort);
627
+ S.els.prompt.addEventListener('keydown', (e) => {
628
+ if ((e.metaKey||e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); run(); }
629
+ });
630
+ updateBanner();
631
+ return wrap;
249
632
  }
250
633
 
251
- S.els.go.onclick=run;
252
- S.els.stop.onclick=abort;
253
- p.querySelector('.aic-key').onclick=askKey;
254
- S.els.model.onchange=updateBanner;
255
- S.els.prompt.onkeydown=e=>{
256
- if(e.key==='Enter'&&(e.metaKey||e.ctrlKey)){e.preventDefault();run()}
634
+ let uiEl = null;
635
+ window.CycleCAD.AICopilot = {
636
+ init: () => true,
637
+ getUI: () => { if (!uiEl) uiEl = buildUI(); return uiEl; },
638
+ execute: (cmd, params) => {
639
+ if (cmd === 'generate') { if (!uiEl) uiEl = buildUI(); if (params && params.prompt) S.els.prompt.value = params.prompt; return run(); }
640
+ if (cmd === 'stop') return abort();
641
+ },
642
+ go: () => { if (!uiEl) uiEl = buildUI(); return run(); },
643
+ abort: () => abort(),
644
+ getState: () => ({ running:S.running, stepIndex:S.stepIndex, results:S.results.length, errors:S.errors.length, model:S.els.model?.value })
257
645
  };
258
-
259
- // Initial banner
260
- setTimeout(updateBanner,0);
261
- return p;
262
- }
263
-
264
- window.CycleCAD=window.CycleCAD||{};
265
- window.CycleCAD.AICopilot={
266
- init:()=>true,
267
- getUI:buildUI,
268
- execute:(c,a)=>c==='generate'&&a?.prompt?(S.els.prompt&&(S.els.prompt.value=a.prompt),run()):c==='stop'?abort():null,
269
- go:run,
270
- abort,
271
- getState:()=>({running:S.running,stepIndex:S.plan.length-1,results:S.results.length,errors:S.errors.length,model:S.els.model?.value}),
272
- };
273
- console.log('AI Copilot v1.1 module loaded');
646
+ console.log('AI Copilot v1.1 module loaded');
274
647
  })();