diff --git a/.gitignore b/.gitignore index d635529..45240ce 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,8 @@ Thumbs.db *.keystore keystore.properties secrets.properties + +# Node +node_modules/ +playwright-report/ +test-results/ diff --git a/app/src/main/java/com/sayanthrock/freeairock/ui/chat/ChatScreen.kt b/app/src/main/java/com/sayanthrock/freeairock/ui/chat/ChatScreen.kt index 1d5cf5c..4983604 100644 --- a/app/src/main/java/com/sayanthrock/freeairock/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/sayanthrock/freeairock/ui/chat/ChatScreen.kt @@ -9,11 +9,14 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.ui.text.input.ImeAction import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -60,7 +63,19 @@ fun ChatScreen(viewModel: AppViewModel, modifier: Modifier = Modifier) { value = inputText, onValueChange = { inputText = it }, modifier = Modifier.weight(1f), - placeholder = { Text("Message AI...") } + placeholder = { Text("Message AI...") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Send + ), + keyboardActions = KeyboardActions( + onSend = { + if (inputText.isNotBlank()) { + viewModel.sendMessage(inputText) + inputText = "" + } + } + ) ) Button( onClick = { @@ -69,6 +84,7 @@ fun ChatScreen(viewModel: AppViewModel, modifier: Modifier = Modifier) { inputText = "" } }, + enabled = inputText.isNotBlank(), modifier = Modifier.padding(start = 8.dp) ) { Text("Send") diff --git a/index.html b/index.html index 52e435a..630ac9e 100644 --- a/index.html +++ b/index.html @@ -16,6 +16,6 @@ function history(){let q=$('#search').value.toLowerCase();$('#history').innerHTML='';let list=chats.filter(c=>c.title.toLowerCase().includes(q));if(!list.length){$('#history').innerHTML='

No conversations yet.

';return}list.forEach(c=>{let b=document.createElement('button');b.className='chat '+(c.id===id?'active':'');b.innerHTML=''+esc(c.title)+'×';b.onclick=e=>{if(e.target.classList.contains('del')){chats=chats.filter(x=>x.id!==c.id);if(id===c.id)id=null;save();draw();history();return}id=c.id;draw();history();$('#sidebar').classList.remove('open')};$('#history').append(b)})} function draw(){let c=active();$('#title').textContent=c?.title||'New conversation';let box=$('#messages');box.innerHTML='';if(!c||!c.messages.length){box.innerHTML='
Powerful AI. Simple experience.

Ask better questions.
Build better things.

Connect a provider in Settings, then start a conversation.

