Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,13 @@ bin/case up
CASE_LOCAL=1 CASE_URL=http://127.0.0.1:8787 node web/web-ui/serve.mjs
```

Drive stores thread screenshots under `~/.case/drive/shots` (Compose: the
`ui-data` volume via `CASE_HOME=/data`). `CASE_TURN_TOKENS` (default 2M) caps
one turn's cumulative input tokens. Mid-turn messages go to `/api/chat/steer`.
Drive stores thread screenshots under `~/.case/drive/shots` and chat
attachments under `~/.case/drive/inbox` (Compose: the `ui-data` volume via
`CASE_HOME=/data`). Those files persist after a thread is deleted — remove
the directory or volume if you need them gone. `CASE_TURN_TOKENS` (default 2M)
caps one turn's cumulative input tokens. Mid-turn messages go to
`/api/chat/steer`. Attach files from the plus menu; they stay on the Drive
host and are never copied onto the computer.

### More knobs

Expand Down
11 changes: 8 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@ Case holds logins. These are promises, with code you can read.
`127.0.0.1:4174`. Set `CASE_TOKEN` before exposing those ports.
- **Audit log** (`~/.case/audit/<date>.jsonl`): one line per API call; request
bodies that can carry secrets are redacted; response bodies are never logged.
- **Drive screenshots persist on disk** under `~/.case/drive/shots` (Compose:
`ui-data` via `CASE_HOME=/data`). They are content-addressed and kept until
you delete the files or the volume. Deleting a thread does not erase them.
- **Drive screenshots and chat attachments persist on disk** under
`~/.case/drive/shots` and `~/.case/drive/inbox` (Compose: `ui-data` via
`CASE_HOME=/data`). They are content-addressed and kept until you delete the
files or the volume. Deleting a thread does not erase them. Treat
`~/.case/drive` / `ui-data` as sensitive chat material. Attachments never
copy onto the computer; the model reads them from Drive. Max 4 files per
turn, 5MB each; allowed types are PNG/JPEG/GIF/WebP, PDF, and text (including
JSON, JS, XML).

## Self-host trust model

Expand Down
6 changes: 6 additions & 0 deletions web/web-ui/case-tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,11 @@ function openaiPartToAnthropic(c) {
if (!m) return null;
return { type: 'image', source: { type: 'base64', media_type: m[1], data: m[2] } };
}
if (c?.type === 'input_file') {
const m = /^data:application\/pdf;base64,(.+)$/i.exec(c.file_data || '');
if (!m) return c.filename ? { type: 'text', text: '[' + c.filename + ']' } : null;
return { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: m[1] } };
}
return null;
}

Expand Down Expand Up @@ -425,6 +430,7 @@ export async function anthropicToolLoop({
tools: antTools,
thinking: anthropicThinkingFor(messages),
output_config: { effort: antEffort },
cache_control: { type: 'ephemeral' },
};
let text = '';
let finished = false;
Expand Down
81 changes: 71 additions & 10 deletions web/web-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,8 @@
}
.ask textarea::placeholder{color:var(--faint)}
/* narrow panel: squeeze the textarea, never the SEND button */
.qline{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 8px}
.qline[hidden]{display:none}
.qline,.aline{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 8px}
.qline[hidden],.aline[hidden]{display:none}
.qchip{display:inline-flex;align-items:center;gap:6px;max-width:100%;
font-family:var(--mono);font-size:10.5px;letter-spacing:.05em;color:var(--faint);
border:1px dashed var(--ink);padding:4px 6px 4px 8px}
Expand Down Expand Up @@ -546,9 +546,13 @@
<div class="cfg-pop" id="cfgPop" hidden></div>
</div>
<div class="qline" id="qline" hidden></div>
<div class="aline" id="aline" hidden></div>
<input type="file" id="attachPick" hidden multiple
accept=".png,.jpg,.jpeg,.gif,.webp,.txt,.md,.csv,.json,.html,.htm,.js,.mjs,.css,.py,.sh,.yml,.yaml,.toml,.log,.xml,.pdf">
<div class="ask">
<button type="button" class="plus" id="plusBtn" aria-haspopup="menu" aria-expanded="false" aria-label="More actions">+</button>
<div class="plus-pop" id="plusPop" hidden>
<button type="button" id="attachStart">Attach files<small>FROM THIS COMPUTER · MODEL READS THEM</small></button>
<button type="button" id="teachStart">Teach a task<small>DEMONSTRATE IN THE BROWSER · SAVED AS A SKILL</small></button>
</div>
<textarea id="q" rows="1" placeholder="tell the computer what to do" aria-label="Message"></textarea>
Expand Down Expand Up @@ -1217,27 +1221,53 @@ <h3 id="keyTitle" hidden>OPENAI KEY</h3>
this a second submit 409s server-side and its cleanup hides the STOP button
for the turn still running. */
const promptQ=[];
const asPrompt=p=>typeof p==='string'?{text:p,files:[]}:{text:p&&p.text||'',files:p&&p.files||[]};
const paintQ=()=>{
const ql=$('qline');
ql.hidden=!promptQ.length;
ql.innerHTML='';
promptQ.forEach((p,i)=>{
const item=asPrompt(p);
const chip=document.createElement('span');
chip.className='qchip';
chip.innerHTML='<span class="qtxt"></span><button type="button" class="qx" aria-label="Remove queued prompt">×</button>';
chip.querySelector('.qtxt').textContent=p;
chip.querySelector('.qtxt').textContent=item.text||((item.files[0]&&item.files[0].name)||'queued');
chip.querySelector('.qx').addEventListener('click',()=>{promptQ.splice(i,1);paintQ();});
ql.appendChild(chip);
});
};
const pending=[];
const ATTACH_EXTS='png,jpg,jpeg,gif,webp,txt,md,csv,json,html,htm,js,mjs,css,py,sh,yml,yaml,toml,log,xml,pdf'.split(',');
const paintAline=()=>{
const al=$('aline');
al.hidden=!pending.length;
al.innerHTML='';
pending.forEach((f,i)=>{
const chip=document.createElement('span');
chip.className='qchip';
chip.innerHTML='<span class="qtxt"></span><button type="button" class="qx" aria-label="Remove attachment">×</button>';
chip.querySelector('.qtxt').textContent=f.name;
chip.querySelector('.qx').addEventListener('click',()=>{pending.splice(i,1);paintAline();});
al.appendChild(chip);
});
};
const youLine=(t,files)=>{
const names=(files||[]).map(f=>f.name||f).filter(Boolean);
return [t,...names].filter(Boolean).join('\n');
};
$('go').addEventListener('submit',e=>{
e.preventDefault();
const t=q.value.trim();
if(!t)return;
const files=pending.slice();
if(!t&&!files.length)return;
if(!currentKey()){openKey();return;}
q.value='';q.style.height='auto';
if(chatCtl){steerPrompt(t);return;}
sendPrompt(t);
pending.length=0;paintAline();
if(chatCtl){
if(files.length){promptQ.push({text:t,files});paintQ();return;}
steerPrompt(t);return;
}
sendPrompt(t,files);
});
/* Mid-turn message: try to inject into the running turn; fall back to the
local queue if the turn just ended (409) or the server is unreachable. */
Expand All @@ -1255,13 +1285,16 @@ <h3 id="keyTitle" hidden>OPENAI KEY</h3>
stick(true);
}catch{promptQ.push(t);paintQ();}
}
async function sendPrompt(t){
async function sendPrompt(t,files){
if(t&&typeof t==='object'&&!Array.isArray(t)){files=t.files||[];t=t.text||'';}
files=files||[];
const es=$('emptyState');if(es)es.remove();
/* the task exists the moment you hit enter, born on the computer you are sat at */
if(!activeTid){
const agentId=comp&&comp.id||'';
activeTid='__pending';
threads.unshift({id:'__pending',title:t.replace(/\s+/g,' ').slice(0,72),agent:agentId,updated:Date.now(),pending:true});
const title=(t||((files[0]&&files[0].name)||'file')).replace(/\s+/g,' ').slice(0,72);
threads.unshift({id:'__pending',title,agent:agentId,updated:Date.now(),pending:true});
paint();
const born=$('navlist').querySelector('.thread[data-t="__pending"]');
if(born)born.classList.add('born');
Expand All @@ -1273,7 +1306,7 @@ <h3 id="keyTitle" hidden>OPENAI KEY</h3>
const art=document.createElement('article');
art.className='turn you';
art.innerHTML='<div class="k">YOU</div><p></p>';
art.querySelector('p').textContent=t;
art.querySelector('p').textContent=youLine(t,files);
inner.appendChild(art);
const reply=document.createElement('article');
reply.className='turn';
Expand Down Expand Up @@ -1317,14 +1350,25 @@ <h3 id="keyTitle" hidden>OPENAI KEY</h3>
chatCtl=new AbortController();
$('stopBtn').hidden=false;
try{
const uploaded=[];
for(const f of files){
const ar=await fetch('/api/attach',{
method:'POST',signal:chatCtl.signal,
headers:{'x-filename':f.name,'content-type':f.type||'application/octet-stream'},
body:f
});
const aj=await ar.json().catch(()=>({}));
if(!ar.ok||!aj.id)throw new Error(aj.error||('attach failed: '+f.name));
uploaded.push({id:aj.id,name:aj.name||f.name});
}
const r=await fetch('/api/chat',{
method:'POST',
signal:chatCtl.signal,
headers:{
'content-type':'application/json',
...(provider==='anthropic'?{'x-anthropic-key':anthropicKey}:{'x-openai-key':openaiKey})
},
body:JSON.stringify({model,effort,input:t,computer_id:comp&&comp.id||'',thread_id:activeTid==='__pending'?'':activeTid})
body:JSON.stringify({model,effort,input:t,computer_id:comp&&comp.id||'',thread_id:activeTid==='__pending'?'':activeTid,files:uploaded})
});
const ctype=r.headers.get('content-type')||'';
if(!ctype.includes('ndjson')){
Expand Down Expand Up @@ -1405,6 +1449,23 @@ <h3 id="keyTitle" hidden>OPENAI KEY</h3>
document.addEventListener('click',e=>{
if(!e.target.closest('.ask')){$('plusPop').hidden=true;$('plusBtn').setAttribute('aria-expanded','false');}
});
$('attachStart').addEventListener('click',()=>{
$('plusPop').hidden=true;
$('plusBtn').setAttribute('aria-expanded','false');
$('attachPick').click();
});
$('attachPick').addEventListener('change',()=>{
const pick=$('attachPick');
for(const f of pick.files||[]){
if(pending.length>=4)break;
const ext=(f.name.split('.').pop()||'').toLowerCase();
if(!ATTACH_EXTS.includes(ext)){q.placeholder=f.name+' is not an allowed type';setTimeout(()=>paint([comp].filter(Boolean)),2500);continue;}
if(f.size>5*1024*1024){q.placeholder=f.name+' is over 5 MB';setTimeout(()=>paint([comp].filter(Boolean)),2500);continue;}
pending.push(f);
}
pick.value='';
paintAline();
});
$('teachStart').addEventListener('click',()=>{
$('plusPop').hidden=true;
if(teach.on)return;
Expand Down
Loading