yummyfiles commited on
Commit
e8fb2cf
·
verified ·
1 Parent(s): f95f1e4

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. app.js +137 -106
  2. index.html +184 -58
app.js CHANGED
@@ -6,26 +6,127 @@ const inputEl = document.getElementById('input');
6
  const chatEl = document.getElementById('chat');
7
  const executeBtn = document.getElementById('execute');
8
  const statusEl = document.getElementById('status');
 
 
 
 
 
 
9
 
10
  let generator = null;
11
  let isLoading = false;
 
 
12
 
13
  const customTools = {
14
  get_crypto_price: async (symbol) => {
15
  try {
16
  const response = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${symbol.toLowerCase()}&vs_currencies=usd`);
17
  const data = await response.json();
18
- if (data[symbol.toLowerCase()]) {
19
- return `$${data[symbol.toLowerCase()].usd.toLocaleString()}`;
20
- }
21
  return `Price unavailable for ${symbol}`;
22
- } catch {
23
- return `Error fetching price for ${symbol}`;
24
- }
25
  },
26
  multiply_numbers: (a, b) => a * b
27
  };
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  async function initModel() {
30
  if (generator) return generator;
31
  isLoading = true;
@@ -47,14 +148,8 @@ async function initModel() {
47
  }
48
  });
49
  statusEl.textContent = 'Ready. Model loaded locally.';
50
- } catch (err) {
51
- statusEl.textContent = `Error: ${err.message}`;
52
- console.error(err);
53
- } finally {
54
- isLoading = false;
55
- executeBtn.disabled = false;
56
- executeBtn.textContent = 'SEND';
57
- }
58
  return generator;
59
  }
60
 
@@ -70,20 +165,10 @@ Available tools:
70
  Only call tools when needed. Respond normally for regular questions.
71
  <|eot_id|>`;
72
 
73
- for (const msg of history) {
74
- if (msg.role === 'user') {
75
- prompt += `<|start_header_id|>user<|end_header_id|>
76
- ${msg.content}<|eot_id|>`;
77
- } else if (msg.role === 'assistant') {
78
- prompt += `<|start_header_id|>assistant<|end_header_id|>
79
- ${msg.content}<|eot_id|>`;
80
- }
81
  }
82
-
83
- prompt += `<|start_header_id|>user<|end_header_id|>
84
- ${userInput}<|eot_id|>
85
- <|start_header_id|>assistant<|end_header_id|>
86
- `;
87
  return prompt;
88
  }
89
 
@@ -91,56 +176,21 @@ async function executeToolCalls(text) {
91
  const toolCallRegex = /<call_tool>(\w+)\((\{.*?\})\)<\/call_tool>/g;
92
  let result = text;
93
  let match;
94
-
95
  while ((match = toolCallRegex.exec(text)) !== null) {
96
  const funcName = match[1];
97
  let args;
98
- try {
99
- args = JSON.parse(match[2]);
100
- } catch {
101
- continue;
102
- }
103
-
104
  if (customTools[funcName]) {
105
  try {
106
  const toolResult = await customTools[funcName](...Object.values(args));
107
- const resultStr = String(toolResult);
108
- result = result.replace(match[0], `[TOOL RESULT: ${funcName} => ${resultStr}]`);
109
- } catch (err) {
110
- result = result.replace(match[0], `[TOOL ERROR: ${err.message}]`);
111
- }
112
  }
113
  }
114
-
115
  return result;
116
  }
117
 
118
- function appendMessage(role, content) {
119
- const wrapper = document.createElement('div');
120
- wrapper.className = `message ${role}`;
121
-
122
- const label = document.createElement('div');
123
- label.className = 'message-label';
124
- label.textContent = role === 'user' ? 'YOU' : 'BOB';
125
- wrapper.appendChild(label);
126
-
127
- const contentDiv = document.createElement('div');
128
- contentDiv.className = 'message-content';
129
- contentDiv.textContent = content;
130
- wrapper.appendChild(contentDiv);
131
-
132
- chatEl.appendChild(wrapper);
133
- chatEl.scrollTop = chatEl.scrollHeight;
134
- }
135
-
136
- function appendToolResult(toolName, result) {
137
- const div = document.createElement('div');
138
- div.className = 'message tool-result';
139
- div.textContent = `[TOOL: ${toolName} => ${result}]`;
140
- chatEl.appendChild(div);
141
- chatEl.scrollTop = chatEl.scrollHeight;
142
- }
143
-
144
  async function runInference(prompt, history) {
145
  if (!generator) await initModel();
146
  if (!generator) return 'Model not loaded.';
@@ -150,16 +200,10 @@ async function runInference(prompt, history) {
150
 
151
  try {
152
  const output = await generator(formatted, {
153
- max_new_tokens: 256,
154
- temperature: 0.1,
155
- top_p: 0.9,
156
- do_sample: true,
157
- return_full_text: false,
158
- repetition_penalty: 1.2
159
  });
160
  let generated = output[0].generated_text.trim();
161
-
162
- // Clean artifacts
163
  generated = generated
164
  .replace(/^.*?<\|assistant\|>\s*/s, '')
165
  .replace(/^.*?<\|user\|>\s*/s, '')
@@ -168,68 +212,55 @@ async function runInference(prompt, history) {
168
  .replace(/Available tools:[\s\S]*?Only call tools[\s\S]*?\n/, '')
169
  .trim();
170
 
171
- // Execute tools
172
  const toolCallRegex = /<call_tool>(\w+)\((\{.*?\})\)<\/call_tool>/g;
173
  let match;
174
  while ((match = toolCallRegex.exec(generated)) !== null) {
175
  const funcName = match[1];
176
  let args;
177
- try {
178
- args = JSON.parse(match[2]);
179
- } catch {
180
- continue;
181
- }
182
  if (customTools[funcName]) {
183
  try {
184
  const toolResult = await customTools[funcName](...Object.values(args));
185
  generated = generated.replace(match[0], `[TOOL RESULT: ${funcName} => ${toolResult}]`);
186
- appendToolResult(funcName, toolResult);
187
- } catch (err) {
188
- generated = generated.replace(match[0], `[TOOL ERROR: ${err.message}]`);
189
- }
190
  }
191
  }
192
-
193
  return generated.trim();
194
- } catch (err) {
195
- console.error(err);
196
- return `Error: ${err.message}`;
197
- }
198
  }
199
 
200
- executeBtn.addEventListener('click', async () => {
201
  if (isLoading) return;
202
  const prompt = inputEl.value.trim();
203
  if (!prompt) return;
204
 
 
205
  appendMessage('user', prompt);
206
  inputEl.value = '';
207
 
208
  executeBtn.disabled = true;
209
  executeBtn.textContent = 'THINKING...';
210
 
211
- // Build history from chat (last 6 messages)
212
- const messages = chatEl.querySelectorAll('.message:not(.tool-result)');
213
- const history = [];
214
- for (let i = Math.max(0, messages.length - 6); i < messages.length; i++) {
215
- const msg = messages[i];
216
- const role = msg.classList.contains('user') ? 'user' : 'assistant';
217
- const content = msg.querySelector('.message-content').textContent;
218
- history.push({ role, content });
219
- }
220
-
221
  const result = await runInference(prompt, history);
222
  appendMessage('assistant', result);
223
 
224
  executeBtn.disabled = false;
225
  executeBtn.textContent = 'SEND';
226
  statusEl.textContent = 'Ready.';
227
- });
228
 
229
- inputEl.addEventListener('keydown', (e) => {
230
- if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
231
- executeBtn.click();
232
- }
233
- });
234
 
 
 
 
 
 
 
 
 
235
  initModel();
 
6
  const chatEl = document.getElementById('chat');
7
  const executeBtn = document.getElementById('execute');
8
  const statusEl = document.getElementById('status');
9
+ const chatListEl = document.getElementById('chat-list');
10
+ const newChatBtn = document.getElementById('new-chat-btn');
11
+ const chatTitleEl = document.getElementById('chat-title');
12
+ const sidebar = document.getElementById('sidebar');
13
+ const sidebarOverlay = document.getElementById('sidebar-overlay');
14
+ const menuBtn = document.getElementById('menu-btn');
15
 
16
  let generator = null;
17
  let isLoading = false;
18
+ let currentChatId = null;
19
+ let chats = JSON.parse(localStorage.getItem('bob_chats') || '{}');
20
 
21
  const customTools = {
22
  get_crypto_price: async (symbol) => {
23
  try {
24
  const response = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${symbol.toLowerCase()}&vs_currencies=usd`);
25
  const data = await response.json();
26
+ if (data[symbol.toLowerCase()]) return `$${data[symbol.toLowerCase()].usd.toLocaleString()}`;
 
 
27
  return `Price unavailable for ${symbol}`;
28
+ } catch { return `Error fetching price for ${symbol}`; }
 
 
29
  },
30
  multiply_numbers: (a, b) => a * b
31
  };
32
 
33
+ function saveChats() { localStorage.setItem('bob_chats', JSON.stringify(chats)); }
34
+
35
+ function createChat() {
36
+ const id = Date.now().toString();
37
+ chats[id] = { id, title: 'New Chat', messages: [], created: Date.now(), updated: Date.now() };
38
+ saveChats();
39
+ return id;
40
+ }
41
+
42
+ function deleteChat(id) { delete chats[id]; saveChats(); renderChatList(); }
43
+
44
+ function switchChat(id) {
45
+ currentChatId = id;
46
+ renderChatList();
47
+ renderChat();
48
+ sidebar.classList.remove('open');
49
+ sidebarOverlay.classList.remove('open');
50
+ }
51
+
52
+ function updateChatTitle(id, title) {
53
+ if (chats[id]) { chats[id].title = title; chats[id].updated = Date.now(); saveChats(); renderChatList(); }
54
+ }
55
+
56
+ function renderChatList() {
57
+ const sorted = Object.values(chats).sort((a, b) => b.updated - a.updated);
58
+ if (sorted.length === 0) {
59
+ chatListEl.innerHTML = '<div class="empty-chats">No chats yet.<br>Click "NEW CHAT" to start.</div>';
60
+ return;
61
+ }
62
+ chatListEl.innerHTML = sorted.map(c => `
63
+ <div class="chat-item ${c.id === currentChatId ? 'active' : ''}" data-id="${c.id}">
64
+ <div class="chat-item-title">${escapeHtml(c.title)}</div>
65
+ <div class="chat-item-preview">${escapeHtml(c.messages[0]?.content?.slice(0, 40) || '')}</div>
66
+ <div class="chat-item-time">${new Date(c.updated).toLocaleString()}</div>
67
+ </div>
68
+ `).join('');
69
+ chatListEl.querySelectorAll('.chat-item').forEach(el => {
70
+ el.addEventListener('click', () => switchChat(el.dataset.id));
71
+ });
72
+ }
73
+
74
+ function escapeHtml(s) { return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
75
+
76
+ function renderChat() {
77
+ if (!currentChatId || !chats[currentChatId]) { showWelcome(); return; }
78
+ const chat = chats[currentChatId];
79
+ chatTitleEl.textContent = chat.title;
80
+ chatEl.innerHTML = '';
81
+ if (chat.messages.length === 0) { showWelcome(); return; }
82
+ chat.messages.forEach(msg => appendMessage(msg.role, msg.content, false));
83
+ chatEl.scrollTop = chatEl.scrollHeight;
84
+ }
85
+
86
+ function showWelcome() {
87
+ chatTitleEl.textContent = currentChatId ? chats[currentChatId]?.title || 'New Chat' : 'Select a chat or start new';
88
+ chatEl.innerHTML = `
89
+ <div class="welcome">
90
+ <h2>BOB</h2>
91
+ <p>Local-first AI running 100% in your browser via WebAssembly.<br>No servers. No cloud. No tracking.</p>
92
+ <div class="hint">Ctrl+Enter to send · Try "What's Bitcoin price?" or "Multiply 144 by 37"</div>
93
+ </div>
94
+ `;
95
+ }
96
+
97
+ function appendMessage(role, content, save = true) {
98
+ if (chatEl.querySelector('.welcome')) chatEl.innerHTML = '';
99
+ const wrapper = document.createElement('div');
100
+ wrapper.className = `message ${role}`;
101
+ const label = document.createElement('div');
102
+ label.className = 'message-label';
103
+ label.textContent = role === 'user' ? 'YOU' : 'BOB';
104
+ wrapper.appendChild(label);
105
+ const contentDiv = document.createElement('div');
106
+ contentDiv.className = 'message-content';
107
+ contentDiv.textContent = content;
108
+ wrapper.appendChild(contentDiv);
109
+ chatEl.appendChild(wrapper);
110
+ chatEl.scrollTop = chatEl.scrollHeight;
111
+ if (save && currentChatId && chats[currentChatId]) {
112
+ chats[currentChatId].messages.push({ role, content, time: Date.now() });
113
+ chats[currentChatId].updated = Date.now();
114
+ if (chats[currentChatId].messages.length === 1) {
115
+ chats[currentChatId].title = content.slice(0, 40);
116
+ }
117
+ saveChats();
118
+ renderChatList();
119
+ }
120
+ }
121
+
122
+ function appendToolResult(toolName, result) {
123
+ const div = document.createElement('div');
124
+ div.className = 'message tool-result';
125
+ div.textContent = `[TOOL: ${toolName} => ${result}]`;
126
+ chatEl.appendChild(div);
127
+ chatEl.scrollTop = chatEl.scrollHeight;
128
+ }
129
+
130
  async function initModel() {
131
  if (generator) return generator;
132
  isLoading = true;
 
148
  }
149
  });
150
  statusEl.textContent = 'Ready. Model loaded locally.';
151
+ } catch (err) { statusEl.textContent = `Error: ${err.message}`; console.error(err); }
152
+ finally { isLoading = false; executeBtn.disabled = false; executeBtn.textContent = 'SEND'; }
 
 
 
 
 
 
153
  return generator;
154
  }
155
 
 
165
  Only call tools when needed. Respond normally for regular questions.
166
  <|eot_id|>`;
167
 
168
+ for (const msg of history.slice(-6)) {
169
+ prompt += `<|start_header_id|>${msg.role}<|end_header_id|>\n${msg.content}<|eot_id|>`;
 
 
 
 
 
 
170
  }
171
+ prompt += `<|start_header_id|>user<|end_header_id|>\n${userInput}<|eot_id|>\n<|start_header_id|>assistant<|end_header_id|>\n`;
 
 
 
 
172
  return prompt;
173
  }
174
 
 
176
  const toolCallRegex = /<call_tool>(\w+)\((\{.*?\})\)<\/call_tool>/g;
177
  let result = text;
178
  let match;
 
179
  while ((match = toolCallRegex.exec(text)) !== null) {
180
  const funcName = match[1];
181
  let args;
182
+ try { args = JSON.parse(match[2]); } catch { continue; }
 
 
 
 
 
183
  if (customTools[funcName]) {
184
  try {
185
  const toolResult = await customTools[funcName](...Object.values(args));
186
+ result = result.replace(match[0], `[TOOL RESULT: ${funcName} => ${toolResult}]`);
187
+ appendToolResult(funcName, toolResult);
188
+ } catch (err) { result = result.replace(match[0], `[TOOL ERROR: ${err.message}]`); }
 
 
189
  }
190
  }
 
191
  return result;
192
  }
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  async function runInference(prompt, history) {
195
  if (!generator) await initModel();
196
  if (!generator) return 'Model not loaded.';
 
200
 
201
  try {
202
  const output = await generator(formatted, {
203
+ max_new_tokens: 256, temperature: 0.1, top_p: 0.9,
204
+ do_sample: true, return_full_text: false, repetition_penalty: 1.2
 
 
 
 
205
  });
206
  let generated = output[0].generated_text.trim();
 
 
207
  generated = generated
208
  .replace(/^.*?<\|assistant\|>\s*/s, '')
209
  .replace(/^.*?<\|user\|>\s*/s, '')
 
212
  .replace(/Available tools:[\s\S]*?Only call tools[\s\S]*?\n/, '')
213
  .trim();
214
 
 
215
  const toolCallRegex = /<call_tool>(\w+)\((\{.*?\})\)<\/call_tool>/g;
216
  let match;
217
  while ((match = toolCallRegex.exec(generated)) !== null) {
218
  const funcName = match[1];
219
  let args;
220
+ try { args = JSON.parse(match[2]); } catch { continue; }
 
 
 
 
221
  if (customTools[funcName]) {
222
  try {
223
  const toolResult = await customTools[funcName](...Object.values(args));
224
  generated = generated.replace(match[0], `[TOOL RESULT: ${funcName} => ${toolResult}]`);
225
+ } catch (err) { generated = generated.replace(match[0], `[TOOL ERROR: ${err.message}]`); }
 
 
 
226
  }
227
  }
 
228
  return generated.trim();
229
+ } catch (err) { console.error(err); return `Error: ${err.message}`; }
 
 
 
230
  }
231
 
232
+ async function handleSend() {
233
  if (isLoading) return;
234
  const prompt = inputEl.value.trim();
235
  if (!prompt) return;
236
 
237
+ if (!currentChatId) currentChatId = createChat();
238
  appendMessage('user', prompt);
239
  inputEl.value = '';
240
 
241
  executeBtn.disabled = true;
242
  executeBtn.textContent = 'THINKING...';
243
 
244
+ const history = chats[currentChatId]?.messages.slice(-6) || [];
 
 
 
 
 
 
 
 
 
245
  const result = await runInference(prompt, history);
246
  appendMessage('assistant', result);
247
 
248
  executeBtn.disabled = false;
249
  executeBtn.textContent = 'SEND';
250
  statusEl.textContent = 'Ready.';
251
+ }
252
 
253
+ executeBtn.addEventListener('click', handleSend);
254
+ inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSend(); });
255
+
256
+ newChatBtn.addEventListener('click', () => { currentChatId = createChat(); switchChat(currentChatId); inputEl.focus(); });
 
257
 
258
+ menuBtn.addEventListener('click', () => { sidebar.classList.toggle('open'); sidebarOverlay.classList.toggle('open'); });
259
+ sidebarOverlay.addEventListener('click', () => { sidebar.classList.remove('open'); sidebarOverlay.classList.remove('open'); });
260
+
261
+ renderChatList();
262
+ if (Object.keys(chats).length > 0) {
263
+ const latest = Object.values(chats).sort((a,b)=>b.updated-a.updated)[0];
264
+ switchChat(latest.id);
265
+ }
266
  initModel();
index.html CHANGED
@@ -13,58 +13,151 @@
13
  font-family: monospace, monospace;
14
  font-size: 14px;
15
  line-height: 1.6;
 
16
  }
17
  body {
18
  display: flex;
19
  flex-direction: column;
20
- align-items: center;
21
- padding: 1.5rem;
22
- gap: 1rem;
23
  min-height: 100vh;
24
  }
25
  header {
26
- text-align: center;
27
- width: 100%;
28
- max-width: 720px;
 
29
  border-bottom: 1px solid #ffffff;
30
- padding-bottom: 1rem;
31
  }
32
  header h1 {
33
- font-size: 1.75rem;
34
  font-weight: 700;
35
- letter-spacing: 0.2em;
36
- margin-bottom: 0.5rem;
37
  }
38
- header p {
39
- font-size: 0.75rem;
 
 
 
 
 
40
  letter-spacing: 0.1em;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  opacity: 0.7;
42
- max-width: 600px;
43
- margin: 0 auto;
44
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  main {
46
- width: 100%;
47
- max-width: 720px;
48
  display: flex;
49
  flex-direction: column;
50
- gap: 1rem;
51
- flex: 1;
 
 
 
 
 
 
 
 
 
 
 
 
52
  }
53
  #chat {
54
  flex: 1;
55
- min-height: 300px;
56
- max-height: 50vh;
57
  overflow-y: auto;
58
- border: 1px solid #ffffff;
59
- padding: 1rem;
60
  display: flex;
61
  flex-direction: column;
 
 
 
 
 
 
 
 
 
 
62
  gap: 1rem;
63
  }
 
 
 
64
  .message {
65
  display: flex;
66
  flex-direction: column;
67
- gap: 0.25rem;
68
  max-width: 85%;
69
  animation: fadeIn 0.2s ease;
70
  }
@@ -72,11 +165,10 @@
72
  .message.user { align-self: flex-end; }
73
  .message.bot { align-self: flex-start; }
74
  .message-label {
75
- font-size: 0.65rem;
76
  letter-spacing: 0.15em;
77
  text-transform: uppercase;
78
  opacity: 0.5;
79
- margin-bottom: 0.25rem;
80
  }
81
  .message.user .message-label { text-align: right; }
82
  .message-content {
@@ -86,22 +178,23 @@
86
  border: 1px solid #ffffff;
87
  background: #000000;
88
  }
89
- .message.user .message-content { border-color: #ffffff; }
90
- .message.bot .message-content { border-color: #ffffff; }
91
  .message.tool-result {
92
- font-size: 0.75rem;
93
- opacity: 0.7;
94
  font-family: monospace;
95
  background: #000000;
96
  border: 1px dashed #ffffff;
97
- padding: 0.5rem;
98
- margin-top: 0.5rem;
 
 
99
  }
 
100
  .input-area {
101
- display: flex;
102
- flex-direction: column;
103
- gap: 0.75rem;
104
- width: 100%;
105
  }
106
  .input-row {
107
  display: flex;
@@ -118,6 +211,7 @@
118
  min-height: 56px;
119
  resize: none;
120
  outline: none;
 
121
  }
122
  #input:focus { border-color: #ffffff; box-shadow: 0 0 0 1px #ffffff; }
123
  #input::placeholder { color: #ffffff60; }
@@ -126,7 +220,7 @@
126
  border: 1px solid #ffffff;
127
  color: #ffffff;
128
  font-family: inherit;
129
- font-size: 0.75rem;
130
  font-weight: 700;
131
  letter-spacing: 0.15em;
132
  text-transform: uppercase;
@@ -134,31 +228,42 @@
134
  cursor: pointer;
135
  transition: background 0.15s, color 0.15s;
136
  white-space: nowrap;
 
137
  }
138
- #execute:hover:not(:disabled), #execute:focus:not(:disabled) {
139
- background: #ffffff;
140
- color: #000000;
141
- outline: none;
142
- }
143
  #execute:disabled { opacity: 0.4; cursor: not-allowed; }
144
  .status {
145
- font-size: 0.65rem;
146
  letter-spacing: 0.1em;
147
  text-transform: uppercase;
148
  opacity: 0.5;
149
  text-align: center;
150
  min-height: 1rem;
 
151
  }
152
  footer {
153
- font-size: 0.6rem;
 
154
  letter-spacing: 0.1em;
155
- opacity: 0.4;
156
  text-align: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  }
158
  @media (max-width: 600px) {
159
- body { padding: 1rem; gap: 1rem; }
160
- header h1 { font-size: 1.25rem; }
161
- header p { font-size: 0.7rem; }
162
  .input-row { flex-direction: column; }
163
  #execute { width: 100%; }
164
  .message { max-width: 100%; }
@@ -167,19 +272,40 @@
167
  </head>
168
  <body>
169
  <header>
170
- <h1>PROJECT: BOB</h1>
171
- <p>Local-first AI running 100% in your browser via WebAssembly. No servers. No cloud. No tracking.</p>
 
 
 
172
  </header>
173
- <main>
174
- <div id="chat"></div>
175
- <div class="input-area">
176
- <div class="input-row">
177
- <textarea id="input" placeholder="Type your message... (Ctrl+Enter to send)"></textarea>
178
- <button id="execute" type="button">SEND</button>
179
  </div>
180
- <div class="status" id="status"></div>
181
- </div>
182
- </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  <footer>PROJECT BOB — LOCAL-FIRST AI</footer>
184
  <script type="module" src="app.js"></script>
185
  </body>
 
13
  font-family: monospace, monospace;
14
  font-size: 14px;
15
  line-height: 1.6;
16
+ overflow: hidden;
17
  }
18
  body {
19
  display: flex;
20
  flex-direction: column;
 
 
 
21
  min-height: 100vh;
22
  }
23
  header {
24
+ display: flex;
25
+ justify-content: space-between;
26
+ align-items: center;
27
+ padding: 0.75rem 1.5rem;
28
  border-bottom: 1px solid #ffffff;
29
+ flex-shrink: 0;
30
  }
31
  header h1 {
32
+ font-size: 1.25rem;
33
  font-weight: 700;
34
+ letter-spacing: 0.15em;
 
35
  }
36
+ #new-chat-btn {
37
+ background: #000000;
38
+ border: 1px solid #ffffff;
39
+ color: #ffffff;
40
+ font-family: inherit;
41
+ font-size: 0.65rem;
42
+ font-weight: 700;
43
  letter-spacing: 0.1em;
44
+ text-transform: uppercase;
45
+ padding: 0.5rem 1rem;
46
+ cursor: pointer;
47
+ transition: background 0.15s, color 0.15s;
48
+ }
49
+ #new-chat-btn:hover { background: #ffffff; color: #000000; }
50
+ .app-container {
51
+ display: flex;
52
+ flex: 1;
53
+ overflow: hidden;
54
+ }
55
+ /* Sidebar */
56
+ aside {
57
+ width: 280px;
58
+ flex-shrink: 0;
59
+ border-right: 1px solid #ffffff;
60
+ display: flex;
61
+ flex-direction: column;
62
+ background: #000000;
63
+ }
64
+ .sidebar-header {
65
+ padding: 1rem;
66
+ border-bottom: 1px solid #ffffff;
67
+ font-size: 0.65rem;
68
+ letter-spacing: 0.15em;
69
+ text-transform: uppercase;
70
  opacity: 0.7;
 
 
71
  }
