pal-code 0.1.0__py3-none-any.whl
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.
- pal/__init__.py +29 -0
- pal/__main__.py +3 -0
- pal/access_manager/__init__.py +16 -0
- pal/access_manager/components/__init__.py +14 -0
- pal/access_manager/components/access_control.py +41 -0
- pal/access_manager/components/base.py +39 -0
- pal/access_manager/components/intervention.py +31 -0
- pal/access_manager/components/privilege_registry.py +30 -0
- pal/access_manager/pipeline.py +68 -0
- pal/access_manager/utils/__init__.py +2 -0
- pal/agent_constraints/__init__.py +3 -0
- pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
- pal/agent_constraints/components/__init__.py +3 -0
- pal/agent_constraints/components/constraints.py +22 -0
- pal/agent_constraints/pipeline.py +22 -0
- pal/agent_constraints/utils/__init__.py +2 -0
- pal/agents/__init__.py +12 -0
- pal/agents/coding.py +14 -0
- pal/agents/docs.py +15 -0
- pal/chat/__init__.py +4 -0
- pal/chat/demo.py +231 -0
- pal/chat/providers.py +23 -0
- pal/chat/service.py +591 -0
- pal/chat/services/__init__.py +26 -0
- pal/chat/services/base.py +142 -0
- pal/chat/services/data.py +142 -0
- pal/chat/services/media.py +123 -0
- pal/chat/services/web.py +60 -0
- pal/chat/sessions.py +108 -0
- pal/chat/tools.py +446 -0
- pal/chat/uploads.py +81 -0
- pal/cli.py +502 -0
- pal/compression/__init__.py +12 -0
- pal/compression/components/__init__.py +64 -0
- pal/compression/components/adverbs.py +20 -0
- pal/compression/components/hedges.py +54 -0
- pal/compression/components/interjections.py +20 -0
- pal/compression/components/minifier.py +56 -0
- pal/compression/components/output_compressor.py +13 -0
- pal/compression/components/pleonasms.py +54 -0
- pal/compression/components/punctuations.py +36 -0
- pal/compression/components/stopwords.py +62 -0
- pal/compression/history.py +346 -0
- pal/compression/pipeline.py +58 -0
- pal/compression/utils/__init__.py +30 -0
- pal/compression/utils/negations.py +18 -0
- pal/compression/utils/nltk_setup.py +20 -0
- pal/compression/utils/pronouns.py +27 -0
- pal/compression/utils/proselint_setup.py +9 -0
- pal/compression/utils/spacy_setup.py +22 -0
- pal/config/default.yaml +163 -0
- pal/config.py +504 -0
- pal/context_manager/__init__.py +7 -0
- pal/context_manager/chat_context.py +117 -0
- pal/context_manager/components/__init__.py +5 -0
- pal/context_manager/components/assembler.py +75 -0
- pal/context_manager/components/context_cache.py +45 -0
- pal/context_manager/components/kv_bridge.py +16 -0
- pal/context_manager/components/pager.py +48 -0
- pal/context_manager/components/simple_context_manager.py +56 -0
- pal/context_manager/components/turns.py +88 -0
- pal/context_manager/pipeline.py +27 -0
- pal/context_manager/utils/__init__.py +18 -0
- pal/context_manager/utils/hashing.py +5 -0
- pal/context_manager/utils/relevance.py +59 -0
- pal/context_manager/utils/storage.py +37 -0
- pal/documents/__init__.py +4 -0
- pal/documents/components/__init__.py +0 -0
- pal/documents/components/chunker.py +79 -0
- pal/documents/components/index.py +155 -0
- pal/documents/utils/__init__.py +1 -0
- pal/errors.py +40 -0
- pal/input_converters/__init__.py +8 -0
- pal/input_converters/components/__init__.py +0 -0
- pal/input_converters/components/base.py +38 -0
- pal/input_converters/components/pandoc_converter.py +87 -0
- pal/input_converters/components/pdf_converter.py +46 -0
- pal/input_converters/components/pptx_converter.py +52 -0
- pal/input_converters/components/xlsx_converter.py +38 -0
- pal/input_converters/pipeline.py +72 -0
- pal/input_converters/utils/__init__.py +1 -0
- pal/input_converters/workspace.py +56 -0
- pal/input_savers/INPUT_RULES.md +2 -0
- pal/input_savers/__init__.py +5 -0
- pal/input_savers/components/__init__.py +0 -0
- pal/input_savers/components/base.py +40 -0
- pal/input_savers/components/methods.py +41 -0
- pal/input_savers/components/verifier.py +39 -0
- pal/input_savers/pipeline.py +154 -0
- pal/input_savers/utils/__init__.py +0 -0
- pal/input_savers/utils/guards.py +94 -0
- pal/knowledge_graph/__init__.py +10 -0
- pal/knowledge_graph/components/__init__.py +0 -0
- pal/knowledge_graph/components/base.py +50 -0
- pal/knowledge_graph/components/builder.py +54 -0
- pal/knowledge_graph/components/retriever.py +100 -0
- pal/knowledge_graph/components/workspace.py +116 -0
- pal/knowledge_graph/pipeline.py +215 -0
- pal/knowledge_graph/utils/__init__.py +1 -0
- pal/latency_tracker/__init__.py +3 -0
- pal/latency_tracker/pipeline.py +86 -0
- pal/llm_scheduler/__init__.py +11 -0
- pal/llm_scheduler/api.py +137 -0
- pal/llm_scheduler/code.py +28 -0
- pal/llm_scheduler/core.py +92 -0
- pal/llm_scheduler/local.py +215 -0
- pal/llm_scheduler/pipeline.py +42 -0
- pal/llm_scheduler/types.py +92 -0
- pal/llm_wrapper/__init__.py +34 -0
- pal/llm_wrapper/adapter.py +316 -0
- pal/llm_wrapper/components/__init__.py +23 -0
- pal/llm_wrapper/components/adapter_types.py +90 -0
- pal/llm_wrapper/components/base.py +195 -0
- pal/llm_wrapper/components/features.py +104 -0
- pal/llm_wrapper/components/litellm_wrapper.py +215 -0
- pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
- pal/llm_wrapper/modes.py +73 -0
- pal/llm_wrapper/pipeline.py +61 -0
- pal/llm_wrapper/utils/__init__.py +4 -0
- pal/llm_wrapper/utils/registry.py +49 -0
- pal/llm_wrapper/utils/token_counting.py +10 -0
- pal/local_llm/__init__.py +5 -0
- pal/local_llm/components/__init__.py +0 -0
- pal/local_llm/components/huggingface_local.py +217 -0
- pal/local_llm/components/local_llm.py +60 -0
- pal/logging_setup.py +18 -0
- pal/memory/__init__.py +7 -0
- pal/memory/components/__init__.py +6 -0
- pal/memory/components/lru_k.py +56 -0
- pal/memory/components/manager.py +98 -0
- pal/memory/components/note.py +54 -0
- pal/memory/components/storage_manager.py +60 -0
- pal/memory/pipeline.py +122 -0
- pal/memory/utils/__init__.py +3 -0
- pal/memory/utils/keyword_overlap.py +3 -0
- pal/memory_tracker/__init__.py +3 -0
- pal/memory_tracker/components/__init__.py +0 -0
- pal/memory_tracker/components/process.py +56 -0
- pal/memory_tracker/pipeline.py +44 -0
- pal/metrics/__init__.py +4 -0
- pal/metrics/components/__init__.py +4 -0
- pal/metrics/components/collector.py +140 -0
- pal/metrics/components/exporter.py +28 -0
- pal/metrics/pipeline.py +49 -0
- pal/metrics/utils/__init__.py +2 -0
- pal/notes_manager/__init__.py +5 -0
- pal/notes_manager/components/__init__.py +0 -0
- pal/notes_manager/components/privacy.py +14 -0
- pal/notes_manager/components/recall.py +31 -0
- pal/notes_manager/components/tidy.py +28 -0
- pal/notes_manager/pipeline.py +138 -0
- pal/orchestrator/__init__.py +26 -0
- pal/orchestrator/components/__init__.py +26 -0
- pal/orchestrator/components/agent.py +105 -0
- pal/orchestrator/components/cgroup_manager.py +209 -0
- pal/orchestrator/components/context_interrupt.py +220 -0
- pal/orchestrator/components/limits.py +9 -0
- pal/orchestrator/components/scheduler.py +69 -0
- pal/orchestrator/components/syscall_scheduler.py +200 -0
- pal/orchestrator/pipeline.py +228 -0
- pal/orchestrator/utils/__init__.py +4 -0
- pal/orchestrator/utils/device.py +164 -0
- pal/orchestrator/utils/protocol.py +32 -0
- pal/os_kernel/__init__.py +5 -0
- pal/os_kernel/filesystem.py +80 -0
- pal/os_kernel/network.py +146 -0
- pal/os_kernel/pipeline.py +175 -0
- pal/os_kernel/sysinfo.py +36 -0
- pal/paths.py +59 -0
- pal/pipeline.py +177 -0
- pal/py.typed +0 -0
- pal/redact.py +19 -0
- pal/sdk/__init__.py +33 -0
- pal/sdk/adapters.py +43 -0
- pal/sdk/agent.py +99 -0
- pal/sdk/components/__init__.py +14 -0
- pal/sdk/components/autogen_adapter.py +65 -0
- pal/sdk/components/autogen_client.py +98 -0
- pal/sdk/components/framework_adapter_base.py +69 -0
- pal/sdk/components/metagpt_adapter.py +46 -0
- pal/sdk/components/open_interpreter_adapter.py +47 -0
- pal/sdk/components/tool_catalog.py +44 -0
- pal/sdk/os_api.py +84 -0
- pal/sdk/patterns.py +129 -0
- pal/sdk/pipeline.py +240 -0
- pal/sdk/queries.py +68 -0
- pal/sdk/savers.py +88 -0
- pal/sdk/utils/__init__.py +2 -0
- pal/storage_manager/__init__.py +17 -0
- pal/storage_manager/components/__init__.py +19 -0
- pal/storage_manager/components/base.py +50 -0
- pal/storage_manager/components/local_file_backend.py +84 -0
- pal/storage_manager/components/storage_queue.py +91 -0
- pal/storage_manager/components/vector_store_backend.py +122 -0
- pal/storage_manager/pipeline.py +153 -0
- pal/storage_manager/utils/__init__.py +4 -0
- pal/storage_manager/utils/memory_adapter.py +60 -0
- pal/storage_manager/utils/sdk.py +93 -0
- pal/studio/__init__.py +0 -0
- pal/studio/builder.py +166 -0
- pal/studio/catalog.py +90 -0
- pal/studio/checks.py +129 -0
- pal/studio/clients.py +6 -0
- pal/studio/editor.py +137 -0
- pal/studio/files.py +6 -0
- pal/studio/models.py +6 -0
- pal/studio/plan.py +149 -0
- pal/studio/planner.py +205 -0
- pal/studio/policy.py +69 -0
- pal/studio/routes.py +254 -0
- pal/studio/runs.py +210 -0
- pal/task_router/__init__.py +11 -0
- pal/task_router/components/__init__.py +0 -0
- pal/task_router/components/answerer.py +37 -0
- pal/task_router/components/base.py +34 -0
- pal/task_router/components/classifier.py +40 -0
- pal/task_router/components/gates.py +23 -0
- pal/task_router/pipeline.py +61 -0
- pal/task_router/utils/__init__.py +1 -0
- pal/terminal/__init__.py +1 -0
- pal/terminal/backend.py +141 -0
- pal/terminal/bench.py +173 -0
- pal/terminal/cli.py +826 -0
- pal/terminal/clients.py +170 -0
- pal/terminal/commands.py +401 -0
- pal/terminal/decide.py +242 -0
- pal/terminal/files.py +55 -0
- pal/terminal/flow_cli.py +439 -0
- pal/terminal/hardware.py +199 -0
- pal/terminal/history.py +88 -0
- pal/terminal/keys.py +83 -0
- pal/terminal/local_models.py +261 -0
- pal/terminal/logo.py +117 -0
- pal/terminal/models.py +99 -0
- pal/terminal/policy.py +54 -0
- pal/terminal/project.py +413 -0
- pal/terminal/review.py +65 -0
- pal/terminal/security.py +239 -0
- pal/terminal/session.py +503 -0
- pal/terminal/setup.py +354 -0
- pal/terminal/syntax.py +98 -0
- pal/terminal/theme.py +37 -0
- pal/terminal/ui.py +647 -0
- pal/terminal/voice.py +137 -0
- pal/terminal/workflow.py +624 -0
- pal/token_tracker/__init__.py +3 -0
- pal/token_tracker/pipeline.py +54 -0
- pal/tokens.py +21 -0
- pal/tool_catalog.py +107 -0
- pal/tool_manager/__init__.py +17 -0
- pal/tool_manager/builtin.py +82 -0
- pal/tool_manager/components/__init__.py +19 -0
- pal/tool_manager/components/base.py +53 -0
- pal/tool_manager/components/conflict_resolver.py +27 -0
- pal/tool_manager/components/param_validator.py +41 -0
- pal/tool_manager/components/tool_call_queue.py +111 -0
- pal/tool_manager/components/tool_registry.py +60 -0
- pal/tool_manager/pipeline.py +80 -0
- pal/tool_manager/utils/__init__.py +3 -0
- pal/web/__init__.py +3 -0
- pal/web/app.py +276 -0
- pal/web/static/editor.js +219 -0
- pal/web/static/fonts/LICENSE-Inter.txt +3 -0
- pal/web/static/fonts/inter-400.woff2 +0 -0
- pal/web/static/fonts/inter-500.woff2 +0 -0
- pal/web/static/fonts/inter-600.woff2 +0 -0
- pal/web/static/fonts/inter-700.woff2 +0 -0
- pal/web/static/inter.css +5 -0
- pal/web/static/pal-logo-256.png +0 -0
- pal/web/static/pal-logo-32.png +0 -0
- pal/web/static/pal-logo-64.png +0 -0
- pal/web/static/studio.css +306 -0
- pal/web/static/studio.js +533 -0
- pal/web/static/voice.js +76 -0
- pal/web/templates/index.html +368 -0
- pal/web/templates/studio.html +120 -0
- pal_code-0.1.0.dist-info/METADATA +379 -0
- pal_code-0.1.0.dist-info/RECORD +282 -0
- pal_code-0.1.0.dist-info/WHEEL +5 -0
- pal_code-0.1.0.dist-info/entry_points.txt +2 -0
- pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- pal_code-0.1.0.dist-info/top_level.txt +1 -0
pal/web/static/studio.js
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/* Pal Studio: a chat. Describe a product; the bot proposes a plan, then asks a few questions one at a time (agents, access, tools, model,
|
|
2
|
+
steps, files, comparison); then it builds, and you watch every step. Type or speak a change at any time.
|
|
3
|
+
Everything a model wrote (replies, file names, file contents) is shown with textContent, never as HTML. */
|
|
4
|
+
(() => {
|
|
5
|
+
'use strict';
|
|
6
|
+
const $ = (id) => document.getElementById(id);
|
|
7
|
+
const h = (tag, props = {}, ...kids) => {
|
|
8
|
+
const e = document.createElement(tag);
|
|
9
|
+
for (const [k, v] of Object.entries(props)) {
|
|
10
|
+
if (k === 'class') e.className = v;
|
|
11
|
+
else if (k === 'text') e.textContent = v;
|
|
12
|
+
else if (k.startsWith('on')) e.addEventListener(k.slice(2), v);
|
|
13
|
+
else if (v === true) e.setAttribute(k, '');
|
|
14
|
+
else if (v !== false && v != null) e.setAttribute(k, v);
|
|
15
|
+
}
|
|
16
|
+
for (const kid of kids.flat()) if (kid != null) e.append(kid.nodeType ? kid : document.createTextNode(String(kid)));
|
|
17
|
+
return e;
|
|
18
|
+
};
|
|
19
|
+
const api = async (path, opts = {}) => {
|
|
20
|
+
const r = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...opts });
|
|
21
|
+
let data = null;
|
|
22
|
+
try { data = await r.json(); } catch (e) { /* not JSON */ }
|
|
23
|
+
if (!r.ok) throw new Error((data && data.error) || ('HTTP ' + r.status));
|
|
24
|
+
return data;
|
|
25
|
+
};
|
|
26
|
+
const post = (path, body, signal) => api(path, { method: 'POST', body: JSON.stringify(body), signal });
|
|
27
|
+
const KINDS = { web_app: 'Web page or app', cli_tool: 'Command-line tool', library: 'Library', script: 'Script', document: 'Document', other: 'Other' };
|
|
28
|
+
const FINAL = ['done', 'error', 'cancelled'];
|
|
29
|
+
const n = (x) => Number(x || 0).toLocaleString('en-US');
|
|
30
|
+
const SUGGESTIONS = ['A landing page for a hotel', 'A to-do web app', 'A script that cleans a CSV file', 'A page where I paste meeting notes and get action items'];
|
|
31
|
+
|
|
32
|
+
const S = { cat: null, plan: null, notes: [], source: '', userSet: new Set(), refs: [], apiKey: '', open: null, q: 0, ret: false, stage: null,
|
|
33
|
+
busy: false, abort: null, run: null, runs: [], summary: null };
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------- the thread
|
|
36
|
+
const scrollDown = () => { const m = $('main'); m.scrollTop = m.scrollHeight; };
|
|
37
|
+
function showThread() { $('empty').hidden = true; $('below').hidden = true; $('thread').hidden = false; document.querySelector('.mainwrap').classList.remove('is-empty'); }
|
|
38
|
+
function append(el) { showThread(); $('thread').append(el); scrollDown(); return el; }
|
|
39
|
+
function addUser(text) { return append(h('div', { class: 'msg user', text })); }
|
|
40
|
+
function addBot(text, pills = []) {
|
|
41
|
+
const body = h('div', { class: 'txt' }, text);
|
|
42
|
+
if (pills.length) body.append(h('div', { class: 'pills' }, ...pills.map((c) => h('span', { class: 'pill', text: c }))));
|
|
43
|
+
return append(h('div', { class: 'msg bot' }, h('img', { class: 'av', src: '/static/pal-logo-64.png', alt: '' }), body));
|
|
44
|
+
}
|
|
45
|
+
// a local model can take minutes (the first request loads it into memory), so say that the bot is working and for how long
|
|
46
|
+
function thinking() {
|
|
47
|
+
const label = h('span', { text: 'Thinking... 0s' });
|
|
48
|
+
const stopBtn = h('button', { type: 'button', id: 'stopwait', text: 'Stop waiting', onclick: () => { if (S.abort) S.abort.abort(); } });
|
|
49
|
+
const m = append(h('div', { class: 'msg bot thinking' }, h('img', { class: 'av', src: '/static/pal-logo-64.png', alt: '' }), h('div', { class: 'txt' }, label, ' ', stopBtn)));
|
|
50
|
+
const t0 = Date.now();
|
|
51
|
+
const iv = setInterval(() => {
|
|
52
|
+
const s = Math.round((Date.now() - t0) / 1000);
|
|
53
|
+
label.textContent = `Thinking... ${s}s` + (s >= 15 ? '. The first request loads the model into memory, which can take a few minutes on a laptop. A smaller bot model is much faster.' : '');
|
|
54
|
+
}, 1000);
|
|
55
|
+
return { stop: () => { clearInterval(iv); m.remove(); } };
|
|
56
|
+
}
|
|
57
|
+
function setBusy(b) { S.busy = b; updateSend(); $('botmodel').disabled = b || !S.cat.local.ok; }
|
|
58
|
+
function updateSend() { $('send').disabled = S.busy || !$('cmd').value.trim(); }
|
|
59
|
+
function sizeInput() { $('cmd').rows = Math.min(6, Math.max(1, $('cmd').value.split('\n').length)); }
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------- the local model chip and the bot's model
|
|
62
|
+
function renderChip() {
|
|
63
|
+
const loc = S.cat.local, chip = $('localchip');
|
|
64
|
+
chip.className = 'chip ' + (loc.ok ? 'ok' : 'bad');
|
|
65
|
+
chip.textContent = loc.ok ? `Local model: ${loc.recommended} (the best fit for this computer)` + (loc.hardware ? ` \u00B7 ${loc.hardware}` : '') : (loc.error || 'Ollama is not reachable');
|
|
66
|
+
$('startmodel').textContent = loc.ok ? loc.recommended : 'not reachable';
|
|
67
|
+
}
|
|
68
|
+
function fillBotModels() {
|
|
69
|
+
const loc = S.cat.local, models = loc.models.filter((x) => !x.embedding);
|
|
70
|
+
const opts = models.length ? models.map((x) => h('option', { value: x.name, text: x.label + (x.name === loc.recommended ? ' (strongest, slowest)' : '') })) : [h('option', { value: '', text: 'No local model' })];
|
|
71
|
+
$('botmodel').replaceChildren(...opts);
|
|
72
|
+
if (loc.recommended) $('botmodel').value = loc.recommended;
|
|
73
|
+
$('botmodel').disabled = !loc.ok;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------- reference files (the + button)
|
|
77
|
+
function refNote(t) { const e = $('voicehint'); e.hidden = !t; e.textContent = t || ''; }
|
|
78
|
+
function renderRefs() {
|
|
79
|
+
const chips = S.refs.map((r, i) => h('span', { class: 'refitem' }, r.name, h('button', { type: 'button', 'data-ref': i, title: 'Remove ' + r.name, 'aria-label': 'Remove ' + r.name, text: '✕' })));
|
|
80
|
+
$('refchips').hidden = !chips.length;
|
|
81
|
+
$('refchips').replaceChildren(...chips);
|
|
82
|
+
document.querySelectorAll('.reflist').forEach((box) => box.replaceChildren(...S.refs.map((r, i) => h('span', { class: 'refitem' }, r.name, h('button', { type: 'button', 'data-ref': i, title: 'Remove ' + r.name, 'aria-label': 'Remove ' + r.name, text: '✕' })))));
|
|
83
|
+
}
|
|
84
|
+
function readRefs(files) {
|
|
85
|
+
refNote('');
|
|
86
|
+
for (const f of files) {
|
|
87
|
+
if (S.refs.length >= S.cat.limits.max_references) { refNote(`At most ${S.cat.limits.max_references} reference files.`); break; }
|
|
88
|
+
if (f.size > S.cat.limits.max_reference_kb * 1000) { refNote(`${f.name} is too big (the limit is ${S.cat.limits.max_reference_kb} KB).`); continue; }
|
|
89
|
+
const rd = new FileReader();
|
|
90
|
+
rd.onload = () => { S.refs.push({ name: f.name, text: String(rd.result) }); renderRefs(); };
|
|
91
|
+
rd.readAsText(f);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function dropRef(ev) { const b = ev.target.closest('button[data-ref]'); if (b) { S.refs.splice(Number(b.dataset.ref), 1); renderRefs(); } }
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------- the plan, and keeping it valid (every change is checked by the server)
|
|
97
|
+
const roleName = (id) => (S.cat.roles.find((r) => r.id === id) || {}).name || id;
|
|
98
|
+
async function validate() {
|
|
99
|
+
const data = await post('/api/studio/validate', { plan: S.plan, user_set: [...S.userSet] });
|
|
100
|
+
S.plan = { ...data.plan };
|
|
101
|
+
S.notes = data.notes || [];
|
|
102
|
+
if (S.notes.length) addBot('I changed a few things to keep it safe or valid:', S.notes);
|
|
103
|
+
return S.plan;
|
|
104
|
+
}
|
|
105
|
+
const withPlan = (fn) => { const p = JSON.parse(JSON.stringify(S.plan)); fn(p); S.plan = p; };
|
|
106
|
+
|
|
107
|
+
function checkItem(attrs, title, desc, why, cls = '') {
|
|
108
|
+
const box = h('input', { type: 'checkbox', ...attrs });
|
|
109
|
+
const body = h('div', {}, h('div', {}, h('b', { text: title }), ' ', ...(cls.includes('risky') ? [h('span', { class: 'badge', text: 'only you can switch this on' })] : [])), h('div', { class: 'why', text: desc }), why ? h('div', { class: 'why', text: why }) : null);
|
|
110
|
+
return h('label', { class: 'item ' + cls }, box, body);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------- the questions: each builds its controls and returns what it will change
|
|
114
|
+
const Q = [
|
|
115
|
+
{ id: 'product', title: 'What is it?', help: 'This is what I understood. Correct anything that is off.',
|
|
116
|
+
build(body) {
|
|
117
|
+
const p = S.plan.product;
|
|
118
|
+
const name = h('input', { type: 'text', maxlength: 60, value: p.name, 'aria-label': 'Name' });
|
|
119
|
+
const kind = h('select', { 'aria-label': 'Kind' }, ...Object.entries(KINDS).map(([k, v]) => h('option', { value: k, text: v, selected: k === p.kind })));
|
|
120
|
+
const sum = h('textarea', { rows: 3, maxlength: 400, 'aria-label': 'What it does' }, p.summary);
|
|
121
|
+
body.append(h('label', {}, 'Name', name), h('label', {}, 'Kind', kind), h('label', {}, 'What it does', sum));
|
|
122
|
+
return { mutate: (pl) => { pl.product = { name: name.value.trim(), kind: kind.value, summary: sum.value.trim() }; } };
|
|
123
|
+
},
|
|
124
|
+
say: (pl) => `${pl.product.name} (${KINDS[pl.product.kind] || pl.product.kind})`, label: 'Product' },
|
|
125
|
+
|
|
126
|
+
{ id: 'agents', title: 'Which agents should work on it?', help: 'Each agent does one job. The coder is always on.',
|
|
127
|
+
build(body) {
|
|
128
|
+
const list = S.cat.roles.map((r) => checkItem({ 'data-role': r.id, checked: S.plan.roles.includes(r.id), disabled: r.id === 'coder' }, r.name, r.description,
|
|
129
|
+
S.plan.reasons['role:' + r.id] ? 'Why: ' + S.plan.reasons['role:' + r.id] : '', S.plan.roles.includes(r.id) ? '' : 'off'));
|
|
130
|
+
body.append(...list);
|
|
131
|
+
return { mutate: (pl) => { pl.roles = [...body.querySelectorAll('[data-role]')].filter((c) => c.checked).map((c) => c.dataset.role); } };
|
|
132
|
+
},
|
|
133
|
+
say: (pl) => pl.roles.map(roleName).join(', '), label: 'Agents' },
|
|
134
|
+
|
|
135
|
+
{ id: 'access', title: 'What may they access?', help: 'Anything that sends data off this machine is off unless you switch it on.',
|
|
136
|
+
build(body) {
|
|
137
|
+
body.append(...S.cat.permissions.map((p) => checkItem({ 'data-perm': p.id, checked: !!S.plan.permissions[p.id], disabled: p.locked }, p.title, p.description,
|
|
138
|
+
p.locked ? 'Locked: ' + p.why_locked : '', (p.risky ? 'risky ' : '') + (p.locked ? 'locked' : ''))));
|
|
139
|
+
return { mutate: (pl) => {
|
|
140
|
+
body.querySelectorAll('[data-perm]').forEach((c) => {
|
|
141
|
+
pl.permissions[c.dataset.perm] = c.checked;
|
|
142
|
+
const meta = S.cat.permissions.find((p) => p.id === c.dataset.perm);
|
|
143
|
+
if (meta && meta.risky) { if (c.checked) S.userSet.add(c.dataset.perm); else S.userSet.delete(c.dataset.perm); }
|
|
144
|
+
});
|
|
145
|
+
} };
|
|
146
|
+
},
|
|
147
|
+
say: (pl) => { const on = S.cat.permissions.filter((p) => pl.permissions[p.id]).map((p) => p.title); return on.length ? on.join('; ') : 'Nothing extra'; }, label: 'Access' },
|
|
148
|
+
|
|
149
|
+
{ id: 'tools', title: 'Which tools may they use?', help: 'Tools give exact answers (a calculator, a document lookup). Some need access you switched on above.',
|
|
150
|
+
build(body) {
|
|
151
|
+
const perms = S.plan.permissions, groups = {};
|
|
152
|
+
for (const t of S.cat.tools) (groups[t.group] = groups[t.group] || []).push(t);
|
|
153
|
+
for (const [g, tools] of Object.entries(groups)) {
|
|
154
|
+
body.append(h('h4', { text: g }));
|
|
155
|
+
for (const t of tools) {
|
|
156
|
+
const blocked = t.needs && !perms[t.needs];
|
|
157
|
+
const permTitle = t.needs ? (S.cat.permissions.find((p) => p.id === t.needs) || {}).title : '';
|
|
158
|
+
const why = !t.ready ? t.why_not : (blocked ? `needs "${permTitle}" (switch it on in the access question)` : (S.plan.reasons['tool:' + t.name] ? 'Why: ' + S.plan.reasons['tool:' + t.name] : ''));
|
|
159
|
+
body.append(checkItem({ 'data-tool': t.name, checked: S.plan.tools.includes(t.name), disabled: !t.ready || blocked }, `${t.title} (${t.name})`, t.summary, why, (!t.ready || blocked) ? 'off' : ''));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { mutate: (pl) => { pl.tools = [...body.querySelectorAll('[data-tool]')].filter((c) => c.checked).map((c) => c.dataset.tool); } };
|
|
163
|
+
},
|
|
164
|
+
say: (pl) => (pl.tools.length ? pl.tools.join(', ') : 'None'), label: 'Tools' },
|
|
165
|
+
|
|
166
|
+
{ id: 'model', title: 'Which model builds it?', help: 'A local model stays on this machine. An API model needs your key (kept in this tab only).',
|
|
167
|
+
build(body) {
|
|
168
|
+
const m = S.plan.model, loc = S.cat.local, models = loc.models.filter((x) => !x.embedding);
|
|
169
|
+
const rl = h('input', { type: 'radio', name: 'mmode', value: 'local', 'aria-label': 'Local model', checked: m.mode !== 'api' });
|
|
170
|
+
const ra = h('input', { type: 'radio', name: 'mmode', value: 'api', 'aria-label': 'API model', checked: m.mode === 'api' });
|
|
171
|
+
const localSel = h('select', { 'aria-label': 'Local model', disabled: !loc.ok }, ...models.map((x) => h('option', { value: x.name, text: x.label + (x.name === loc.recommended ? ' (recommended: the strongest)' : '') })));
|
|
172
|
+
if (m.mode !== 'api' && m.model) localSel.value = m.model;
|
|
173
|
+
const localBox = h('div', { class: 'localbox' }, h('label', {}, 'Model', localSel), h('p', { class: 'hint', text: loc.ok ? `Runs on this machine. Nothing leaves it. Context window: ${n(S.cat.limits.num_ctx)} tokens.` : (loc.error || 'Ollama is not reachable.') }));
|
|
174
|
+
const modeSel = h('select', { 'aria-label': 'How' }, ...S.cat.llm_modes.map((x) => h('option', { value: x.key, text: x.title })));
|
|
175
|
+
modeSel.value = m.mode === 'api' && m.api_mode ? m.api_mode : S.cat.llm_mode;
|
|
176
|
+
const modeNote = h('p', { class: 'hint' });
|
|
177
|
+
const prov = h('select', { 'aria-label': 'Provider' });
|
|
178
|
+
const apiModel = h('input', { type: 'text', list: 'apimodels', placeholder: 'model name', 'aria-label': 'API model name' });
|
|
179
|
+
const key = h('input', { type: 'password', autocomplete: 'off', placeholder: 'paste your key (kept in this tab only)', 'aria-label': 'API key' });
|
|
180
|
+
key.value = S.apiKey;
|
|
181
|
+
const keyWarn = h('p', { class: 'warn', hidden: true });
|
|
182
|
+
const useLocal = h('button', { type: 'button', class: 'btn', hidden: true, text: 'Use the local model instead', onclick: () => { rl.checked = true; toggle(); keyWarn.hidden = true; useLocal.hidden = true; } });
|
|
183
|
+
const apiBox = h('div', { class: 'apibox' }, h('label', {}, 'How', modeSel), modeNote, h('label', {}, 'Provider', prov), h('label', {}, 'Model', apiModel), h('datalist', { id: 'apimodels' }), h('label', {}, 'API key', key), keyWarn, useLocal);
|
|
184
|
+
const fillProv = (want) => {
|
|
185
|
+
const mode = S.cat.llm_modes.find((x) => x.key === modeSel.value) || { providers: [], summary: '' };
|
|
186
|
+
modeNote.textContent = mode.summary;
|
|
187
|
+
prov.replaceChildren(...mode.providers.map((p) => h('option', { value: p.name, text: p.name + (p.key_set ? ' (key set)' : '') })));
|
|
188
|
+
if (want && mode.providers.some((p) => p.name === want)) prov.value = want;
|
|
189
|
+
if (modeSel.value === 'explore') loadOpenRouterModels(apiModel); else apiBox.querySelector('#apimodels').replaceChildren();
|
|
190
|
+
};
|
|
191
|
+
const toggle = () => { localBox.hidden = ra.checked; apiBox.hidden = !ra.checked; };
|
|
192
|
+
modeSel.addEventListener('change', () => fillProv(''));
|
|
193
|
+
rl.addEventListener('change', toggle); ra.addEventListener('change', toggle);
|
|
194
|
+
fillProv(m.mode === 'api' ? m.provider : '');
|
|
195
|
+
if (m.mode === 'api') apiModel.value = m.model || '';
|
|
196
|
+
toggle();
|
|
197
|
+
body.append(h('div', { class: 'choice' }, h('label', {}, rl, ' Local (Ollama, on this machine)'), h('label', {}, ra, ' API model')), localBox, apiBox);
|
|
198
|
+
return {
|
|
199
|
+
check() {
|
|
200
|
+
keyWarn.hidden = true; useLocal.hidden = true;
|
|
201
|
+
if (ra.checked && !apiModel.value.trim()) { keyWarn.hidden = false; keyWarn.textContent = 'Choose a model name for the API provider.'; return false; }
|
|
202
|
+
if (ra.checked && !key.value && !S.cat.providers[prov.value]) { keyWarn.hidden = false; keyWarn.textContent = `There is no API key for ${prov.value}. Paste one above, or use the local model.`; useLocal.hidden = false; return false; }
|
|
203
|
+
if (rl.checked && !localSel.value) { keyWarn.hidden = false; keyWarn.textContent = 'There is no local model: start Ollama and pull one (for example: ollama pull llama3.1:8b), or switch to an API model.'; return false; }
|
|
204
|
+
return true;
|
|
205
|
+
},
|
|
206
|
+
mutate: (pl) => {
|
|
207
|
+
S.apiKey = ra.checked ? key.value : '';
|
|
208
|
+
pl.model = ra.checked ? { mode: 'api', provider: prov.value, model: apiModel.value.trim() } : { mode: 'local', model: localSel.value };
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
say: (pl) => (pl.model.mode === 'api' ? `${pl.model.provider}: ${pl.model.model}` : `${pl.model.model} (local)`), label: 'Model' },
|
|
213
|
+
|
|
214
|
+
{ id: 'steps', title: 'What are the steps?', help: 'The agents work through these in order. Change the wording, the agent, or the order.',
|
|
215
|
+
build(body) {
|
|
216
|
+
const roles = S.plan.roles, list = h('div', { class: 'steps' });
|
|
217
|
+
const row = (s) => {
|
|
218
|
+
const r = h('div', { class: 'steprow' },
|
|
219
|
+
h('select', { 'aria-label': 'Agent for this step' }, ...roles.map((x) => h('option', { value: x, text: roleName(x), selected: x === s.role }))),
|
|
220
|
+
h('input', { type: 'text', value: s.task, maxlength: 300, 'aria-label': 'Task for this step' }),
|
|
221
|
+
h('span', {}, h('button', { type: 'button', 'data-move': -1, title: 'Move up', text: '↑' }), h('button', { type: 'button', 'data-move': 1, title: 'Move down', text: '↓' }), h('button', { type: 'button', 'data-del': 1, title: 'Remove this step', text: '✕' })));
|
|
222
|
+
return r;
|
|
223
|
+
};
|
|
224
|
+
list.append(...S.plan.steps.map(row));
|
|
225
|
+
list.addEventListener('click', (e) => {
|
|
226
|
+
const b = e.target.closest('button');
|
|
227
|
+
if (!b) return;
|
|
228
|
+
const r = b.closest('.steprow');
|
|
229
|
+
if (b.dataset.del) r.remove();
|
|
230
|
+
else if (b.dataset.move) { const to = Number(b.dataset.move) < 0 ? r.previousElementSibling : r.nextElementSibling; if (to) { if (Number(b.dataset.move) < 0) to.before(r); else to.after(r); } }
|
|
231
|
+
});
|
|
232
|
+
const add = h('button', { type: 'button', class: 'btn', text: 'Add a step', onclick: () => {
|
|
233
|
+
const role = roles.includes('reviewer') ? 'reviewer' : roles[roles.length - 1] || 'coder';
|
|
234
|
+
list.append(row({ role, task: 'Describe what this step should do' }));
|
|
235
|
+
} });
|
|
236
|
+
body.append(list, h('div', { class: 'btnrow' }, add));
|
|
237
|
+
return { mutate: (pl) => { pl.steps = [...list.querySelectorAll('.steprow')].map((r) => ({ role: r.querySelector('select').value, task: r.querySelector('input').value.trim() })).filter((s) => s.task); } };
|
|
238
|
+
},
|
|
239
|
+
say: (pl) => `${pl.steps.length} step${pl.steps.length === 1 ? '' : 's'}`, label: 'Steps' },
|
|
240
|
+
|
|
241
|
+
{ id: 'files', title: 'Any reference files?', help: 'A brief or a design document. Pal sends only the parts that match each step; a plain client pastes them in full. Text files, up to 150 KB each.',
|
|
242
|
+
build(body) {
|
|
243
|
+
body.append(h('div', { class: 'reflist' }), h('div', { class: 'btnrow' }, h('button', { type: 'button', class: 'btn', id: 'attachq', text: 'Attach files', onclick: () => $('refs').click() })));
|
|
244
|
+
renderRefs();
|
|
245
|
+
return { mutate: (pl) => { if (S.refs.length) pl.permissions.read_references = true; } };
|
|
246
|
+
},
|
|
247
|
+
say: () => (S.refs.length ? S.refs.map((r) => r.name).join(', ') : 'None'), label: 'Files', cont: 'Continue' },
|
|
248
|
+
|
|
249
|
+
{ id: 'compare', title: 'Show the difference?', help: 'Build it twice, once with a plain client and once with Pal, and compare tokens and time. It takes about twice as long. Small builds can show little or no saving.',
|
|
250
|
+
build(body) {
|
|
251
|
+
const yes = h('input', { type: 'radio', name: 'cmp', value: 'yes', checked: !!S.plan.metrics, 'aria-label': 'Build twice and compare' });
|
|
252
|
+
const no = h('input', { type: 'radio', name: 'cmp', value: 'no', checked: !S.plan.metrics, 'aria-label': 'Build once' });
|
|
253
|
+
body.append(h('div', { class: 'choice' }, h('label', {}, yes, ' Yes, build it twice and compare'), h('label', {}, no, ' No, build it once')));
|
|
254
|
+
return { mutate: (pl) => { pl.metrics = yes.checked; } };
|
|
255
|
+
},
|
|
256
|
+
say: (pl) => (pl.metrics ? 'Build twice and compare' : 'Build once'), label: 'Compare' },
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
function closeOpen() { if (S.open) { S.open.remove(); S.open = null; } }
|
|
260
|
+
function answered(i) {
|
|
261
|
+
const q = Q[i];
|
|
262
|
+
return h('div', { class: 'answered', 'data-q': q.id }, h('span', { class: 'k', text: q.label }), h('span', { class: 'v', text: q.say(S.plan) }),
|
|
263
|
+
h('button', { type: 'button', text: 'Change', onclick: () => askQ(i, true) }));
|
|
264
|
+
}
|
|
265
|
+
function askQ(i, ret = false) {
|
|
266
|
+
closeOpen();
|
|
267
|
+
S.q = i; S.ret = ret; S.stage = 'q';
|
|
268
|
+
const q = Q[i], body = h('div', { class: 'qbody' });
|
|
269
|
+
const got = q.build(body);
|
|
270
|
+
const err = h('p', { class: 'warn', hidden: true });
|
|
271
|
+
const next = h('button', { type: 'button', class: 'btn primary', 'data-next': q.id, text: ret ? 'Done' : (i === Q.length - 1 ? 'Review' : 'Continue') });
|
|
272
|
+
const back = h('button', { type: 'button', class: 'btn', text: 'Back', hidden: ret || i === 0 });
|
|
273
|
+
const card = h('div', { class: 'qcard', 'data-q': q.id, role: 'group', 'aria-label': q.title },
|
|
274
|
+
h('div', { class: 'qhead', text: `Question ${i + 1} of ${Q.length}` }), h('div', { class: 'qtitle', text: q.title }), h('p', { class: 'qhelp', text: q.help }), body, err,
|
|
275
|
+
h('div', { class: 'btnrow' }, next, back));
|
|
276
|
+
next.addEventListener('click', async () => {
|
|
277
|
+
if (got.check && !got.check()) return;
|
|
278
|
+
next.disabled = true;
|
|
279
|
+
try {
|
|
280
|
+
withPlan((pl) => got.mutate(pl));
|
|
281
|
+
await validate();
|
|
282
|
+
card.replaceWith(answered(i));
|
|
283
|
+
S.open = null;
|
|
284
|
+
if (ret || i === Q.length - 1) showConfirm(); else askQ(i + 1);
|
|
285
|
+
} catch (e) { err.hidden = false; err.textContent = e.message; next.disabled = false; }
|
|
286
|
+
});
|
|
287
|
+
back.addEventListener('click', () => { const prev = card.previousElementSibling; if (prev && prev.classList.contains('answered')) prev.remove(); askQ(i - 1); });
|
|
288
|
+
S.open = card;
|
|
289
|
+
append(card);
|
|
290
|
+
renderRefs();
|
|
291
|
+
const first = card.querySelector('input:not([disabled]),select,textarea');
|
|
292
|
+
if (first && !('ontouchstart' in window)) first.focus({ preventScroll: true });
|
|
293
|
+
}
|
|
294
|
+
function showConfirm() {
|
|
295
|
+
closeOpen();
|
|
296
|
+
S.stage = 'confirm';
|
|
297
|
+
const p = S.plan, running = S.run && !FINAL.includes((S.run.summary || {}).status || 'queued');
|
|
298
|
+
const rows = Q.map((q) => h('tr', {}, h('td', { text: q.label }), h('td', { text: q.say(p) })));
|
|
299
|
+
const changes = h('div', { class: 'btnrow' }, ...Q.map((q, i) => h('button', { type: 'button', class: 'btn', text: 'Change ' + q.label.toLowerCase(), onclick: () => askQ(i, true) })));
|
|
300
|
+
const build = h('button', { type: 'button', class: 'btn primary', id: 'build', disabled: !!running, text: running ? 'Building...' : 'Build it' });
|
|
301
|
+
const msg = h('p', { class: 'warn', id: 'buildmsg', hidden: true });
|
|
302
|
+
build.addEventListener('click', () => startBuild(build, msg));
|
|
303
|
+
const card = h('div', { class: 'qcard', 'data-q': 'confirm', role: 'group', 'aria-label': 'Ready to build' },
|
|
304
|
+
h('div', { class: 'qhead', text: 'Ready' }), h('div', { class: 'qtitle', text: 'Here is the plan. Shall I build it?' }),
|
|
305
|
+
h('table', { class: 'summary' }, h('tbody', {}, ...rows)), h('p', { class: 'hint', text: 'You can also type or say a change below, for example "add a tester".' }),
|
|
306
|
+
msg, h('div', { class: 'btnrow' }, build), h('h4', { text: 'Or change something' }), changes);
|
|
307
|
+
S.open = card;
|
|
308
|
+
append(card);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------- talking to the bot
|
|
312
|
+
async function sendChat(text) {
|
|
313
|
+
if (!text || S.busy) return;
|
|
314
|
+
addUser(text);
|
|
315
|
+
setBusy(true);
|
|
316
|
+
const wait = thinking();
|
|
317
|
+
S.abort = new AbortController();
|
|
318
|
+
const model = $('botmodel').value || undefined;
|
|
319
|
+
try {
|
|
320
|
+
let r;
|
|
321
|
+
const first = !S.plan;
|
|
322
|
+
if (first) r = await post('/api/studio/plan', { command: text, user_set: [...S.userSet], model }, S.abort.signal);
|
|
323
|
+
else r = await post('/api/studio/revise', { plan: S.plan, instruction: text, user_set: [...S.userSet], model }, S.abort.signal);
|
|
324
|
+
wait.stop();
|
|
325
|
+
S.plan = r.plan; S.notes = r.notes || []; S.source = r.source;
|
|
326
|
+
if (first) { document.querySelectorAll('.answered').forEach((e) => e.remove()); }
|
|
327
|
+
addBot(r.message, r.changes || []);
|
|
328
|
+
if (r.local && !r.local.ok) addBot(r.local.error);
|
|
329
|
+
if (S.source === 'fallback') addBot('The bot could not plan this, so I made a plan by simple rules. Please check each answer.');
|
|
330
|
+
if (first) { addBot(`I have a plan. I will ask ${Q.length} short questions, one at a time. You can type or say a change at any time.`); askQ(0); }
|
|
331
|
+
else if (S.stage === 'confirm') { const gone = S.open; showConfirm(); if (gone && gone.isConnected) gone.remove(); }
|
|
332
|
+
else askQ(S.q, S.ret);
|
|
333
|
+
} catch (e) {
|
|
334
|
+
wait.stop();
|
|
335
|
+
addBot(e.name === 'AbortError' ? 'Stopped waiting. The model may still be working in the background. Try a smaller bot model, or choose "Set up by hand".' : 'That did not work: ' + e.message);
|
|
336
|
+
} finally { setBusy(false); }
|
|
337
|
+
}
|
|
338
|
+
async function manualStart() {
|
|
339
|
+
if (S.busy) return;
|
|
340
|
+
S.plan = { product: { name: 'My project', kind: 'web_app', summary: '' }, roles: ['coder'], tools: [], permissions: {}, model: { mode: 'local', model: S.cat.local.recommended || '' }, steps: [], reasons: {}, metrics: false };
|
|
341
|
+
S.source = '';
|
|
342
|
+
showThread();
|
|
343
|
+
closeOpen();
|
|
344
|
+
const data = await post('/api/studio/validate', { plan: S.plan, user_set: [] });
|
|
345
|
+
S.plan = data.plan; S.notes = [];
|
|
346
|
+
addBot('You are setting this up by hand. I will ask a few questions, one at a time. You can still type a change at any time.');
|
|
347
|
+
askQ(0);
|
|
348
|
+
}
|
|
349
|
+
function newBuild() {
|
|
350
|
+
if (S.abort && S.busy) S.abort.abort();
|
|
351
|
+
closeOpen();
|
|
352
|
+
Object.assign(S, { plan: null, notes: [], source: '', stage: null, q: 0, ret: false, apiKey: '' });
|
|
353
|
+
S.userSet = new Set();
|
|
354
|
+
$('thread').replaceChildren();
|
|
355
|
+
$('thread').hidden = true; $('empty').hidden = false; $('below').hidden = false; document.querySelector('.mainwrap').classList.add('is-empty');
|
|
356
|
+
$('cmd').value = ''; sizeInput(); updateSend();
|
|
357
|
+
$('side').classList.remove('open');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------- building and watching
|
|
361
|
+
async function startBuild(btn, msg) {
|
|
362
|
+
msg.hidden = true;
|
|
363
|
+
btn.disabled = true;
|
|
364
|
+
try {
|
|
365
|
+
if (S.refs.length && !S.plan.permissions.read_references) withPlan((pl) => { pl.permissions.read_references = true; });
|
|
366
|
+
const plan = await validate();
|
|
367
|
+
const body = { plan, user_set: [...S.userSet], references: S.refs };
|
|
368
|
+
if (S.apiKey && plan.model.mode === 'api') body.api_key = S.apiKey;
|
|
369
|
+
const r = await post('/api/studio/run', body);
|
|
370
|
+
btn.textContent = 'Building...';
|
|
371
|
+
startWork(r.run_id, plan.product.name);
|
|
372
|
+
} catch (e) { msg.hidden = false; msg.textContent = e.message; btn.disabled = false; }
|
|
373
|
+
}
|
|
374
|
+
function startWork(runId, product) {
|
|
375
|
+
const el = {
|
|
376
|
+
status: h('div', { class: 'runstatus', role: 'status', text: 'Starting...' }), tabs: h('div', { class: 'tabs', role: 'tablist' }), counters: h('div', { class: 'counters' }),
|
|
377
|
+
events: h('div', { class: 'events', 'aria-live': 'polite' }), files: h('ul', { class: 'files' }), preview: h('pre', { class: 'preview', hidden: true }),
|
|
378
|
+
filesbox: h('div', { hidden: true }), download: h('a', { class: 'linkbtn', href: '#', hidden: true, text: 'Download this build (.zip)' }), compare: h('div', { hidden: true }),
|
|
379
|
+
stop: h('button', { type: 'button', class: 'btn', id: 'stop', text: 'Stop' }),
|
|
380
|
+
};
|
|
381
|
+
el.filesbox.append(h('h3', { text: 'Files' }), h('div', { class: 'filesrow' }, el.files, el.preview), h('p', {}, el.download));
|
|
382
|
+
const card = h('div', { class: 'run', 'data-run': runId }, h('div', { class: 'qhead', text: 'Building ' + product }), el.status, el.tabs, el.counters, el.events, el.filesbox, el.compare, h('div', { class: 'btnrow' }, el.stop));
|
|
383
|
+
const R = { id: runId, product, since: 0, events: [], summary: null, tab: null, timer: null, polling: false, filesSeq: 0, fileSeq: 0, el, card };
|
|
384
|
+
S.run = R; S.runs.push(R); S.summary = null;
|
|
385
|
+
closeOpen();
|
|
386
|
+
append(card);
|
|
387
|
+
addRunItem(R);
|
|
388
|
+
el.stop.addEventListener('click', async () => { el.stop.disabled = true; try { await post(`/api/studio/run/${R.id}/cancel`, {}); } catch (e) { addBot(e.message); } });
|
|
389
|
+
el.tabs.addEventListener('click', async (e) => { const b = e.target.closest('button[data-tab]'); if (!b) return; R.tab = b.dataset.tab; renderWork(R); if (R.tab !== 'compare' && FINAL.includes(R.summary.status)) await showFilesFor(R, R.tab); });
|
|
390
|
+
el.files.addEventListener('click', (e) => { const b = e.target.closest('button[data-file]'); if (b) openFile(R, b.dataset.file); });
|
|
391
|
+
R.timer = setInterval(() => poll(R), 700);
|
|
392
|
+
poll(R);
|
|
393
|
+
}
|
|
394
|
+
function addRunItem(R) {
|
|
395
|
+
const box = $('runs');
|
|
396
|
+
if (box.querySelector('.navnote')) box.replaceChildren();
|
|
397
|
+
R.item = h('button', { type: 'button', class: 'runitem', 'data-run': R.id, onclick: () => { R.card.scrollIntoView({ block: 'start' }); $('side').classList.remove('open'); } }, h('span', { class: 'dot live' }), h('span', { text: R.product }));
|
|
398
|
+
box.prepend(R.item);
|
|
399
|
+
}
|
|
400
|
+
async function poll(R) {
|
|
401
|
+
if (R.polling) return;
|
|
402
|
+
R.polling = true;
|
|
403
|
+
try {
|
|
404
|
+
const d = await api(`/api/studio/run/${R.id}?since=${R.since}`);
|
|
405
|
+
R.events.push(...d.events);
|
|
406
|
+
R.since += d.events.length;
|
|
407
|
+
R.summary = d; S.summary = d;
|
|
408
|
+
renderWork(R);
|
|
409
|
+
if (FINAL.includes(d.status)) await finishRun(R);
|
|
410
|
+
} catch (e) { R.el.status.textContent = 'Lost contact with the server: ' + e.message; clearInterval(R.timer); }
|
|
411
|
+
finally { R.polling = false; }
|
|
412
|
+
}
|
|
413
|
+
async function finishRun(R) {
|
|
414
|
+
clearInterval(R.timer);
|
|
415
|
+
R.el.stop.hidden = true;
|
|
416
|
+
R.item.querySelector('.dot').className = 'dot done';
|
|
417
|
+
const arms = Object.keys(R.summary.arms);
|
|
418
|
+
if (R.summary.comparison) R.tab = 'compare';
|
|
419
|
+
else if (!R.tab) R.tab = arms[0] || null;
|
|
420
|
+
renderWork(R);
|
|
421
|
+
await showFilesFor(R, R.tab === 'compare' ? arms[arms.length - 1] : R.tab);
|
|
422
|
+
addBot(R.summary.status === 'done' ? 'Done. Preview or download the files above, or open the project in the editor. Type a change below if you want something different, or build it again.' : 'The build stopped. Check the steps above, change something, and build again.');
|
|
423
|
+
if (Object.values(R.summary.arms).some((a) => a.files > 0)) {
|
|
424
|
+
$('vieweditor').disabled = false;
|
|
425
|
+
append(h('div', { class: 'btnrow' }, h('button', { type: 'button', class: 'btn primary', id: 'openeditor', text: 'Open in editor', onclick: () => window.studio.openEditor && window.studio.openEditor(R) })));
|
|
426
|
+
}
|
|
427
|
+
showConfirm();
|
|
428
|
+
}
|
|
429
|
+
const activeArm = (R) => { const arms = Object.keys((R.summary || {}).arms || {}); return R.tab && R.tab !== 'compare' ? R.tab : arms[arms.length - 1]; };
|
|
430
|
+
function renderWork(R) {
|
|
431
|
+
const d = R.summary, el = R.el;
|
|
432
|
+
if (!d) return;
|
|
433
|
+
const arms = Object.entries(d.arms);
|
|
434
|
+
const labels = { done: 'Finished', running: 'Building...', queued: 'Starting...', error: 'Stopped by an error', cancelled: 'Stopped' };
|
|
435
|
+
el.status.textContent = `${d.product}: ${labels[d.status] || d.status} (${d.seconds}s)`;
|
|
436
|
+
if (!R.tab) R.tab = arms.length ? arms[arms.length - 1][0] : null;
|
|
437
|
+
const tabs = arms.map(([k, a]) => h('button', { type: 'button', role: 'tab', class: R.tab === k ? 'active' : '', 'data-tab': k, text: a.label }));
|
|
438
|
+
if (d.comparison) tabs.push(h('button', { type: 'button', role: 'tab', class: R.tab === 'compare' ? 'active' : '', 'data-tab': 'compare', text: 'Compare' }));
|
|
439
|
+
el.tabs.replaceChildren(...(arms.length > 1 || d.comparison ? tabs : []));
|
|
440
|
+
const arm = activeArm(R);
|
|
441
|
+
const t = arm && d.arms[arm] ? d.arms[arm].totals : null;
|
|
442
|
+
el.counters.replaceChildren(...(t && t.steps ? [
|
|
443
|
+
h('span', {}, 'Step ', h('b', { text: `${t.steps_done} of ${t.steps}` })), h('span', {}, 'Tokens read ', h('b', { text: n(t.prompt_tokens) })), h('span', {}, 'written ', h('b', { text: n(t.completion_tokens) })),
|
|
444
|
+
...(t.helper_tokens ? [h('span', {}, 'helper ', h('b', { text: n(t.helper_tokens) }))] : []), h('span', {}, 'Time ', h('b', { text: t.seconds + 's' })), h('span', {}, 'Files ', h('b', { text: String(t.files) })),
|
|
445
|
+
...(t.blocked ? [h('span', { class: 'bad' }, 'Refused ', h('b', { text: String(t.blocked) }))] : [])] : []));
|
|
446
|
+
const show = R.tab === 'compare';
|
|
447
|
+
el.events.hidden = show; el.compare.hidden = !show; el.filesbox.hidden = show || !R.events.some((e) => e.kind === 'file');
|
|
448
|
+
if (show) renderCompare(R, d.comparison); else renderEvents(R, arm);
|
|
449
|
+
}
|
|
450
|
+
function renderEvents(R, arm) {
|
|
451
|
+
const box = R.el.events;
|
|
452
|
+
const stick = box.scrollTop + box.clientHeight >= box.scrollHeight - 24;
|
|
453
|
+
box.replaceChildren(...R.events.filter((e) => !e.arm || e.arm === arm).map((e) => h('div', { class: 'ev ev-' + e.kind }, h('span', { class: 't', text: e.t + 's' }), e.text)));
|
|
454
|
+
if (stick) box.scrollTop = box.scrollHeight;
|
|
455
|
+
}
|
|
456
|
+
function renderCompare(R, c) {
|
|
457
|
+
const b = c.baseline, m = c.pal, saved = c.main_tokens_saved;
|
|
458
|
+
const head = saved > 0 ? `Without Pal: ${n(b.main_tokens)} tokens. With Pal: ${n(m.main_tokens)}. Saved ${n(saved)} tokens (${c.main_tokens_saved_pct}%).`
|
|
459
|
+
: `Without Pal: ${n(b.main_tokens)} tokens. With Pal: ${n(m.main_tokens)}. Pal used ${n(-saved)} more tokens (${Math.abs(c.main_tokens_saved_pct)}% more).`;
|
|
460
|
+
const row = (label, a, z, pc, cls = '') => h('tr', { class: cls }, h('td', { text: label }), h('td', { text: a }), h('td', { text: z }), h('td', { class: pc != null && pc < 0 ? 'neg' : '', text: pc == null ? '' : (pc >= 0 ? pc + '% saved' : Math.abs(pc) + '% more') }));
|
|
461
|
+
const table = h('table', { class: 'cmp' },
|
|
462
|
+
h('thead', {}, h('tr', {}, h('th', { text: '' }), h('th', { text: 'Plain chat client' }), h('th', { text: 'Pal' }), h('th', { text: 'Difference' }))),
|
|
463
|
+
h('tbody', {},
|
|
464
|
+
row('Main-model tokens', n(b.main_tokens), n(m.main_tokens), c.main_tokens_saved_pct, 'saved'),
|
|
465
|
+
row('Tokens read (prompts)', n(b.prompt_tokens), n(m.prompt_tokens), c.prompt_tokens_saved_pct),
|
|
466
|
+
row('Tokens written', n(b.completion_tokens), n(m.completion_tokens), null),
|
|
467
|
+
row('Helper tokens (its own small jobs)', '0', n(m.helper_tokens), null),
|
|
468
|
+
row('All tokens, helper included', n(b.main_tokens), n(m.main_tokens + m.helper_tokens), c.all_tokens_saved_pct),
|
|
469
|
+
row('Time (seconds)', String(b.seconds), String(m.seconds), c.seconds_saved_pct),
|
|
470
|
+
row('Files written', String(b.files), String(m.files), null), row('Problems left in the files', String(b.problems_left), String(m.problems_left), null)));
|
|
471
|
+
R.el.compare.replaceChildren(h('div', { class: 'headline', text: head }), table, h('ul', {}, ...c.notes.map((t) => h('li', { class: 'hint', text: t }))));
|
|
472
|
+
}
|
|
473
|
+
async function showFilesFor(R, arm) {
|
|
474
|
+
if (!arm) return;
|
|
475
|
+
const mine = ++R.filesSeq; // only the latest request may draw: an older answer arriving late must not replace a newer tab's list
|
|
476
|
+
const d = await api(`/api/studio/run/${R.id}/files?arm=${encodeURIComponent(arm)}`);
|
|
477
|
+
if (mine !== R.filesSeq) return;
|
|
478
|
+
R.el.files.replaceChildren(...d.files.map((f) => h('li', {}, h('button', { type: 'button', 'data-file': f.path, text: `${f.path} (${n(f.bytes)} B)` }))));
|
|
479
|
+
R.el.filesbox.hidden = !d.files.length;
|
|
480
|
+
const saved = R.summary && R.summary.arms[arm] && R.summary.arms[arm].totals.files > 0;
|
|
481
|
+
R.el.download.hidden = !(d.files.length && saved);
|
|
482
|
+
R.el.download.href = `/api/studio/run/${R.id}/download?arm=${encodeURIComponent(arm)}`;
|
|
483
|
+
R.el.preview.hidden = true;
|
|
484
|
+
}
|
|
485
|
+
async function openFile(R, path) {
|
|
486
|
+
const mine = ++R.fileSeq;
|
|
487
|
+
const d = await api(`/api/studio/run/${R.id}/file?arm=${encodeURIComponent(activeArm(R))}&path=${encodeURIComponent(path)}`);
|
|
488
|
+
if (mine !== R.fileSeq) return;
|
|
489
|
+
R.el.preview.hidden = false;
|
|
490
|
+
R.el.preview.textContent = d.content + (d.truncated ? '\n... (shortened)' : '');
|
|
491
|
+
R.el.files.querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.file === path));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------- the microphone (voice.js: Pal's speech-to-text, else the browser's)
|
|
495
|
+
function setupMic() {
|
|
496
|
+
if (window.palVoice) window.palVoice($('mic'), $('cmd'), refNote, () => { sizeInput(); updateSend(); });
|
|
497
|
+
else $('mic').disabled = true;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ---------------------------------------------------------------- start
|
|
501
|
+
async function init() {
|
|
502
|
+
try { S.cat = await api('/api/studio/catalog'); }
|
|
503
|
+
catch (e) { $('localchip').className = 'chip bad'; $('localchip').textContent = 'Could not load the Studio: ' + e.message; return; }
|
|
504
|
+
renderChip();
|
|
505
|
+
fillBotModels();
|
|
506
|
+
$('suggest').replaceChildren(...SUGGESTIONS.map((t) => h('button', { type: 'button', text: t, onclick: () => sendChat(t) })));
|
|
507
|
+
$('composer').addEventListener('submit', (e) => { e.preventDefault(); const t = $('cmd').value.trim(); if (!t || S.busy) return; $('cmd').value = ''; sizeInput(); updateSend(); sendChat(t); });
|
|
508
|
+
$('cmd').addEventListener('input', () => { sizeInput(); updateSend(); });
|
|
509
|
+
$('cmd').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); $('composer').requestSubmit(); } });
|
|
510
|
+
$('attach').addEventListener('click', () => $('refs').click());
|
|
511
|
+
$('refs').addEventListener('change', (e) => { readRefs([...e.target.files]); e.target.value = ''; });
|
|
512
|
+
$('refchips').addEventListener('click', dropRef);
|
|
513
|
+
$('thread').addEventListener('click', dropRef);
|
|
514
|
+
$('byhand').addEventListener('click', manualStart);
|
|
515
|
+
$('newbuild').addEventListener('click', newBuild);
|
|
516
|
+
$('menu').addEventListener('click', () => { const o = $('side').classList.toggle('open'); $('menu').setAttribute('aria-expanded', String(o)); });
|
|
517
|
+
setupMic();
|
|
518
|
+
updateSend();
|
|
519
|
+
window.studio = { state: S, validate, poll: () => (S.run ? poll(S.run) : null), util: { h, api, post, n } }; // for tests and for curious people at the console
|
|
520
|
+
}
|
|
521
|
+
let orLoaded = false;
|
|
522
|
+
async function loadOpenRouterModels(input) {
|
|
523
|
+
const list = document.getElementById('apimodels');
|
|
524
|
+
if (orLoaded) return;
|
|
525
|
+
try {
|
|
526
|
+
const d = await api('/api/llm/models?limit=500');
|
|
527
|
+
orLoaded = true;
|
|
528
|
+
if (list) list.replaceChildren(...d.models.map((m) => h('option', { value: m.id, label: m.label })));
|
|
529
|
+
input.placeholder = 'pick one of ' + d.models.length + ' models, or type a name';
|
|
530
|
+
} catch (e) { input.placeholder = 'the model list could not be read: type a model name'; }
|
|
531
|
+
}
|
|
532
|
+
init();
|
|
533
|
+
})();
|
pal/web/static/voice.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Talk instead of typing, for the chat and the Studio. The words go into the message box; nothing is sent on its own.
|
|
2
|
+
// When Pal's server can turn speech into text (faster-whisper on this computer, or your OpenAI / Groq key), the page records with the
|
|
3
|
+
// browser's MediaRecorder and sends the audio to this computer's Pal only. Otherwise it falls back to the browser's own speech recognition.
|
|
4
|
+
// Dictation apps such as Wispr Flow need none of this: they type into the box.
|
|
5
|
+
(function () {
|
|
6
|
+
'use strict';
|
|
7
|
+
async function serverEngines() {
|
|
8
|
+
try { const r = await fetch('/api/voice'); if (!r.ok) return []; return (await r.json()).engines || []; } catch (e) { return []; }
|
|
9
|
+
}
|
|
10
|
+
function insert(box, text, onInput) {
|
|
11
|
+
if (!text) return;
|
|
12
|
+
const base = box.value && !/\s$/.test(box.value) ? box.value + ' ' : box.value;
|
|
13
|
+
box.value = base + text;
|
|
14
|
+
box.dispatchEvent(new Event('input', { bubbles: true }));
|
|
15
|
+
if (onInput) onInput();
|
|
16
|
+
box.focus();
|
|
17
|
+
}
|
|
18
|
+
function setLive(btn, on, label) {
|
|
19
|
+
btn.classList.toggle('live', on);
|
|
20
|
+
btn.setAttribute('aria-pressed', String(on));
|
|
21
|
+
btn.title = label;
|
|
22
|
+
}
|
|
23
|
+
window.palVoice = async function (btn, box, note, onInput) {
|
|
24
|
+
note = note || function () {};
|
|
25
|
+
const engines = await serverEngines();
|
|
26
|
+
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
27
|
+
const canRecord = !!(navigator.mediaDevices && window.MediaRecorder);
|
|
28
|
+
if (!(engines.length && canRecord) && !SR) {
|
|
29
|
+
btn.disabled = true;
|
|
30
|
+
btn.title = 'Voice input is not available here: install pal-code[voice] for speech-to-text on this computer, or use Chrome, Edge or Safari';
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
let rec = null, chunks = [], stream = null, sr = null;
|
|
34
|
+
const idle = engines.length && canRecord ? 'Speak instead of typing (turned into text on this computer: ' + engines[0] + ')' : 'Speak instead of typing (your browser turns it into text)';
|
|
35
|
+
setLive(btn, false, idle);
|
|
36
|
+
btn.addEventListener('click', async function () {
|
|
37
|
+
if (rec) { rec.stop(); return; }
|
|
38
|
+
if (sr) { sr.stop(); return; }
|
|
39
|
+
note('');
|
|
40
|
+
if (engines.length && canRecord) {
|
|
41
|
+
try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
|
|
42
|
+
catch (e) { note('The microphone is blocked. Allow it for this page in the browser, then try again.'); return; }
|
|
43
|
+
chunks = [];
|
|
44
|
+
rec = new MediaRecorder(stream);
|
|
45
|
+
rec.ondataavailable = function (e) { if (e.data && e.data.size) chunks.push(e.data); };
|
|
46
|
+
rec.onstop = async function () {
|
|
47
|
+
stream.getTracks().forEach(function (t) { t.stop(); });
|
|
48
|
+
const blob = new Blob(chunks, { type: rec.mimeType || 'audio/webm' });
|
|
49
|
+
rec = null;
|
|
50
|
+
setLive(btn, false, 'Turning speech into text…');
|
|
51
|
+
btn.disabled = true;
|
|
52
|
+
try {
|
|
53
|
+
const r = await fetch('/api/transcribe', { method: 'POST', headers: { 'Content-Type': blob.type.split(';')[0] || 'audio/webm' }, body: blob });
|
|
54
|
+
const d = await r.json();
|
|
55
|
+
if (!r.ok) note(d.error || 'Speech-to-text failed'); else if (!d.text) note('I did not hear anything.'); else insert(box, d.text, onInput);
|
|
56
|
+
} catch (e) { note('Speech-to-text failed: ' + e.message); }
|
|
57
|
+
btn.disabled = false;
|
|
58
|
+
setLive(btn, false, idle);
|
|
59
|
+
};
|
|
60
|
+
rec.start();
|
|
61
|
+
setLive(btn, true, 'Listening… click again to stop');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
sr = new SR();
|
|
65
|
+
sr.interimResults = true; sr.continuous = true; sr.lang = navigator.language || 'en-US';
|
|
66
|
+
const start = box.value && !/\s$/.test(box.value) ? box.value + ' ' : box.value;
|
|
67
|
+
sr.onresult = function (e) { // the words so far, shown as they are heard
|
|
68
|
+
let t = ''; for (let i = 0; i < e.results.length; i++) t += e.results[i][0].transcript;
|
|
69
|
+
box.value = start + t; box.dispatchEvent(new Event('input', { bubbles: true })); if (onInput) onInput();
|
|
70
|
+
};
|
|
71
|
+
sr.onerror = function (e) { note(e.error === 'not-allowed' || e.error === 'service-not-allowed' ? 'The microphone is blocked. Allow it for this page in the browser, then try again.' : e.error === 'no-speech' ? 'I did not hear anything.' : 'Voice input stopped: ' + e.error); };
|
|
72
|
+
sr.onend = function () { sr = null; setLive(btn, false, idle); };
|
|
73
|
+
try { sr.start(); setLive(btn, true, 'Listening… click again to stop'); } catch (e) { sr = null; }
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
})();
|