cyclecad 3.10.0 → 3.10.1

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,274 @@
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)}
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
+ }
8
+
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)}
31
+
30
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))}
31
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}
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}
34
+
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
+ }
43
+
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;
51
+ S.els.log.appendChild(d);
52
+ }
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
+ }
70
+ }
71
+ }
72
+ S.els.log.scrollTop=S.els.log.scrollHeight;
73
+ }
74
+
33
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}`}
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}}
76
+
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';
91
+ }
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;
111
+ }
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;
142
+ }
143
+ }
144
+ }
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();
154
+ }
155
+ }
156
+
35
157
  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:
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();
167
+ }
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:
38
211
  • create a Raspberry Pi 4B case with port cutouts
39
212
  • 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');
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);
236
+ }
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;
249
+ }
250
+
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()}
257
+ };
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');
42
274
  })();
@@ -0,0 +1,230 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>AI Copilot Tests — cycleCAD</title>
6
+ <style>
7
+ * { margin: 0; padding: 0; box-sizing: border-box; }
8
+ body { font-family: -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; display: flex; height: 100vh; }
9
+ .app { flex: 0 0 70%; border: 1px solid #2d3748; }
10
+ .app iframe { width: 100%; height: 100%; border: 0; }
11
+ .panel { flex: 0 0 30%; display: flex; flex-direction: column; background: #1a202c; border-left: 1px solid #2d3748; }
12
+ .header { padding: 16px; border-bottom: 1px solid #2d3748; }
13
+ .header h1 { font-size: 16px; color: #38bdf8; margin-bottom: 10px; }
14
+ .stats { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px; font-size: 11px; }
15
+ .stat { padding: 6px; background: #1e293b; border-radius: 4px; border-left: 2px solid #64748b; }
16
+ .stat.pass { border-left-color: #10b981; }
17
+ .stat.fail { border-left-color: #ef4444; }
18
+ .stat.skip { border-left-color: #f59e0b; }
19
+ .stat-label { font-size: 10px; color: #94a3b8; }
20
+ .stat-value { font-size: 16px; font-weight: 600; }
21
+ .controls { padding: 10px; border-bottom: 1px solid #2d3748; display: flex; gap: 6px; }
22
+ .controls button { flex: 1; padding: 6px 8px; background: #334155; color: #e2e8f0; border: 0; border-radius: 4px; font-size: 11px; cursor: pointer; }
23
+ .controls button:hover { background: #475569; }
24
+ .controls button.primary { background: #38bdf8; color: #0f172a; font-weight: 600; }
25
+ .log { flex: 1; overflow-y: auto; padding: 10px; font: 11px 'SF Mono', monospace; }
26
+ .entry { padding: 4px 6px; margin-bottom: 2px; border-radius: 3px; white-space: pre-wrap; }
27
+ .entry.pass { background: rgba(16, 185, 129, 0.1); color: #10b981; }
28
+ .entry.fail { background: rgba(239, 68, 68, 0.1); color: #ef4444; }
29
+ .entry.info { color: #94a3b8; }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <div class="app"><iframe id="appFrame" src="/app/index.html"></iframe></div>
34
+ <div class="panel">
35
+ <div class="header">
36
+ <h1>🤖 AI Copilot Tests</h1>
37
+ <div class="stats">
38
+ <div class="stat pass"><div class="stat-label">PASSED</div><div class="stat-value" id="passCount">0</div></div>
39
+ <div class="stat fail"><div class="stat-label">FAILED</div><div class="stat-value" id="failCount">0</div></div>
40
+ <div class="stat skip"><div class="stat-label">SKIPPED</div><div class="stat-value" id="skipCount">0</div></div>
41
+ </div>
42
+ </div>
43
+ <div class="controls">
44
+ <button class="primary" onclick="runAllTests()">▶ Run All</button>
45
+ <button onclick="document.getElementById('log').innerHTML=''">Clear</button>
46
+ </div>
47
+ <div class="log" id="log"></div>
48
+ </div>
49
+
50
+ <script>
51
+ let passCount = 0, failCount = 0, skipCount = 0;
52
+
53
+ function log(msg, cls = 'info') {
54
+ const e = document.createElement('div');
55
+ e.className = 'entry ' + cls;
56
+ e.textContent = msg;
57
+ document.getElementById('log').appendChild(e);
58
+ }
59
+
60
+ function updateCounts() {
61
+ document.getElementById('passCount').textContent = passCount;
62
+ document.getElementById('failCount').textContent = failCount;
63
+ document.getElementById('skipCount').textContent = skipCount;
64
+ }
65
+
66
+ async function waitForFrame() {
67
+ const iframe = document.getElementById('appFrame');
68
+ iframe.src = 'http://localhost:3000/app/index.html?cb=' + Date.now();
69
+ await new Promise(r => iframe.addEventListener('load', r, { once: true }));
70
+ const deadline = Date.now() + 15000;
71
+ while (Date.now() < deadline) {
72
+ if (iframe.contentWindow.CycleCAD?.AICopilot) return true;
73
+ await new Promise(r => setTimeout(r, 200));
74
+ }
75
+ return false;
76
+ }
77
+
78
+ const tests = [
79
+ // MODULE LOADING
80
+ { name: 'AICopilot module exists', fn: () => {
81
+ const f = document.getElementById('appFrame').contentWindow;
82
+ return !!f.CycleCAD?.AICopilot;
83
+ }},
84
+ { name: 'getUI returns valid panel', fn: () => {
85
+ const f = document.getElementById('appFrame').contentWindow;
86
+ const ui = f.CycleCAD.AICopilot.getUI();
87
+ return !!ui && (ui instanceof f.HTMLElement || ui instanceof f.Node || typeof ui === 'object');
88
+ }},
89
+ { name: 'init function exists', fn: () => {
90
+ const f = document.getElementById('appFrame').contentWindow;
91
+ return typeof f.CycleCAD.AICopilot.init === 'function';
92
+ }},
93
+ { name: 'execute function exists', fn: () => {
94
+ const f = document.getElementById('appFrame').contentWindow;
95
+ return typeof f.CycleCAD.AICopilot.execute === 'function';
96
+ }},
97
+ { name: 'go function exists', fn: () => {
98
+ const f = document.getElementById('appFrame').contentWindow;
99
+ return typeof f.CycleCAD.AICopilot.go === 'function';
100
+ }},
101
+ { name: 'abort function exists', fn: () => {
102
+ const f = document.getElementById('appFrame').contentWindow;
103
+ return typeof f.CycleCAD.AICopilot.abort === 'function';
104
+ }},
105
+ { name: 'getState returns object', fn: () => {
106
+ const f = document.getElementById('appFrame').contentWindow;
107
+ const s = f.CycleCAD.AICopilot.getState();
108
+ return s && typeof s === 'object' && 'running' in s;
109
+ }},
110
+ // UI STRUCTURE
111
+ { name: 'UI has prompt textarea', fn: () => {
112
+ const f = document.getElementById('appFrame').contentWindow;
113
+ const ui = f.CycleCAD.AICopilot.getUI();
114
+ return !!ui.querySelector('.aic-prompt');
115
+ }},
116
+ { name: 'UI has Generate button', fn: () => {
117
+ const f = document.getElementById('appFrame').contentWindow;
118
+ const ui = f.CycleCAD.AICopilot.getUI();
119
+ return !!ui.querySelector('.aic-go');
120
+ }},
121
+ { name: 'UI has Stop button', fn: () => {
122
+ const f = document.getElementById('appFrame').contentWindow;
123
+ const ui = f.CycleCAD.AICopilot.getUI();
124
+ return !!ui.querySelector('.aic-stop');
125
+ }},
126
+ { name: 'UI has model dropdown', fn: () => {
127
+ const f = document.getElementById('appFrame').contentWindow;
128
+ const ui = f.CycleCAD.AICopilot.getUI();
129
+ const s = ui.querySelector('.aic-model');
130
+ return s && s.tagName === 'SELECT';
131
+ }},
132
+ { name: 'Model dropdown has 5 options', fn: () => {
133
+ const f = document.getElementById('appFrame').contentWindow;
134
+ const ui = f.CycleCAD.AICopilot.getUI();
135
+ const s = ui.querySelector('.aic-model');
136
+ return s && s.options.length === 5;
137
+ }},
138
+ { name: 'UI has API key button', fn: () => {
139
+ const f = document.getElementById('appFrame').contentWindow;
140
+ const ui = f.CycleCAD.AICopilot.getUI();
141
+ return !!ui.querySelector('.aic-key');
142
+ }},
143
+ { name: 'UI has progress bar', fn: () => {
144
+ const f = document.getElementById('appFrame').contentWindow;
145
+ const ui = f.CycleCAD.AICopilot.getUI();
146
+ return !!ui.querySelector('.aic-prog');
147
+ }},
148
+ { name: 'UI has log area', fn: () => {
149
+ const f = document.getElementById('appFrame').contentWindow;
150
+ const ui = f.CycleCAD.AICopilot.getUI();
151
+ return !!ui.querySelector('.aic-log');
152
+ }},
153
+ // MENU INTEGRATION
154
+ { name: 'Tools menu has AI Copilot entry', fn: () => {
155
+ const f = document.getElementById('appFrame').contentWindow;
156
+ const btn = f.document.querySelector('[data-action="tools-ai-copilot"]');
157
+ return !!btn;
158
+ }},
159
+ // BEHAVIOR
160
+ { name: 'execute("stop") returns without throwing', fn: () => {
161
+ const f = document.getElementById('appFrame').contentWindow;
162
+ f.CycleCAD.AICopilot.execute('stop');
163
+ return true;
164
+ }},
165
+ { name: 'getState.running is false initially', fn: () => {
166
+ const f = document.getElementById('appFrame').contentWindow;
167
+ return f.CycleCAD.AICopilot.getState().running === false;
168
+ }},
169
+ { name: 'Missing key shows friendly error', async fn() {
170
+ const f = document.getElementById('appFrame').contentWindow;
171
+ // Clear keys temporarily
172
+ const saved = f.localStorage.getItem('cyclecad_api_keys');
173
+ f.localStorage.setItem('cyclecad_api_keys', '{}');
174
+ try {
175
+ const ui = f.CycleCAD.AICopilot.getUI();
176
+ const prompt = ui.querySelector('.aic-prompt');
177
+ const log = ui.querySelector('.aic-log');
178
+ prompt.value = 'test';
179
+ await f.CycleCAD.AICopilot.go();
180
+ await new Promise(r => setTimeout(r, 500));
181
+ // Expect a warning about missing key in the log
182
+ return log.textContent.toLowerCase().includes('key') || log.textContent.toLowerCase().includes('missing');
183
+ } finally {
184
+ if (saved !== null) f.localStorage.setItem('cyclecad_api_keys', saved);
185
+ }
186
+ }},
187
+ { name: 'Empty prompt shows warning', async fn() {
188
+ const f = document.getElementById('appFrame').contentWindow;
189
+ const ui = f.CycleCAD.AICopilot.getUI();
190
+ const prompt = ui.querySelector('.aic-prompt');
191
+ const log = ui.querySelector('.aic-log');
192
+ log.innerHTML = '';
193
+ prompt.value = '';
194
+ await f.CycleCAD.AICopilot.go();
195
+ await new Promise(r => setTimeout(r, 200));
196
+ return log.textContent.toLowerCase().includes('prompt') || log.textContent.toLowerCase().includes('enter');
197
+ }},
198
+ ];
199
+
200
+ async function runAllTests() {
201
+ passCount = failCount = skipCount = 0;
202
+ updateCounts();
203
+ document.getElementById('log').innerHTML = '';
204
+
205
+ log('Waiting for app iframe to populate window.CycleCAD.AICopilot...', 'info');
206
+ const ready = await waitForFrame();
207
+ log(ready ? '✓ App iframe ready. Running tests.' : '⚠ App did not populate AICopilot within 15s — tests may fail.', ready ? 'pass' : 'fail');
208
+
209
+ for (const t of tests) {
210
+ try {
211
+ const result = await Promise.resolve(t.fn());
212
+ if (result) {
213
+ passCount++;
214
+ log(`✓ ${t.name}`, 'pass');
215
+ } else {
216
+ failCount++;
217
+ log(`✗ ${t.name}`, 'fail');
218
+ }
219
+ } catch (e) {
220
+ failCount++;
221
+ log(`✗ ${t.name}: ${e.message}`, 'fail');
222
+ }
223
+ updateCounts();
224
+ }
225
+
226
+ log(`\nDone — ${passCount} passed, ${failCount} failed, ${skipCount} skipped.`, passCount === tests.length ? 'pass' : 'info');
227
+ }
228
+ </script>
229
+ </body>
230
+ </html>