72
+ .chat-list {
73
+ flex: 1;
74
+ overflow-y: auto;
75
+ padding: 0.5rem;
76
+ }
77
+ .chat-item {
78
+ padding: 0.75rem 1rem;
79
+ margin-bottom: 0.5rem;
80
+ border: 1px solid transparent;
81
+ cursor: pointer;
82
+ transition: border-color 0.15s, background 0.15s;
83
+ white-space: nowrap;
84
+ overflow: hidden;
85
+ text-overflow: ellipsis;
86
+ }
87
+ .chat-item:hover { border-color: #ffffff; background: #111111; }
88
+ .chat-item.active { border-color: #ffffff; background: #1a1a1a; }
89
+ .chat-item-title {
90
+ font-size: 0.75rem;
91
+ font-weight: 700;
92
+ letter-spacing: 0.05em;
93
+ margin-bottom: 0.25rem;
94
+ white-space: nowrap;
95
+ overflow: hidden;
96
+ text-overflow: ellipsis;
97
+ }
98
+ .chat-item-preview {
99
+ font-size: 0.65rem;
100
+ opacity: 0.5;
101
+ white-space: nowrap;
102
+ overflow: hidden;
103
+ text-overflow: ellipsis;
104
+ }
105
+ .chat-item-time {
106
+ font-size: 0.55rem;
107
+ opacity: 0.4;
108
+ margin-top: 0.25rem;
109
+ }
110
+ .empty-chats {
111
+ padding: 2rem 1rem;
112
+ text-align: center;
113
+ opacity: 0.4;
114
+ font-size: 0.75rem;
115
+ }
116
+ /* Main chat area */
117
  main {
118
+ flex: 1;
 
119
  display: flex;
120
  flex-direction: column;
121
+ min-width: 0;
122
+ background: #000000;
123
+ }
124
+ .chat-header {
125
+ padding: 1rem 1.5rem;
126
+ border-bottom: 1px solid #ffffff;
127
+ flex-shrink: 0;
128
+ }
129
+ .chat-header-title {
130
+ font-size: 0.75rem;
131
+ font-weight: 700;
132
+ letter-spacing: 0.1em;
133
+ text-transform: uppercase;
134
+ opacity: 0.7;
135
  }
136
  #chat {
137
  flex: 1;
 
 
138
  overflow-y: auto;
139
+ padding: 1.5rem;
 
140
  display: flex;
141
  flex-direction: column;
142
+ gap: 1.25rem;
143
+ }
144
+ .welcome {
145
+ flex: 1;
146
+ display: flex;
147
+ flex-direction: column;
148
+ align-items: center;
149
+ justify-content: center;
150
+ text-align: center;
151
+ opacity: 0.5;
152
  gap: 1rem;
153
  }
154
+ .welcome h2 { font-size: 1.5rem; font-weight: 700; letter-spacing: 0.1em; }
155
+ .welcome p { font-size: 0.85rem; max-width: 400px; line-height: 1.7; }
156
+ .welcome .hint { font-size: 0.65rem; opacity: 0.4; margin-top: 1rem; letter-spacing: 0.1em; }
157
  .message {
158
  display: flex;
159
  flex-direction: column;
160
+ gap: 0.35rem;
161
  max-width: 85%;
162
  animation: fadeIn 0.2s ease;
163
  }
 
165
  .message.user { align-self: flex-end; }
166
  .message.bot { align-self: flex-start; }
167
  .message-label {
168
+ font-size: 0.6rem;
169
  letter-spacing: 0.15em;
170
  text-transform: uppercase;
171
  opacity: 0.5;
 
172
  }
173
  .message.user .message-label { text-align: right; }
174
  .message-content {
 
178
  border: 1px solid #ffffff;
179
  background: #000000;
180
  }
 
 
181
  .message.tool-result {
182
+ font-size: 0.7rem;
183
+ opacity: 0.6;
184
  font-family: monospace;
185
  background: #000000;
186
  border: 1px dashed #ffffff;
187
+ padding: 0.5rem 0.75rem;
188
+ max-width: 100%;
189
+ align-self: flex-start;
190
+ margin-left: 0;
191
  }
192
+ /* Input area */
193
  .input-area {
194
+ padding: 1rem 1.5rem;
195
+ border-top: 1px solid #ffffff;
196
+ flex-shrink: 0;
197
+ background: #000000;
198
  }
199
  .input-row {
200
  display: flex;
 
211
  min-height: 56px;
212
  resize: none;
213
  outline: none;
214
+ line-height: 1.5;
215
  }
216
  #input:focus { border-color: #ffffff; box-shadow: 0 0 0 1px #ffffff; }
217
  #input::placeholder { color: #ffffff60; }
 
220
  border: 1px solid #ffffff;
221
  color: #ffffff;
222
  font-family: inherit;
223
+ font-size: 0.7rem;
224
  font-weight: 700;
225
  letter-spacing: 0.15em;
226
  text-transform: uppercase;
 
228
  cursor: pointer;
229
  transition: background 0.15s, color 0.15s;
230
  white-space: nowrap;
231
+ align-self: flex-end;
232
  }
