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.
- package/app/index.html +9 -0
- package/app/js/fusion-help.json +373 -96
- package/app/js/modules/ai-copilot.js +274 -0
- package/app/tests/ai-copilot-tests.html +230 -0
- package/docs/AI-COPILOT-TUTORIAL.md +150 -0
- package/docs/AI-COPILOT.md +99 -0
- package/docs/API-REFERENCE.md +17 -0
- package/package.json +1 -1
|
@@ -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: ...` |
|