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.
- package/app/js/fusion-help.json +373 -96
- package/app/js/modules/ai-copilot.js +267 -35
- 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
|
@@ -1,42 +1,274 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
'
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
async function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
function
|
|
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
|
|
41
|
-
|
|
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>
|