233
+ #execute:hover:not(:disabled), #execute:focus:not(:disabled) { background: #ffffff; color: #000000; outline: none; }
 
 
 
 
234
  #execute:disabled { opacity: 0.4; cursor: not-allowed; }
235
  .status {
236
+ font-size: 0.6rem;
237
  letter-spacing: 0.1em;
238
  text-transform: uppercase;
239
  opacity: 0.5;
240
  text-align: center;
241
  min-height: 1rem;
242
+ margin-top: 0.5rem;
243
  }
244
  footer {
245
+ padding: 0.5rem;
246
+ font-size: 0.55rem;
247
  letter-spacing: 0.1em;
248
+ opacity: 0.3;
249
  text-align: center;
250
+ border-top: 1px solid #ffffff;
251
+ flex-shrink: 0;
252
+ }
253
+ /* Mobile */
254
+ @media (max-width: 900px) {
255
+ aside { position: fixed; left: 0; top: 0; bottom: 0; z-index: 100; transform: translateX(-100%); transition: transform 0.2s ease; box-shadow: 2px 0 8px rgba(255,255,255,0.1); }
256
+ aside.open { transform: translateX(0); }
257
+ .sidebar-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.8); z-index: 99; }
258
+ .sidebar-overlay.open { display: block; }
259
+ header { padding: 0.75rem 1rem; }
260
+ main { padding-left: 0; }
261
+ #menu-btn { display: block; background: none; border: 1px solid #ffffff; color: #ffffff; padding: 0.4rem 0.75rem; font-family: inherit; font-size: 0.6rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; cursor: pointer; }
262
+ #menu-btn:hover { background: #ffffff; color: #000000; }
263
+ .chat-header { padding: 0.75rem 1rem; }
264
+ .input-area { padding: 0.75rem 1rem; }
265
  }
