cyclecad 3.9.20 → 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.
@@ -0,0 +1,274 @@
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
+
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}
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
+
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}`}
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
+
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();
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:
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);
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');
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>
@@ -0,0 +1,150 @@
1
+ # AI Copilot — hands-on tutorial
2
+
3
+ Goal: design a **Raspberry Pi 4B enclosure** from scratch using only natural language, in under 2 minutes.
4
+
5
+ ## Prereqs
6
+
7
+ - cycleCAD v3.10.0 or later (`npm install cyclecad@latest`, or pull from GitHub)
8
+ - An API key for one of: Anthropic Claude, Google Gemini, or Groq
9
+ - Modern browser with `fetch` support (any recent Chrome/Safari/Firefox)
10
+
11
+ ## Step 1 — Open the copilot
12
+
13
+ Tools menu → **AI Copilot (multi-step)**. A dialog opens with a model dropdown, a prompt textarea, Generate/Stop buttons, a progress bar, and a live log.
14
+
15
+ ## Step 2 — Set your API key
16
+
17
+ Click the **🔑** icon next to the model dropdown. A browser prompt asks for your key. Paste it, click OK. The key is stored in `localStorage['cyclecad_api_keys']` — it never leaves your machine.
18
+
19
+ If you have a Claude key, the model dropdown defaults to **Claude Sonnet 4.6**. If not, it defaults to **Gemini 2.0 Flash (free)**.
20
+
21
+ ## Step 3 — First prompt
22
+
23
+ Start simple to validate everything works:
24
+
25
+ ```
26
+ box 100x50x20 with 3mm fillet
27
+ ```
28
+
29
+ Click **⚡ Generate**. You should see:
30
+
31
+ ```
32
+ ℹ Planning with Claude Sonnet 4.6...
33
+ ✓ Got 7-step plan. Executing...
34
+ ⋯ step 1: sketch.start — Start sketch
35
+ ✓ step 1 done
36
+ ⋯ step 2: sketch.rect — Rectangle
37
+ ✓ step 2 done
38
+ ⋯ step 3: sketch.end — Finish
39
+ ✓ step 3 done
40
+ ⋯ step 4: ops.extrude — Extrude
41
+ ✓ step 4 done
42
+ ⋯ step 5: ops.fillet — Fillet edges
43
+ ✓ step 5 done
44
+ ⋯ step 6: view.set — Isometric
45
+ ✓ step 6 done
46
+ ⋯ step 7: view.fit — Fit
47
+ ✓ step 7 done
48
+ ✓ Done — 7/7 succeeded
49
+ ```
50
+
51
+ The 3D viewport will show a 100×50×20 box with rounded edges, framed in isometric view.
52
+
53
+ ## Step 4 — The real deal
54
+
55
+ Clear the prompt and paste:
56
+
57
+ ```
58
+ create a Raspberry Pi 4B case. Use board specs: 85×56mm, 1.4mm thick, mounting holes at (3.5, 3.5), (61.5, 3.5), (3.5, 52.5), (61.5, 52.5) Ø2.7. Add:
59
+ - 2mm wall thickness
60
+ - 12mm internal height
61
+ - USB cutout on the right side
62
+ - HDMI cutout next to USB
63
+ - Ethernet cutout on the left
64
+ - SD card cutout on one short side
65
+ - vent slots on top
66
+ - 3mm fillet on outer edges
67
+ ```
68
+
69
+ Click **⚡ Generate**. This triggers a larger plan — typically 12-18 steps. The log fills in as each step runs. Watch the viewport: the case base appears, then shells into a hollow box, mounting posts rise, port cutouts carve out, vent slots appear, edges round over.
70
+
71
+ Typical elapsed time: 25-60 seconds (most of it is the LLM thinking; the Agent API calls themselves are instant).
72
+
73
+ ## Step 5 — Handling errors gracefully
74
+
75
+ Try this deliberately tricky prompt:
76
+
77
+ ```
78
+ extrude the current sketch 10mm
79
+ ```
80
+
81
+ There's no active sketch — the first call to `ops.extrude` will fail. Watch the log:
82
+
83
+ ```
84
+ ⋯ step 1: ops.extrude — Extrude sketch
85
+ ✗ step 1: No active sketch
86
+ ℹ Asking for recovery plan (1/2)...
87
+ ℹ Inserted 3 recovery steps
88
+ ⋯ step 2: sketch.start — Start a sketch first
89
+ ✓ step 2 done
90
+ ⋯ step 3: sketch.rect — Default rectangle
91
+ ✓ step 3 done
92
+ ⋯ step 4: sketch.end — Finish sketch
93
+ ✓ step 4 done
94
+ ⋯ step 5: ops.extrude — Now extrude
95
+ ✓ step 5 done
96
+ ```
97
+
98
+ The copilot self-corrected: it inserted the missing sketch steps and resumed.
99
+
100
+ ## Step 6 — Programmatic access
101
+
102
+ You can kick off a prompt from the browser console:
103
+
104
+ ```js
105
+ window.CycleCAD.AICopilot.execute('generate', {
106
+ prompt: 'design a 50mm mounting bracket with 4 M5 holes on 40mm PCD'
107
+ });
108
+ ```
109
+
110
+ Monitor state:
111
+
112
+ ```js
113
+ setInterval(() => {
114
+ const s = window.CycleCAD.AICopilot.getState();
115
+ console.log(`${s.running ? 'running' : 'idle'} — ${s.results} ok / ${s.errors} errs`);
116
+ }, 500);
117
+ ```
118
+
119
+ Stop mid-run:
120
+
121
+ ```js
122
+ window.CycleCAD.AICopilot.abort();
123
+ ```
124
+
125
+ ## Step 7 — Tune your prompts
126
+
127
+ | Prompt quality | Example |
128
+ |---|---|
129
+ | **Weak**: *"make a case"* | Claude guesses dimensions |
130
+ | **Better**: *"make a 100×60×25 case with 2mm wall"* | Claude knows the envelope |
131
+ | **Best**: *"make a 100×60×25 case. 2mm wall. 4 M3 mounting holes at (5,5), (95,5), (5,55), (95,55). 3mm fillet on outer edges. One USB-C cutout (9×4) on the front face centered horizontally, 10mm up from the base."* | Claude gets it right in one shot |
132
+
133
+ **Rule of thumb**: mention every dimension you care about. Claude is good at filling gaps with sensible defaults, but precise inputs produce precise parts.
134
+
135
+ ## Step 8 — What's next
136
+
137
+ - Read `docs/AI-COPILOT.md` for the full feature reference
138
+ - Check `docs/API-REFERENCE.md` for the underlying Agent API the copilot drives
139
+ - Try composing prompts: generate a base plate → save → regenerate with additional features
140
+
141
+ ## Troubleshooting
142
+
143
+ | Symptom | Fix |
144
+ |---|---|
145
+ | `Missing anthropic key — click 🔑` | Set the key for the selected provider |
146
+ | `Claude 401` | Key is invalid or expired. Regenerate at console.anthropic.com |
147
+ | `Gemini 429` | Rate-limited on free tier. Wait 60s or switch to Groq |
148
+ | Plan executes but nothing appears in viewport | Agent API may not be loaded. Check console for `window.cycleCAD.execute is not available` |
149
+ | Model returns prose instead of JSON | Rerun — sometimes models forget the format. The parser tries to be lenient but fails on pure prose. |
150
+ | Recovery loop doesn't fire | You're already at the 2-retry limit or the recovery call itself failed. Check log for `Recovery failed: ...` |