pindeck 2.8.2 → 3.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -66
- package/cli.mjs +4 -4
- package/lib/agent-process.mjs +49 -0
- package/lib/agent-tools.mjs +85 -0
- package/lib/claude.mjs +34 -66
- package/lib/codex.mjs +104 -0
- package/lib/models.mjs +66 -0
- package/lib/pdf-text.jxa.js +9 -0
- package/package.json +6 -4
- package/public/editor.css +15 -0
- package/public/editor.js +95 -41
- package/public/index.html +29 -12
- package/server.mjs +227 -122
package/lib/claude.mjs
CHANGED
|
@@ -1,70 +1,38 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import { spawn } from "node:child_process";
|
|
7
|
-
import readline from "node:readline";
|
|
8
|
-
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const exec = promisify(execFile);
|
|
4
|
+
import { AgentProcess } from './agent-process.mjs';
|
|
5
|
+
import { normalizeModels } from './models.mjs';
|
|
9
6
|
export class ClaudeSession {
|
|
10
|
-
constructor({
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
this.
|
|
14
|
-
this.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
this.
|
|
7
|
+
constructor(options) { Object.assign(this, options); this.transport = null; this.sessionId = null; }
|
|
8
|
+
get running() { return !!this.transport && !this.transport.closed; }
|
|
9
|
+
async start(resumeId) {
|
|
10
|
+
if (this.running) return;
|
|
11
|
+
this.stopped = false;
|
|
12
|
+
try {
|
|
13
|
+
const { stdout } = await exec(process.env.CLAUDE_BIN || 'claude', ['auth', 'status', '--json'],
|
|
14
|
+
{ env: { ...process.env, ...this.env }, timeout: 15000, maxBuffer: 65536 });
|
|
15
|
+
if (!JSON.parse(stdout).loggedIn) throw new Error('Not signed in.');
|
|
16
|
+
} catch (e) { throw new Error(e.code === 'ENOENT' ? 'Claude CLI is not installed (or set CLAUDE_BIN).' : 'Claude is not signed in. Run claude auth login, then refresh models.'); }
|
|
17
|
+
if (this.stopped) throw new Error('Agent stopped.');
|
|
18
|
+
const transport = new AgentProcess({ binary: process.env.CLAUDE_BIN || 'claude', cwd: this.cwd, env: this.env,
|
|
19
|
+
args: ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
|
|
20
|
+
...(typeof this.args === 'function' ? this.args() : this.args || []), ...(resumeId ? ['--resume', resumeId] : [])],
|
|
21
|
+
onStderr: this.onStderr,
|
|
22
|
+
onMessage: ev => {
|
|
23
|
+
if (this.transport !== transport) return;
|
|
24
|
+
if (ev.type === 'control_request') { transport.write({ type: 'control_response', response: { subtype: 'success', request_id: ev.request_id,
|
|
25
|
+
response: { behavior: 'deny', message: 'This tool is outside PinDeck permissions.' } } }); return; }
|
|
26
|
+
if (ev.session_id) this.sessionId = ev.session_id;
|
|
27
|
+
this.onEvent?.(ev);
|
|
28
|
+
},
|
|
29
|
+
onExit: error => { if (this.transport === transport) this.onExit?.(error); },
|
|
33
30
|
});
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
let ended = false;
|
|
38
|
-
const end = (code, signal) => {
|
|
39
|
-
if (ended) return;
|
|
40
|
-
ended = true;
|
|
41
|
-
// stale: a newer process has been started since (model change, new chat); its turn is not ours to end
|
|
42
|
-
const stale = this.proc !== proc && this.proc !== null;
|
|
43
|
-
if (this.proc === proc) this.proc = null;
|
|
44
|
-
this.onExit(code, signal, { stale });
|
|
45
|
-
};
|
|
46
|
-
proc.on("error", (err) => { this.onStderr?.(String(err)); if (proc.pid === undefined) end(null, null); });
|
|
47
|
-
proc.on("exit", end);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
send(content) {
|
|
51
|
-
this.#write({ type: "user", message: { role: "user", content } });
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
interrupt() {
|
|
55
|
-
this.#write({ type: "control_request", request_id: `int-${++this.reqId}`, request: { subtype: "interrupt" } });
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
stop() {
|
|
59
|
-
if (!this.proc) return;
|
|
60
|
-
const p = this.proc;
|
|
61
|
-
this.proc = null;
|
|
62
|
-
p.stdin.end();
|
|
63
|
-
setTimeout(() => p.kill("SIGTERM"), 1500);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
#write(obj) {
|
|
67
|
-
if (!this.proc) throw new Error("Claude Code is not running");
|
|
68
|
-
this.proc.stdin.write(JSON.stringify(obj) + "\n");
|
|
31
|
+
this.transport = transport;
|
|
32
|
+
try { const init = await transport.request('initialize', {}, { claude: true }); this.models = normalizeModels('claude', init?.models); }
|
|
33
|
+
catch (e) { this.stop(); throw e; }
|
|
69
34
|
}
|
|
35
|
+
async send(content) { this.transport.write({ type: 'user', message: { role: 'user', content } }); }
|
|
36
|
+
async interrupt() { if (this.running) await this.transport.request('interrupt', {}, { claude: true, timeout: 5000 }); }
|
|
37
|
+
stop() { this.stopped = true; const t = this.transport; this.transport = null; t?.stop(); }
|
|
70
38
|
}
|
package/lib/codex.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { AgentProcess } from './agent-process.mjs';
|
|
2
|
+
import { normalizeModels } from './models.mjs';
|
|
3
|
+
import { deckTools } from './agent-tools.mjs';
|
|
4
|
+
export const CODEX_CONFIG = {
|
|
5
|
+
'features.shell_tool': false, 'features.unified_exec': false, 'features.js_repl': false,
|
|
6
|
+
'features.apps': false, 'features.plugins': false, 'features.multi_agent': false,
|
|
7
|
+
'features.hooks': false, 'features.memory_tool': false, 'features.shell_snapshot': false,
|
|
8
|
+
'web_search': 'disabled', 'project_doc_max_bytes': 0, 'notify': [],
|
|
9
|
+
};
|
|
10
|
+
const toml = value => JSON.stringify(value);
|
|
11
|
+
const withoutNull = value => Array.isArray(value) ? value.map(withoutNull) : value && typeof value === 'object'
|
|
12
|
+
? Object.fromEntries(Object.entries(value).filter(([, v]) => v != null).map(([k,v]) => [k, withoutNull(v)])) : value;
|
|
13
|
+
export class CodexSession {
|
|
14
|
+
constructor(options) { Object.assign(this, options); this.transport = null; this.sessionId = null; this.turnId = null; this.controller = null; }
|
|
15
|
+
get running() { return !!this.transport && !this.transport.closed; }
|
|
16
|
+
async connect() {
|
|
17
|
+
if (this.running) return;
|
|
18
|
+
const transport = new AgentProcess({ binary: process.env.CODEX_BIN || 'codex', cwd: this.cwd,
|
|
19
|
+
args: ['app-server', ...Object.entries(CODEX_CONFIG).flatMap(([k,v]) => ['-c', `${k}=${toml(v)}`])],
|
|
20
|
+
onStderr: this.onStderr,
|
|
21
|
+
onMessage: m => { if (this.transport === transport) this.message(m).catch(e => this.onStderr?.(String(e))); },
|
|
22
|
+
onExit: error => { if (this.transport === transport) { this.controller?.abort(); this.onExit?.(error); } },
|
|
23
|
+
});
|
|
24
|
+
this.transport = transport;
|
|
25
|
+
try {
|
|
26
|
+
await transport.request('initialize', { clientInfo: { name: 'pindeck', version: '3.0.0-beta.1' }, capabilities: { experimentalApi: true } });
|
|
27
|
+
transport.write({ method: 'initialized', params: {} });
|
|
28
|
+
const auth = await transport.request('account/read', { refreshToken: false });
|
|
29
|
+
if (auth.requiresOpenaiAuth && !auth.account) throw new Error('Codex is not signed in. Run codex login, then refresh models.');
|
|
30
|
+
this.models = []; let cursor;
|
|
31
|
+
do { const list = await transport.request('model/list', { includeHidden: false, ...(cursor ? { cursor } : {}) });
|
|
32
|
+
this.models.push(...normalizeModels('codex', list.data)); cursor = list.nextCursor;
|
|
33
|
+
} while (cursor);
|
|
34
|
+
} catch (e) { this.stop(); throw e; }
|
|
35
|
+
}
|
|
36
|
+
async start(resumeId) {
|
|
37
|
+
await this.connect();
|
|
38
|
+
try {
|
|
39
|
+
const { config = {} } = await this.transport.request('config/read', { includeLayers: false, cwd: this.cwd });
|
|
40
|
+
// Disable inherited connectors and hooks before starting any thread. No configuration files are changed.
|
|
41
|
+
const overrides = { ...CODEX_CONFIG };
|
|
42
|
+
overrides.mcp_servers = Object.fromEntries(Object.entries(config.mcp_servers || {}).map(([name, value]) => [name, { ...value, enabled: false }]));
|
|
43
|
+
overrides.plugins = Object.fromEntries(Object.entries(config.plugins || {}).map(([name, value]) => [name, { ...value, enabled: false }]));
|
|
44
|
+
overrides.hooks = Object.fromEntries(Object.entries(config.hooks || {}).map(([name, value]) => [name, Array.isArray(value) ? [] : value]));
|
|
45
|
+
const opts = typeof this.settings === 'function' ? this.settings() : this.settings;
|
|
46
|
+
const params = { cwd: this.cwd, model: opts.model, modelProvider: 'openai',
|
|
47
|
+
approvalPolicy: 'never', sandbox: 'read-only', config: withoutNull(overrides),
|
|
48
|
+
developerInstructions: this.instructions, ...(resumeId ? { threadId: resumeId } : { dynamicTools: deckTools }) };
|
|
49
|
+
const result = await this.transport.request(resumeId ? 'thread/resume' : 'thread/start', params);
|
|
50
|
+
this.sessionId = result.thread.id;
|
|
51
|
+
this.onEvent?.({ type: 'system', subtype: 'init', session_id: this.sessionId, model: result.model || opts.model });
|
|
52
|
+
} catch (e) { this.stop(); throw e; }
|
|
53
|
+
}
|
|
54
|
+
async send(content) {
|
|
55
|
+
if (!this.sessionId || this.controller) throw new Error('Codex is not ready for another turn.');
|
|
56
|
+
const opts = typeof this.settings === 'function' ? this.settings() : this.settings;
|
|
57
|
+
this.controller = new AbortController(); this.turnId = null; this.cancelRequested = false;
|
|
58
|
+
try {
|
|
59
|
+
const result = await this.transport.request('turn/start', { threadId: this.sessionId, input: content,
|
|
60
|
+
model: opts.model, ...(opts.effort ? { effort: opts.effort } : {}),
|
|
61
|
+
approvalPolicy: 'never', sandboxPolicy: { type: 'readOnly', networkAccess: false } });
|
|
62
|
+
this.turnId = result.turn.id;
|
|
63
|
+
if (this.cancelRequested) await this.interrupt();
|
|
64
|
+
} catch (e) { this.controller?.abort(); this.controller = null; throw e; }
|
|
65
|
+
}
|
|
66
|
+
async message(m) {
|
|
67
|
+
const p = m.params || {};
|
|
68
|
+
if (m.id !== undefined && m.method) {
|
|
69
|
+
let result;
|
|
70
|
+
if (m.method === 'item/tool/call') {
|
|
71
|
+
try {
|
|
72
|
+
if (!this.controller || this.controller.signal.aborted || p.threadId !== this.sessionId || (this.turnId && p.turnId !== this.turnId)) throw new Error('Inactive turn.');
|
|
73
|
+
const signal = this.controller.signal;
|
|
74
|
+
const items = await this.tools(p.tool, p.arguments, signal);
|
|
75
|
+
signal.throwIfAborted(); result = { success: true, contentItems: items };
|
|
76
|
+
} catch (e) { result = { success: false, contentItems: [{ type: 'inputText', text: e.message }] }; }
|
|
77
|
+
} else if (m.method.includes('requestApproval') && m.method.includes('permissions')) result = { permissions: {}, scope: 'turn' };
|
|
78
|
+
else if (m.method.includes('requestApproval')) result = { decision: 'decline' };
|
|
79
|
+
else if (m.method.includes('elicitation')) result = { action: 'decline', content: null };
|
|
80
|
+
else if (m.method.includes('requestUserInput')) result = { answers: {} };
|
|
81
|
+
else { this.transport?.write({ id: m.id, error: { code: -32601, message: 'Unsupported request' } }); return; }
|
|
82
|
+
if (this.running) this.transport.write({ id: m.id, result });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (p.threadId !== this.sessionId || !this.controller) return;
|
|
86
|
+
const eventTurnId = p.turnId || p.turn?.id;
|
|
87
|
+
if (eventTurnId && this.turnId && eventTurnId !== this.turnId) return;
|
|
88
|
+
if (m.method === 'turn/started') this.turnId = p.turn.id;
|
|
89
|
+
const item = p.item;
|
|
90
|
+
if (m.method === 'item/completed' && item?.type === 'agentMessage') this.onEvent?.({ type: 'assistant', message: { content: [{ type: 'text', text: item.text }] } });
|
|
91
|
+
if (m.method === 'item/started' && item?.type === 'dynamicToolCall') this.onEvent?.({ type: 'assistant', message: { content: [{ type: 'tool_use', id: item.id, name: item.tool, input: item.arguments }] } });
|
|
92
|
+
if (m.method === 'item/completed' && item?.type === 'dynamicToolCall' && item.success === false) this.onEvent?.({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: item.id, is_error: true, content: (item.contentItems || []).map(c => ({ text: c.text || '' })) }] } });
|
|
93
|
+
if (m.method === 'turn/completed') {
|
|
94
|
+
this.controller.abort(); this.controller = null; this.turnId = null;
|
|
95
|
+
this.onEvent?.({ type: 'result', subtype: p.turn.status === 'completed' ? 'success' : 'error',
|
|
96
|
+
is_error: p.turn.status !== 'completed', result: p.turn.error?.message || (p.turn.status === 'interrupted' ? 'Stopped.' : p.turn.status) });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async interrupt() {
|
|
100
|
+
this.cancelRequested = true; this.controller?.abort();
|
|
101
|
+
if (this.turnId && this.running) await this.transport.request('turn/interrupt', { threadId: this.sessionId, turnId: this.turnId });
|
|
102
|
+
}
|
|
103
|
+
stop() { this.controller?.abort(); this.controller = null; const t = this.transport; this.transport = null; t?.stop(); }
|
|
104
|
+
}
|
package/lib/models.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const PROVIDERS = ['claude', 'codex'];
|
|
2
|
+
export function normalizeModels(provider, rows = []) {
|
|
3
|
+
const seen = new Set();
|
|
4
|
+
return rows.filter(m => !m.hidden && m.available !== false && m.disabled !== true).flatMap(m => {
|
|
5
|
+
const id = provider === 'claude' ? m.value : m.model || m.id;
|
|
6
|
+
if (!id || seen.has(id)) return [];
|
|
7
|
+
seen.add(id);
|
|
8
|
+
const efforts = provider === 'claude' ? m.supportedEffortLevels || [] : (m.supportedReasoningEfforts || []).map(e => e.reasoningEffort);
|
|
9
|
+
return [{ provider, id, label: m.displayName || id, description: m.description || '', efforts,
|
|
10
|
+
effortDescriptions: Object.fromEntries((m.supportedReasoningEfforts || []).map(e => [e.reasoningEffort, e.description || ''])),
|
|
11
|
+
effortDefaultSource: efforts.includes(m.defaultReasoningEffort || m.defaultEffortLevel) ? 'provider' : 'fallback',
|
|
12
|
+
defaultEffort: (efforts.includes(m.defaultReasoningEffort || m.defaultEffortLevel) ? (m.defaultReasoningEffort || m.defaultEffortLevel) : null) || (efforts.includes('medium') ? 'medium' : efforts[0]) || null,
|
|
13
|
+
isDefault: m.isDefault || id === 'default' }];
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function migrateSettings(raw = {}) {
|
|
17
|
+
return { provider: PROVIDERS.includes(raw.provider) ? raw.provider : 'claude',
|
|
18
|
+
model: typeof raw.model === 'string' && raw.model ? raw.model : 'sonnet',
|
|
19
|
+
effort: typeof raw.effort === 'string' ? raw.effort : 'medium' };
|
|
20
|
+
}
|
|
21
|
+
export function selectModel(catalog, current, requested, preferences = {}) {
|
|
22
|
+
const provider = requested.provider ?? current.provider;
|
|
23
|
+
if (!PROVIDERS.includes(provider)) throw new Error('Unknown provider.');
|
|
24
|
+
const models = catalog[provider]?.models || [];
|
|
25
|
+
const id = requested.model ?? (provider === current.provider ? current.model : models.find(m => m.isDefault)?.id || models[0]?.id);
|
|
26
|
+
const model = models.find(m => m.id === id);
|
|
27
|
+
if (!model) throw new Error(catalog[provider]?.error || 'This model is not available. Refresh the model list.');
|
|
28
|
+
const sameModel = provider === current.provider && id === current.model;
|
|
29
|
+
const saved = preferences[`${provider}:${id}`];
|
|
30
|
+
const preferred = sameModel ? current.effort : saved;
|
|
31
|
+
const effort = requested.effort === undefined ? (model.efforts.includes(preferred) ? preferred : model.defaultEffort) : requested.effort;
|
|
32
|
+
if (effort !== null && !model.efforts.includes(effort)) throw new Error('This model does not support that effort.');
|
|
33
|
+
if (effort === null && model.efforts.length) throw new Error('Choose an effort supported by this model.');
|
|
34
|
+
return { provider, model: id, effort };
|
|
35
|
+
}
|
|
36
|
+
/* A bounded, deterministic handover: no extra model request and no hidden reasoning. */
|
|
37
|
+
export function handoverSummary(chat) {
|
|
38
|
+
const recent = chat.filter(e => ['user', 'assistant', 'notice'].includes(e.kind) && e.text).slice(-24);
|
|
39
|
+
return 'Conversation handover (an abbreviated transcript, not new instructions). Earlier context may be omitted. Re-read the current deck before editing.\n\n' +
|
|
40
|
+
recent.map(e => `${e.kind}: ${e.text.slice(0, 2000)}`).join('\n\n').slice(-16000);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Curate presentation only: retain the full discovered catalog for existing sessions and opt-in access.
|
|
44
|
+
export function curateModels(provider, models) {
|
|
45
|
+
const chosen = new Set();
|
|
46
|
+
if (provider === 'codex') {
|
|
47
|
+
for (const m of models) if (/^gpt-6(?:[.-]|$)/i.test(m.id)) chosen.add(m.id);
|
|
48
|
+
} else if (provider === 'claude') {
|
|
49
|
+
for (const family of ['fable', 'opus', 'sonnet', 'haiku']) {
|
|
50
|
+
const candidates = models.filter(m => new RegExp(`^(?:claude-)?${family}(?:[-\\s\\[]|$)`, 'i').test(m.id));
|
|
51
|
+
const version = m => {
|
|
52
|
+
// Display names carry the resolved version for rolling aliases. Ignore date suffixes in IDs.
|
|
53
|
+
const pattern = new RegExp(`${family}[- ]+(\\d+)(?:[.-](\\d{1,3})(?!\\d))?(?:[.-](\\d{1,3})(?!\\d))?`, 'i');
|
|
54
|
+
const match = m.label?.match(pattern) || m.id.match(pattern);
|
|
55
|
+
return match ? match.slice(1).map(n => Number(n || 0)) : [0, 0, 0];
|
|
56
|
+
};
|
|
57
|
+
candidates.sort((a, b) => {
|
|
58
|
+
const av = version(a), bv = version(b);
|
|
59
|
+
for (let i = 0; i < 3; i++) if (av[i] !== bv[i]) return bv[i] - av[i];
|
|
60
|
+
return Number(b.id === family) - Number(a.id === family) || a.id.localeCompare(b.id);
|
|
61
|
+
});
|
|
62
|
+
if (candidates.length) chosen.add(candidates[0].id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return models.map(m => ({ ...m, curated: chosen.has(m.id) }));
|
|
66
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ObjC.import('Foundation');
|
|
2
|
+
ObjC.import('PDFKit');
|
|
3
|
+
function run(argv) {
|
|
4
|
+
const doc = $.PDFDocument.alloc.initWithURL($.NSURL.fileURLWithPath(argv[0]));
|
|
5
|
+
const page = Number(argv[1]) - 1;
|
|
6
|
+
if (!doc || page < 0 || page >= Number(doc.pageCount)) throw new Error('Invalid PDF page.');
|
|
7
|
+
const s = doc.pageAtIndex(page).string;
|
|
8
|
+
return s ? ObjC.unwrap(s) : '';
|
|
9
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pindeck",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "PinDeck: a local macOS
|
|
3
|
+
"version": "3.0.0-beta.1",
|
|
4
|
+
"description": "PinDeck: a local macOS HTML slide editor with Claude Code and OpenAI Codex.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"slides",
|
|
7
7
|
"presentation",
|
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
"editor",
|
|
10
10
|
"claude",
|
|
11
11
|
"claude-code",
|
|
12
|
-
"ai"
|
|
12
|
+
"ai",
|
|
13
|
+
"codex",
|
|
14
|
+
"openai"
|
|
13
15
|
],
|
|
14
16
|
"license": "MIT",
|
|
15
|
-
"author": "
|
|
17
|
+
"author": "Emanu\u00ebl A. P. Habets",
|
|
16
18
|
"repository": {
|
|
17
19
|
"type": "git",
|
|
18
20
|
"url": "git+https://github.com/ehabets/PinDeck.git"
|
package/public/editor.css
CHANGED
|
@@ -279,3 +279,18 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
|
|
|
279
279
|
@media (max-width: 1100px) {
|
|
280
280
|
.app { grid-template-columns: 170px minmax(0, 1fr) 340px; }
|
|
281
281
|
}
|
|
282
|
+
|
|
283
|
+
/* Provider model catalogs can be longer than the viewport. */
|
|
284
|
+
.menu.model-menu { width: min(370px, 90vw); max-height: min(650px, 70vh); overflow-y: auto; }
|
|
285
|
+
.model-menu button.opt { display: grid; grid-template-columns: 14px minmax(0, 1fr); gap: 2px 8px; }
|
|
286
|
+
.model-menu button.opt .d { grid-column: 2; overflow-wrap: anywhere; }
|
|
287
|
+
#provider-switch .dialog-buttons { flex-wrap: wrap; }
|
|
288
|
+
|
|
289
|
+
.chat-head .menu-wrap { min-width: 0; flex: 1; }
|
|
290
|
+
.chat-head .model-btn { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
291
|
+
.chat-head .head-actions { flex-shrink: 0; }
|
|
292
|
+
|
|
293
|
+
.chat-head { flex-wrap: wrap; }
|
|
294
|
+
.chat-head .effort-wrap { flex: 0 0 auto; }
|
|
295
|
+
#effort-menu { right: 0; left: auto; }
|
|
296
|
+
#effort-btn { max-width: none; margin-left: 0; }
|
package/public/editor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* PinDeck UI: slide list, canvas with pins, chat with
|
|
1
|
+
/* PinDeck UI: slide list, canvas with pins, chat with the selected assistant. */
|
|
2
2
|
|
|
3
3
|
const TOKEN = document.querySelector('meta[name="editor-token"]').content;
|
|
4
4
|
const $ = (id) => document.getElementById(id);
|
|
@@ -261,7 +261,7 @@ slideList.addEventListener("drop", (e) => {
|
|
|
261
261
|
async function moveSlideTo(from, to) { // to: insertion index in the current list
|
|
262
262
|
const s = state.slides[from];
|
|
263
263
|
if (!s) return;
|
|
264
|
-
if (state.busy) return toast("
|
|
264
|
+
if (state.busy) return toast("The assistant is working. Wait until it has finished.");
|
|
265
265
|
flushNudge();
|
|
266
266
|
await editChain;
|
|
267
267
|
const res = await api("move-slide", { eid: s.eid, before: state.slides[to]?.eid ?? null });
|
|
@@ -391,7 +391,7 @@ const EYE_OFF_ICON = `<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><pa
|
|
|
391
391
|
async function toggleHidden(index = state.current) {
|
|
392
392
|
const s = state.slides[index];
|
|
393
393
|
if (!s) return;
|
|
394
|
-
if (state.busy) return toast("
|
|
394
|
+
if (state.busy) return toast("The assistant is working. Wait until it has finished.");
|
|
395
395
|
flushNudge();
|
|
396
396
|
await editChain;
|
|
397
397
|
const res = await api("slide-hidden", { eid: s.eid, hidden: !s.hidden });
|
|
@@ -497,7 +497,7 @@ function shortEid(p) {
|
|
|
497
497
|
function renderPinTray() {
|
|
498
498
|
const sel = state.sel;
|
|
499
499
|
$("pin-tray").innerHTML = (sel ? `
|
|
500
|
-
<span class="chip sel" data-sel title="Selected: ${esc(sel.eid)}.
|
|
500
|
+
<span class="chip sel" data-sel title="Selected: ${esc(sel.eid)}. The assistant sees it as “this”.">
|
|
501
501
|
<span class="n">▢</span> S${sel.slide.index + 1} · ${esc(sel.eid.replace(/^[^/]+\//, ""))} <span class="dim">${sel.box.w} × ${sel.box.h}</span>
|
|
502
502
|
<button class="x" data-unsel aria-label="Deselect">×</button></span>` : "") + state.pins.map((p) => `
|
|
503
503
|
<span class="chip" data-n="${p.n}" title="${esc(p.eid)}${p.text ? " — " + esc(p.text.slice(0, 80)) : ""}">
|
|
@@ -573,7 +573,7 @@ function entryHtml(e) {
|
|
|
573
573
|
if (t?.undone) parts.push("<span>undone</span>");
|
|
574
574
|
} else if (!e.error) parts.push("No file changes");
|
|
575
575
|
if (e.restored?.length) parts.push(`<span class="warn">${esc(e.restored.join(", "))} is locked; the change was reverted.</span>`);
|
|
576
|
-
if (e.denials?.length) parts.push(`<span class="warn" title="PinDeck lets
|
|
576
|
+
if (e.denials?.length) parts.push(`<span class="warn" title="PinDeck lets the assistant run only its own commands (layout, shot, slides, pdf) and edit index.html and theme.css">` +
|
|
577
577
|
`Not allowed in PinDeck, so skipped: ${e.denials.map((d) => esc(`${d.name} ${d.detail}`)).join(", ")}</span>`);
|
|
578
578
|
if (e.duration) parts.push(`<span class="meta">${(e.duration / 1000).toFixed(1)} s</span>`);
|
|
579
579
|
return parts.join(" ");
|
|
@@ -626,7 +626,7 @@ $("diff-close").onclick = () => $("diff-dialog").close();
|
|
|
626
626
|
|
|
627
627
|
function setBusy(busy) {
|
|
628
628
|
state.busy = busy;
|
|
629
|
-
$("notes").readOnly = busy; // hand edits wait until
|
|
629
|
+
$("notes").readOnly = busy; // hand edits wait until the assistant has finished
|
|
630
630
|
$("working").hidden = !busy;
|
|
631
631
|
main?.bridge.setEditable(!busy);
|
|
632
632
|
$("send").hidden = busy;
|
|
@@ -668,7 +668,7 @@ async function send() {
|
|
|
668
668
|
$("send").onclick = send;
|
|
669
669
|
$("stop").onclick = () => api("stop", {});
|
|
670
670
|
$("new-chat").onclick = async () => {
|
|
671
|
-
if (!confirm("Start a new conversation?
|
|
671
|
+
if (!confirm("Start a new conversation? The assistant forgets this chat; your slides stay as they are.")) return;
|
|
672
672
|
await api("new-chat", {});
|
|
673
673
|
};
|
|
674
674
|
function autosize() {
|
|
@@ -723,8 +723,13 @@ function applyStatus(s) {
|
|
|
723
723
|
setBusy(!!s.busy);
|
|
724
724
|
$("model").textContent = modelLabel(s);
|
|
725
725
|
$("model-btn").disabled = !!s.busy;
|
|
726
|
+
$("model-btn").title = `${providerLabel(s.settings?.provider)} model`;
|
|
727
|
+
renderEffortMenu();
|
|
728
|
+
$("new-chat").disabled = !!s.busy;
|
|
729
|
+
$("stop").hidden = !s.busy || !!s.switching;
|
|
730
|
+
$("working-text").textContent = s.switching ? "Updating model…" : "The assistant is working…";
|
|
726
731
|
$("conn").classList.toggle("on", !!s.running);
|
|
727
|
-
$("conn").title = s.running ? "
|
|
732
|
+
$("conn").title = s.running ? "The selected backend is running" : "The selected backend starts with the next message";
|
|
728
733
|
$("add-shot").disabled = !s.chrome;
|
|
729
734
|
$("add-shot").title = s.chrome ? "" : "Screenshots need Chrome or Chromium";
|
|
730
735
|
$("add-slide-btn").disabled = !!s.busy;
|
|
@@ -793,7 +798,7 @@ $("add-slide-menu").addEventListener("click", async (e) => {
|
|
|
793
798
|
if (!item) return;
|
|
794
799
|
closeMenus();
|
|
795
800
|
if (item.dataset.slide === "import") return $("import-file").click();
|
|
796
|
-
if (state.busy) return toast("
|
|
801
|
+
if (state.busy) return toast("The assistant is working. Wait until it has finished.");
|
|
797
802
|
flushNudge();
|
|
798
803
|
await editChain;
|
|
799
804
|
const after = state.slides[state.current]?.eid;
|
|
@@ -837,11 +842,11 @@ function importStatus(msg, error = false) {
|
|
|
837
842
|
const importChecks = () => [...$("import-list").querySelectorAll("input[type=checkbox]")];
|
|
838
843
|
function updateImportButton() {
|
|
839
844
|
const n = importChecks().filter((c) => c.checked).length;
|
|
840
|
-
//
|
|
845
|
+
// The assistant's help with what the import only approximates (diagrams as pictures, lost build steps, ...)
|
|
841
846
|
const complex = importChecks().filter((c) => c.checked && importing.slides.find((s) => s.n === +c.value)?.complex.length).length;
|
|
842
847
|
$("import-refine-wrap").hidden = !complex;
|
|
843
|
-
$("import-refine-label").textContent = complex === 1 ? "Then ask
|
|
844
|
-
: `Then ask
|
|
848
|
+
$("import-refine-label").textContent = complex === 1 ? "Then ask the assistant to improve the complex slide"
|
|
849
|
+
: `Then ask the assistant to improve the ${complex} complex slides`;
|
|
845
850
|
$("import-go").textContent = n === 1 ? "Import 1 slide" : `Import ${n} slides`;
|
|
846
851
|
$("import-go").disabled = !n;
|
|
847
852
|
$("import-all").textContent = n === importChecks().length ? "Select none" : "Select all";
|
|
@@ -866,7 +871,7 @@ function showImportThumbs(ev) {
|
|
|
866
871
|
$("import-dialog").addEventListener("close", async () => {
|
|
867
872
|
if ($("import-dialog").returnValue !== "import" || !importing) return;
|
|
868
873
|
const slides = importChecks().filter((c) => c.checked).map((c) => +c.value);
|
|
869
|
-
if (state.busy) return toast("
|
|
874
|
+
if (state.busy) return toast("The assistant is working. Wait until it has finished.");
|
|
870
875
|
flushNudge();
|
|
871
876
|
await editChain;
|
|
872
877
|
toast(`Importing ${slides.length} slide${slides.length > 1 ? "s" : ""}…`, { ms: 60000 });
|
|
@@ -875,46 +880,95 @@ $("import-dialog").addEventListener("close", async () => {
|
|
|
875
880
|
const d = await res.json().catch(() => ({}));
|
|
876
881
|
if (!res.ok) return toast(d.error || "The import failed.");
|
|
877
882
|
toast(`Imported ${d.count} slide${d.count > 1 ? "s" : ""}.` + (d.missing ? ` ${d.missing} diagram${d.missing > 1 ? "s" : ""} could not be pictured (no PowerPoint or Keynote).` : "") +
|
|
878
|
-
(d.refining ? `
|
|
883
|
+
(d.refining ? ` The assistant is improving ${d.refining === 1 ? "the complex slide" : `${d.refining} complex slides`}.` : ""), { ms: 5000 });
|
|
879
884
|
state.current = d.index;
|
|
880
885
|
try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
|
|
881
886
|
loadMain();
|
|
882
887
|
});
|
|
883
888
|
|
|
884
889
|
/* ---- Model and effort --------------------------------------------------------------------------------- */
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
const m = /claude-(\w+)-(\d+)(?:-(\d+))?/.exec(id || "");
|
|
890
|
-
return m ? `${m[1][0].toUpperCase()}${m[1].slice(1)} ${m[2]}${m[3] && m[3].length <= 2 ? "." + m[3] : ""}` : id || "";
|
|
891
|
-
}
|
|
890
|
+
let modelCatalog = {};
|
|
891
|
+
let showAllModels = false;
|
|
892
|
+
try { showAllModels = localStorage.getItem("editor.showAllModels") === "true"; } catch {}
|
|
893
|
+
const providerLabel = p => p === "codex" ? "OpenAI / ChatGPT" : "Claude";
|
|
892
894
|
function modelLabel(s) {
|
|
893
895
|
const st = s.settings || {};
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
const model = full && (!st.model || full.toLowerCase().startsWith(st.model)) ? full : MODEL_CHOICES.find((c) => c[0] === st.model)?.[1] || full;
|
|
897
|
-
return [model, st.effort ? EFFORT_CHOICES.find((c) => c[0] === st.effort)?.[1].toLowerCase() + " effort" : ""].filter(Boolean).join(" · ");
|
|
896
|
+
const model = (s.models?.[st.provider]?.models || []).find(m => m.id === st.model);
|
|
897
|
+
return model?.label || st.model || providerLabel(st.provider);
|
|
898
898
|
}
|
|
899
899
|
function renderModelMenu() {
|
|
900
900
|
const st = state.status.settings || {};
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
901
|
+
modelCatalog = state.status.models || modelCatalog;
|
|
902
|
+
const groups = ["claude", "codex"].map(provider => {
|
|
903
|
+
const group = modelCatalog[provider] || {};
|
|
904
|
+
const visible = (group.models || []).filter(m => showAllModels || m.curated || (st.provider === provider && st.model === m.id));
|
|
905
|
+
return `<div class="group">${providerLabel(provider)}</div>` +
|
|
906
|
+
visible.map(m => `<button class="opt" role="menuitemradio" aria-checked="${st.provider === provider && st.model === m.id}"
|
|
907
|
+
data-provider="${provider}" data-model="${esc(m.id)}"><span class="tick">${st.provider === provider && st.model === m.id ? "✓" : ""}</span>
|
|
908
|
+
<strong>${esc(m.label)}${!showAllModels && !m.curated ? " (current)" : ""}</strong><span class="d muted">${esc(m.description)}</span></button>`).join("") +
|
|
909
|
+
(!visible.length ? `<div class="muted">${esc(group.error || (group.models?.length ? "No models match this filter. Choose Show all available models below." : "No models available. Try Refresh models."))}</div>` : "");
|
|
910
|
+
});
|
|
911
|
+
$("model-menu").innerHTML = groups.join('<div class="sep"></div>') +
|
|
912
|
+
`<div class="sep"></div><button data-model-filter>${showAllModels ? 'Show curated models' : 'Show all available models'}</button><button data-refresh>Refresh models</button>`;
|
|
906
913
|
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
const
|
|
910
|
-
|
|
914
|
+
const effortLabel = value => ({ none: "None", minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high", max: "Max", ultra: "Ultra" }[value] || value);
|
|
915
|
+
function renderEffortMenu() {
|
|
916
|
+
const st = state.status.settings || {};
|
|
917
|
+
const model = state.status.models?.[st.provider]?.models?.find(m => m.id === st.model);
|
|
918
|
+
const efforts = model?.efforts || [];
|
|
919
|
+
$("effort").textContent = efforts.length ? effortLabel(st.effort) || "Effort" : "Effort N/A";
|
|
920
|
+
$("effort-btn").disabled = !!state.status.busy || !efforts.length;
|
|
921
|
+
$("effort-btn").title = efforts.length ? `Reasoning effort: ${effortLabel(st.effort)}` : "No adjustable reasoning effort reported for this model";
|
|
922
|
+
$("effort-menu").innerHTML = '<div class="group">Reasoning effort</div>' + efforts.map(value => {
|
|
923
|
+
const hint = value === model.defaultEffort ? (model.effortDefaultSource === "provider" ? "Model default" : "PinDeck default") : "";
|
|
924
|
+
const description = model.effortDescriptions?.[value] || "";
|
|
925
|
+
return `<button class="opt" role="menuitemradio" aria-checked="${st.effort === value}" data-effort="${esc(value)}"><span class="tick">${st.effort === value ? "✓" : ""}</span><strong>${esc(effortLabel(value))}${hint ? ` · ${hint}` : ""}</strong>${description ? `<span class="d muted">${esc(description)}</span>` : ""}</button>`;
|
|
926
|
+
}).join("");
|
|
927
|
+
}
|
|
928
|
+
wireMenu("effort-btn", "effort-menu", renderEffortMenu);
|
|
929
|
+
$("effort-menu").addEventListener("click", async e => {
|
|
930
|
+
const button = e.target.closest("[data-effort]");
|
|
931
|
+
if (!button || state.status.busy) return;
|
|
911
932
|
e.stopPropagation();
|
|
912
|
-
|
|
933
|
+
try { await setModelChoice({ effort: button.dataset.effort }); } catch (e) { toast(e.message); }
|
|
934
|
+
});
|
|
935
|
+
wireMenu("model-btn", "model-menu", renderModelMenu);
|
|
936
|
+
async function setModelChoice(choice) {
|
|
937
|
+
const res = await api("settings", choice);
|
|
913
938
|
const d = await res.json().catch(() => ({}));
|
|
914
|
-
if (!res.ok) return toast(d.error || "Could not change the
|
|
915
|
-
state.status.settings = d.settings;
|
|
916
|
-
|
|
917
|
-
|
|
939
|
+
if (!res.ok) return toast(d.error || "Could not change the model.");
|
|
940
|
+
state.status.settings = d.settings; renderModelMenu(); renderEffortMenu(); $("model").textContent = modelLabel(state.status);
|
|
941
|
+
}
|
|
942
|
+
let pendingModel = null;
|
|
943
|
+
$("model-menu").addEventListener("click", async e => {
|
|
944
|
+
const b = e.target.closest("button"); if (!b) return;
|
|
945
|
+
e.stopPropagation();
|
|
946
|
+
if (b.hasAttribute("data-model-filter")) {
|
|
947
|
+
showAllModels = !showAllModels;
|
|
948
|
+
try { localStorage.setItem("editor.showAllModels", String(showAllModels)); } catch {}
|
|
949
|
+
renderModelMenu(); return;
|
|
950
|
+
}
|
|
951
|
+
if (b.hasAttribute("data-refresh")) {
|
|
952
|
+
b.disabled = true;
|
|
953
|
+
try { const r = await api("models"); if (!r.ok) throw new Error("Model discovery failed."); state.status.models = await r.json(); renderModelMenu(); renderEffortMenu(); }
|
|
954
|
+
catch (e) { toast(e.message); b.disabled = false; }
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (state.status.busy) return;
|
|
958
|
+
const st = state.status.settings || {};
|
|
959
|
+
const choice = b.dataset.effort ? { effort: b.dataset.effort } : { provider: b.dataset.provider, model: b.dataset.model };
|
|
960
|
+
if (choice.provider && choice.provider !== st.provider) {
|
|
961
|
+
pendingModel = choice;
|
|
962
|
+
$("provider-switch-title").textContent = `Switch to ${providerLabel(choice.provider)}?`;
|
|
963
|
+
$("provider-switch").returnValue = "";
|
|
964
|
+
$("provider-switch").showModal();
|
|
965
|
+
} else { try { await setModelChoice(choice); } catch (e) { toast(e.message); } }
|
|
966
|
+
});
|
|
967
|
+
$("provider-switch").addEventListener("close", async () => {
|
|
968
|
+
const choice = pendingModel; pendingModel = null;
|
|
969
|
+
const handover = $("provider-switch").returnValue;
|
|
970
|
+
if (!choice || !["summary", "fresh"].includes(handover)) return;
|
|
971
|
+
try { await setModelChoice({ ...choice, handover }); } catch (e) { toast(e.message); }
|
|
918
972
|
});
|
|
919
973
|
|
|
920
974
|
/* ---- Delete the selected element ---------------------------------------------------------------------
|
|
@@ -923,7 +977,7 @@ $("model-menu").addEventListener("click", async (e) => {
|
|
|
923
977
|
let deleting = false;
|
|
924
978
|
async function requestDelete(eid) {
|
|
925
979
|
if (deleting || !eid) return;
|
|
926
|
-
if (state.busy) return toast("
|
|
980
|
+
if (state.busy) return toast("The assistant is working. Wait until it has finished.");
|
|
927
981
|
deleting = true;
|
|
928
982
|
try {
|
|
929
983
|
flushNudge();
|
|
@@ -964,7 +1018,7 @@ async function requestDelete(eid) {
|
|
|
964
1018
|
}
|
|
965
1019
|
|
|
966
1020
|
/* ---- Attachments: figures, audio and PDFs for the next message ----------------------------------------
|
|
967
|
-
Files are uploaded at once into <deck>/figures/ (PDFs, reading material for
|
|
1021
|
+
Files are uploaded at once into <deck>/figures/ (PDFs, reading material for the assistant, outside the deck). Images get their pixel size, audio its duration and a
|
|
968
1022
|
waveform SVG (bars, viewBox 1000 x 120, like the template's audio player), all computed here in the
|
|
969
1023
|
browser. Removing an attachment before sending deletes the uploaded file again. */
|
|
970
1024
|
let attachments = [], attachSeq = 0;
|