';box.querySelectorAll('.suggestion').forEach(b=>b.onclick=()=>{$('#prompt').value=b.textContent;$('#prompt').focus()});return}c.messages.forEach((m,i)=>{let r=document.createElement('article');r.className='message '+m.role;r.innerHTML=(m.role==='assistant'?'
R
':'')+'
'+md(m.content)+'
'+(m.role==='assistant'?'
':'')+'
';box.append(r)});box.scrollTop=box.scrollHeight;box.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>navigator.clipboard.writeText(c.messages[+b.dataset.copy].content).then(()=>toast('Copied')));box.querySelectorAll('[data-retry]').forEach(b=>b.onclick=()=>send(c.messages[+b.dataset.retry-1]?.content||'',true))} function newChat(){id=null;draw();history();$('#prompt').focus()}function ensure(text){if(!id){let c={id:uid(),title:text.slice(0,42)+(text.length>42?'…':''),messages:[],createdAt:Date.now()};chats.unshift(c);id=c.id}return active()} -async function send(text,retry=false){if(controller||!text.trim())return;let c=ensure(text);if(!retry)c.messages.push({role:'user',content:text});else c.messages=c.messages.slice(0,-1);let answer={role:'assistant',content:''};c.messages.push(answer);$('#prompt').value='';draw();history();controller=new AbortController();$('#send').textContent='Stop ■';$('#send').classList.add('stop');status('Generating…');try{if(!cfg.apiKey)throw Error('Add an API key and endpoint in Settings first.');let r=await fetch(cfg.endpoint||'https://api.openai.com/v1/chat/completions',{method:'POST',signal:controller.signal,headers:{'Content-Type':'application/json','Authorization':'Bearer '+cfg.apiKey},body:JSON.stringify({model:$('#model').value,messages:c.messages.filter(m=>m.content).map(m=>({role:m.role,content:m.content})),stream:true})});if(!r.ok)throw Error('Provider returned '+r.status+'. Check your key, model, or endpoint.');let reader=r.body?.getReader(),decoder=new TextDecoder(),buffer='';if(!reader)throw Error('Provider did not return a readable stream.');while(true){let {done,value}=await reader.read();if(done)break;buffer+=decoder.decode(value,{stream:true});let lines=buffer.split('\\n');buffer=lines.pop();for(let line of lines){if(!line.startsWith('data:'))continue;let payload=line.slice(5).trim();if(payload==='[DONE]')break;try{let d=JSON.parse(payload),delta=d.choices?.[0]?.delta?.content||d.candidates?.[0]?.content?.parts?.[0]?.text||'';if(delta){answer.content+=delta;draw()}}catch{}}}status('');}catch(e){if(e.name==='AbortError'){status('Generation stopped')}else{answer.content='Could not generate a response. '+e.message;status('Provider error',true)}}finally{controller=null;$('#send').textContent='Send ↑';$('#send').classList.remove('stop');save();draw();history()}} +async function send(text,retry=false){if(controller||!text.trim())return;let c=ensure(text);if(!retry)c.messages.push({role:'user',content:text});else c.messages=c.messages.slice(0,-1);let answer={role:'assistant',content:''};c.messages.push(answer);$('#prompt').value='';draw();history();controller=new AbortController();$('#send').textContent='Stop ■';$('#send').classList.add('stop');status('Generating…');try{if(!cfg.apiKey)throw Error('Add an API key and endpoint in Settings first.');let r=await fetch(cfg.endpoint||'https://api.openai.com/v1/chat/completions',{method:'POST',signal:controller.signal,headers:{'Content-Type':'application/json','Authorization':'Bearer '+cfg.apiKey},body:JSON.stringify({model:$('#model').value,messages:c.messages.filter(m=>m.content).map(m=>({role:m.role,content:m.content})),stream:true})});if(!r.ok)throw Error('Provider returned '+r.status+'. Check your key, model, or endpoint.');let reader=r.body?.getReader(),decoder=new TextDecoder(),buffer='';if(!reader)throw Error('Provider did not return a readable stream.');while(true){let {done,value}=await reader.read();if(done)break;buffer+=decoder.decode(value,{stream:true});let lines=buffer.split('\n');buffer=lines.pop();for(let line of lines){if(!line.startsWith('data:'))continue;let payload=line.slice(5).trim();if(payload==='[DONE]')break;try{let d=JSON.parse(payload),delta=d.choices?.[0]?.delta?.content||d.candidates?.[0]?.content?.parts?.[0]?.text||'';if(delta){answer.content+=delta;draw()}}catch{}}}status('');}catch(e){if(e.name==='AbortError'){status('Generation stopped')}else{answer.content='Could not generate a response. '+e.message;status('Provider error',true)}}finally{controller=null;$('#send').textContent='Send ↑';$('#send').classList.remove('stop');save();draw();history()}} $('#form').onsubmit=e=>{e.preventDefault();if(controller)controller.abort();else send($('#prompt').value)};$('#prompt').onkeydown=e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();$('#form').requestSubmit()}};$('#new').onclick=newChat;$('#search').oninput=history;$('#open').onclick=()=>$('#sidebar').classList.add('open');$('#close').onclick=()=>$('#sidebar').classList.remove('open');function openSettings(){ $('#endpoint').value=cfg.endpoint;$('#key').value=cfg.apiKey;$('#theme').value=cfg.theme;$('#modal').classList.add('open')}$('#settings').onclick=$('#topSettings').onclick=openSettings;$('#closeSettings').onclick=$('#cancel').onclick=()=>$('#modal').classList.remove('open');$('#saveSettings').onclick=()=>{cfg={endpoint:$('#endpoint').value.trim(),apiKey:$('#key').value.trim(),theme:$('#theme').value};localStorage.setItem(CFG,JSON.stringify(cfg));applyTheme();$('#modal').classList.remove('open');toast('Settings saved locally')};$('#theme').onchange=applyTheme;function applyTheme(){let t=cfg.theme==='system'?(matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'):cfg.theme;document.documentElement.dataset.theme=t}$('#rename').onclick=()=>{let c=active();if(!c)return;let n=prompt('Conversation name',c.title);if(n?.trim()){c.title=n.trim();save();draw();history()}};$('#export').onclick=()=>{let a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(chats,null,2)],{type:'application/json'}));a.download='free-ai-rock-chats.json';a.click()};$('#clear').onclick=()=>{if(confirm('Delete all saved conversations?')){chats=[];id=null;save();draw();history();$('#modal').classList.remove('open')}};applyTheme();history();draw(); diff --git a/package-lock.json b/package-lock.json index 02f9f46..f5c91f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,53 +5,112 @@ "packages": { "": { "name": "free-ai-rock-browser", + "dependencies": { + "@vercel/analytics": "^1.2.2" + }, "devDependencies": { "@playwright/test": "^1.55.0" } }, "node_modules/@playwright/test": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", - "integrity": "sha512-v4bVfF0dY2iU7lT+vG1t7L4q1U8AqzR6bB09/yW0Zk+A7oWvjYl+wXq5V8j6X8r6m7G7tE5M5fU/0H/x+J1Hxg==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.55.0" + "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" + } + }, + "node_modules/@vercel/analytics": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz", + "integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==", + "license": "MPL-2.0", + "peerDependencies": { + "@remix-run/react": "^2", + "@sveltejs/kit": "^1 || ^2", + "next": ">= 13", + "react": "^18 || ^19 || ^19.0.0-rc", + "svelte": ">= 4", + "vue": "^3", + "vue-router": "^4" + }, + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + }, + "vue-router": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/playwright": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", - "integrity": "sha512-y2+7Vn4G1nB9wGf9qFj6w/TfHlB1H8X/Yl2H0M8Qf7+m/b7z/6Z4/x7O/o+Ym/9/g4N4L8O/4q8A8b2Yv2T8Bw==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.55.0" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", - "integrity": "sha512-v6d4G/z+xR4b+p+rP+0Xm3/kS5R4A2i1O/G4k0u7wU/s2/c/3M/Qe2Oq6s/0J8r5mB4M/1g+Q1B4d/8/P9y+Vw==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } } } diff --git a/tests/browser-smoke.spec.js b/tests/browser-smoke.spec.js index d735e18..bf5e7a3 100644 --- a/tests/browser-smoke.spec.js +++ b/tests/browser-smoke.spec.js @@ -57,14 +57,32 @@ test.describe('FREE AI ROCK browser smoke', () => { test('switches dark, light, and system themes', async ({ page }) => { await seedSettings(page); await page.goto(appUrl); + + const isMobile = await page.locator('#open').isVisible(); + const sidebar = page.locator('#sidebar'); + + const ensureSidebarOpen = async () => { + if (isMobile) { + const isOpen = await sidebar.evaluate(el => el.classList.contains('open')); + if (!isOpen) { + await page.locator('#open').click(); + } + } + }; + + await ensureSidebarOpen(); await page.locator('#settings').click(); await page.locator('#theme').selectOption('light'); await page.locator('#saveSettings').click(); await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + + await ensureSidebarOpen(); await page.locator('#settings').click(); await page.locator('#theme').selectOption('dark'); await page.locator('#saveSettings').click(); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + + await ensureSidebarOpen(); await page.locator('#settings').click(); await page.locator('#theme').selectOption('system'); await page.locator('#saveSettings').click();