266
  @media (max-width: 600px) {
 
 
 
267
  .input-row { flex-direction: column; }
268
  #execute { width: 100%; }
269
  .message { max-width: 100%; }
 
272
  </head>
273
  <body>
274
  <header>
275
+ <div style="display:flex; align-items:center; gap:0.75rem;">
276
+ <button id="menu-btn" style="display:none;">MENU</button>
277
+ <h1>PROJECT: BOB</h1>
278
+ </div>
279
+ <button id="new-chat-btn" type="button">NEW CHAT</button>
280
  </header>
281
+ <div class="sidebar-overlay" id="sidebar-overlay"></div>
282
+ <div class="app-container">
283
+ <aside id="sidebar">
284
+ <div class="sidebar-header">CHATS</div>
285
+ <div class="chat-list" id="chat-list">
286
+ <div class="empty-chats">No chats yet.<br>Click "NEW CHAT" to start.</div>
287
  </div>
288
+ </aside>
289
+ <main>
290
+ <div class="chat-header">
291
+ <div class="chat-header-title" id="chat-title">Select a chat or start new</div>
292
+ </div>
293
+ <div id="chat">
294
+ <div class="welcome">
295
+ <h2>BOB</h2>
296
+ <p>Local-first AI running 100% in your browser via WebAssembly.<br>No servers. No cloud. No tracking.</p>
297
+ <div class="hint">Ctrl+Enter to send · Try "What's Bitcoin price?" or "Multiply 144 by 37"</div>
298
+ </div>
299
+ </div>
300
+ <div class="input-area">
301
+ <div class="input-row">
302
+ <textarea id="input" placeholder="Type your message... (Ctrl+Enter to send)"></textarea>
303
+ <button id="execute" type="button">SEND</button>
304
+ </div>
305
+ <div class="status" id="status"></div>
306
+ </div>
307
+ </main>
308
+ </div>
309
  <footer>PROJECT BOB — LOCAL-FIRST AI</footer>
310
  <script type="module" src="app.js"></script>
311
  </body>