@kywk/open-slide-core 2.3.1 → 2.3.3
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/dist/{build-CZ6Qtrng.js → build-46dO14h1.js} +1 -1
- package/dist/cli/bin.js +4 -4
- package/dist/{config-CpmGqpQi.d.ts → config-DJ8ZOe7C.d.ts} +6 -2
- package/dist/{config-B5LArqpp.js → config-KyYvWqq-.js} +2061 -355
- package/dist/{dev-CCgdakbZ.js → dev-C0n-o0-c.js} +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +73 -32
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +1 -1
- package/dist/{preview-AwAqvG17.js → preview-CDWAGSfB.js} +1 -1
- package/dist/{types-CvAqFqsY.d.ts → types-tdaXSyah.d.ts} +111 -0
- package/dist/{update-C_RlnKu4.js → update-CqZqQRQk.js} +128 -7
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +1 -1
- package/dist/{zh-tw-B7_Oqv2q.js → zh-tw-DXcqQTrA.js} +540 -0
- package/package.json +10 -3
- package/src/agent/prompt.ts +141 -0
- package/src/agent/provider.ts +123 -0
- package/src/agent/types.ts +151 -0
- package/src/app/components/agent/agent-dock.tsx +35 -0
- package/src/app/components/agent/agent-markdown.tsx +45 -0
- package/src/app/components/agent/agent-panel.tsx +692 -0
- package/src/app/components/agent/agent-presence.tsx +74 -0
- package/src/app/components/agent/composer.tsx +120 -0
- package/src/app/components/agent/connect-view.tsx +184 -0
- package/src/app/components/agent/history-view.tsx +135 -0
- package/src/app/components/agent/provider-logo.tsx +34 -0
- package/src/app/components/agent/transcript.tsx +609 -0
- package/src/app/components/command/slide-command-menu.tsx +10 -0
- package/src/app/components/inline-svg.tsx +52 -21
- package/src/app/components/inspector/comment-layer.tsx +20 -1
- package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
- package/src/app/components/inspector/inspector-panel.tsx +1 -1
- package/src/app/components/mermaid.tsx +42 -11
- package/src/app/components/panel/editor-sidebar.tsx +85 -16
- package/src/app/components/panel/panel-shell.tsx +2 -2
- package/src/app/components/panel/resize-handle.tsx +129 -0
- package/src/app/components/player.tsx +105 -23
- package/src/app/components/present/curtain.tsx +52 -0
- package/src/app/components/present/last-slide-hint.tsx +36 -0
- package/src/app/components/slide-canvas.tsx +1 -1
- package/src/app/components/thumbnail-rail.tsx +1 -1
- package/src/app/lib/agent/use-agent.ts +543 -0
- package/src/app/lib/use-auto-grow.ts +15 -0
- package/src/app/lib/utils.ts +4 -0
- package/src/app/routes/home.tsx +51 -7
- package/src/app/routes/slide.tsx +172 -116
- package/src/app/styles.css +170 -0
- package/src/editing/placeholder-id.ts +1 -0
- package/src/locale/en.ts +107 -0
- package/src/locale/es.ts +109 -0
- package/src/locale/ja.ts +100 -0
- package/src/locale/types.ts +110 -0
- package/src/locale/zh-cn.ts +97 -0
- package/src/locale/zh-tw.ts +97 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { useCallback, useEffect, useSyncExternalStore } from 'react';
|
|
2
|
+
import { toast } from 'sonner';
|
|
3
|
+
import { useLocale } from '@/lib/use-locale';
|
|
4
|
+
import {
|
|
5
|
+
type AgentEffort,
|
|
6
|
+
type AgentItem,
|
|
7
|
+
type AgentModelOption,
|
|
8
|
+
type AgentProviderId,
|
|
9
|
+
type AgentSession,
|
|
10
|
+
type AgentSessionSummary,
|
|
11
|
+
type AgentStatus,
|
|
12
|
+
type AgentStreamEvent,
|
|
13
|
+
type AgentTurnContext,
|
|
14
|
+
isAgentEffort,
|
|
15
|
+
isAgentProviderId,
|
|
16
|
+
type RespondPayload,
|
|
17
|
+
} from '../../../agent/types';
|
|
18
|
+
|
|
19
|
+
type ModelChoice = { model: string; effort: AgentEffort | null };
|
|
20
|
+
|
|
21
|
+
type AgentRequest = { text: string };
|
|
22
|
+
|
|
23
|
+
type State = {
|
|
24
|
+
status: AgentStatus | null;
|
|
25
|
+
statusError: boolean;
|
|
26
|
+
sessions: AgentSessionSummary[];
|
|
27
|
+
sessionsLoaded: boolean;
|
|
28
|
+
full: Record<string, AgentSession>;
|
|
29
|
+
activeId: string | null;
|
|
30
|
+
provider: AgentProviderId;
|
|
31
|
+
modelChoices: Partial<Record<AgentProviderId, ModelChoice>>;
|
|
32
|
+
autoApprove: boolean;
|
|
33
|
+
dockOpen: boolean;
|
|
34
|
+
request: AgentRequest | null;
|
|
35
|
+
installing: AgentProviderId | null;
|
|
36
|
+
connectErrors: Partial<Record<AgentProviderId, string>>;
|
|
37
|
+
newDeck: string | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const ACTIVE_KEY = 'open-slide:agent-session';
|
|
41
|
+
const PROVIDER_KEY = 'open-slide:agent-provider';
|
|
42
|
+
const MODEL_KEY = 'open-slide:agent-model';
|
|
43
|
+
const AUTO_APPROVE_KEY = 'open-slide:agent-auto-approve';
|
|
44
|
+
const DOCK_KEY = 'open-slide:agent-dock';
|
|
45
|
+
const NEW_DECK_KEY = 'open-slide:agent-new-deck';
|
|
46
|
+
|
|
47
|
+
function readStorage(key: string): string | null {
|
|
48
|
+
try {
|
|
49
|
+
return localStorage.getItem(key);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeStorage(key: string, value: string | null) {
|
|
56
|
+
try {
|
|
57
|
+
if (value === null) localStorage.removeItem(key);
|
|
58
|
+
else localStorage.setItem(key, value);
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function basePath(): string {
|
|
63
|
+
return import.meta.env.BASE_URL.replace(/\/$/, '');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readModelChoices(): Partial<Record<AgentProviderId, ModelChoice>> {
|
|
67
|
+
const choices: Partial<Record<AgentProviderId, ModelChoice>> = {};
|
|
68
|
+
try {
|
|
69
|
+
const raw = JSON.parse(readStorage(MODEL_KEY) ?? '{}') as Record<string, unknown>;
|
|
70
|
+
for (const [provider, value] of Object.entries(raw)) {
|
|
71
|
+
if (!isAgentProviderId(provider) || !value || typeof value !== 'object') continue;
|
|
72
|
+
const v = value as Record<string, unknown>;
|
|
73
|
+
if (typeof v.model !== 'string') continue;
|
|
74
|
+
choices[provider] = { model: v.model, effort: isAgentEffort(v.effort) ? v.effort : null };
|
|
75
|
+
}
|
|
76
|
+
} catch {}
|
|
77
|
+
return choices;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const available = import.meta.env.DEV && typeof window !== 'undefined';
|
|
81
|
+
|
|
82
|
+
let state: State = {
|
|
83
|
+
status: null,
|
|
84
|
+
statusError: false,
|
|
85
|
+
sessions: [],
|
|
86
|
+
sessionsLoaded: false,
|
|
87
|
+
full: {},
|
|
88
|
+
activeId: readStorage(ACTIVE_KEY),
|
|
89
|
+
provider: (() => {
|
|
90
|
+
const stored = readStorage(PROVIDER_KEY);
|
|
91
|
+
return isAgentProviderId(stored) ? stored : 'claude';
|
|
92
|
+
})(),
|
|
93
|
+
modelChoices: readModelChoices(),
|
|
94
|
+
autoApprove: readStorage(AUTO_APPROVE_KEY) === '1',
|
|
95
|
+
dockOpen: readStorage(DOCK_KEY) === '1',
|
|
96
|
+
request: null,
|
|
97
|
+
installing: null,
|
|
98
|
+
connectErrors: {},
|
|
99
|
+
newDeck: readStorage(NEW_DECK_KEY),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const listeners = new Set<() => void>();
|
|
103
|
+
|
|
104
|
+
function setState(patch: Partial<State> | ((prev: State) => Partial<State>)) {
|
|
105
|
+
const next = typeof patch === 'function' ? patch(state) : patch;
|
|
106
|
+
state = { ...state, ...next };
|
|
107
|
+
for (const l of listeners) l();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function subscribe(listener: () => void): () => void {
|
|
111
|
+
listeners.add(listener);
|
|
112
|
+
return () => {
|
|
113
|
+
listeners.delete(listener);
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getSnapshot(): State {
|
|
118
|
+
return state;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function useAgentState(): State {
|
|
122
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let stream: EventSource | null = null;
|
|
126
|
+
let streamOpened = false;
|
|
127
|
+
|
|
128
|
+
function syncStream(enabled: boolean) {
|
|
129
|
+
if (!enabled) {
|
|
130
|
+
stream?.close();
|
|
131
|
+
stream = null;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (stream) return;
|
|
135
|
+
stream = new EventSource('/__agent/events');
|
|
136
|
+
stream.onmessage = (message) => applyEvent(JSON.parse(message.data) as AgentStreamEvent);
|
|
137
|
+
stream.onopen = () => {
|
|
138
|
+
// Anything streamed while disconnected is gone, so reload what is on screen.
|
|
139
|
+
if (!streamOpened) {
|
|
140
|
+
streamOpened = true;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
void refreshStatus();
|
|
144
|
+
if (state.sessionsLoaded) void loadSessions();
|
|
145
|
+
for (const id of Object.keys(state.full)) void loadSession(id);
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function upsertSummary(list: AgentSessionSummary[], summary: AgentSessionSummary) {
|
|
150
|
+
const rest = list.filter((s) => s.id !== summary.id);
|
|
151
|
+
return [summary, ...rest].sort((a, b) =>
|
|
152
|
+
a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function applyEvent(event: AgentStreamEvent) {
|
|
157
|
+
switch (event.type) {
|
|
158
|
+
case 'session':
|
|
159
|
+
setState((prev) => {
|
|
160
|
+
const current = prev.full[event.session.id];
|
|
161
|
+
return {
|
|
162
|
+
sessions: upsertSummary(prev.sessions, event.session),
|
|
163
|
+
full: current
|
|
164
|
+
? { ...prev.full, [event.session.id]: { ...current, ...event.session } }
|
|
165
|
+
: prev.full,
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
case 'session-removed':
|
|
170
|
+
setState((prev) => {
|
|
171
|
+
const { [event.sessionId]: _gone, ...full } = prev.full;
|
|
172
|
+
return {
|
|
173
|
+
sessions: prev.sessions.filter((s) => s.id !== event.sessionId),
|
|
174
|
+
full,
|
|
175
|
+
activeId: prev.activeId === event.sessionId ? null : prev.activeId,
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
case 'item':
|
|
180
|
+
setState((prev) => {
|
|
181
|
+
const current = prev.full[event.sessionId];
|
|
182
|
+
if (!current) return {};
|
|
183
|
+
const index = current.items.findIndex((i) => i.id === event.item.id);
|
|
184
|
+
const items =
|
|
185
|
+
index === -1
|
|
186
|
+
? [...current.items, event.item]
|
|
187
|
+
: current.items.map((i, n) => (n === index ? event.item : i));
|
|
188
|
+
return { full: { ...prev.full, [event.sessionId]: { ...current, items } } };
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
case 'truncate':
|
|
192
|
+
setState((prev) => {
|
|
193
|
+
const current = prev.full[event.sessionId];
|
|
194
|
+
const index = current?.items.findIndex((i) => i.id === event.itemId) ?? -1;
|
|
195
|
+
if (!current || index === -1) return {};
|
|
196
|
+
const items = current.items.slice(0, index);
|
|
197
|
+
return { full: { ...prev.full, [event.sessionId]: { ...current, items } } };
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
case 'slide-renamed':
|
|
201
|
+
followSlideRename(event.from, event.to);
|
|
202
|
+
return;
|
|
203
|
+
case 'delta':
|
|
204
|
+
setState((prev) => {
|
|
205
|
+
const current = prev.full[event.sessionId];
|
|
206
|
+
if (!current) return {};
|
|
207
|
+
const items = current.items.map((i): AgentItem => {
|
|
208
|
+
if (i.id !== event.itemId || i.kind !== 'assistant') return i;
|
|
209
|
+
return { ...i, text: i.text + event.text };
|
|
210
|
+
});
|
|
211
|
+
return { full: { ...prev.full, [event.sessionId]: { ...current, items } } };
|
|
212
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function followSlideRename(from: string, to: string) {
|
|
218
|
+
const base = basePath();
|
|
219
|
+
const prefix = `${base}/s/${encodeURIComponent(from)}`;
|
|
220
|
+
const { pathname, search, hash } = window.location;
|
|
221
|
+
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
|
|
222
|
+
const rest = pathname.slice(prefix.length);
|
|
223
|
+
window.location.replace(`${base}/s/${encodeURIComponent(to)}${rest}${search}${hash}`);
|
|
224
|
+
} else if (!pathname.startsWith(`${base}/s/`)) {
|
|
225
|
+
// The slide list is baked into the bundle, so pages listing decks reload
|
|
226
|
+
// to pick up the new id. Another deck's page may hold unsaved edits, so it
|
|
227
|
+
// is left alone and catches up on its next load.
|
|
228
|
+
window.location.reload();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
233
|
+
const res = await fetch(path, init);
|
|
234
|
+
if (!res.ok) {
|
|
235
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
236
|
+
throw new Error(body.error ?? `${init?.method ?? 'GET'} ${path} → ${res.status}`);
|
|
237
|
+
}
|
|
238
|
+
return (await res.json()) as T;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function post(body?: unknown): RequestInit {
|
|
242
|
+
return body === undefined
|
|
243
|
+
? { method: 'POST' }
|
|
244
|
+
: {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers: { 'content-type': 'application/json' },
|
|
247
|
+
body: JSON.stringify(body),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function refreshStatus(): Promise<void> {
|
|
252
|
+
if (!available) return;
|
|
253
|
+
try {
|
|
254
|
+
const status = await request<AgentStatus>('/__agent/status');
|
|
255
|
+
setState({ status, statusError: false });
|
|
256
|
+
syncStream(status.enabled);
|
|
257
|
+
} catch {
|
|
258
|
+
setState({ statusError: true });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function loadSessions(): Promise<void> {
|
|
263
|
+
if (!available) return;
|
|
264
|
+
try {
|
|
265
|
+
const { sessions } = await request<{ sessions: AgentSessionSummary[] }>('/__agent/sessions');
|
|
266
|
+
setState((prev) => ({
|
|
267
|
+
sessions,
|
|
268
|
+
sessionsLoaded: true,
|
|
269
|
+
activeId:
|
|
270
|
+
prev.activeId && !sessions.some((s) => s.id === prev.activeId) ? null : prev.activeId,
|
|
271
|
+
}));
|
|
272
|
+
} catch {
|
|
273
|
+
setState({ sessionsLoaded: true });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function loadSession(id: string): Promise<AgentSession | null> {
|
|
278
|
+
if (!available) return null;
|
|
279
|
+
try {
|
|
280
|
+
const session = await request<AgentSession>(`/__agent/sessions/${encodeURIComponent(id)}`);
|
|
281
|
+
setState((prev) => ({ full: { ...prev.full, [id]: session } }));
|
|
282
|
+
return session;
|
|
283
|
+
} catch {
|
|
284
|
+
setState((prev) => {
|
|
285
|
+
const { [id]: _gone, ...full } = prev.full;
|
|
286
|
+
return { full, activeId: prev.activeId === id ? null : prev.activeId };
|
|
287
|
+
});
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function createSession(
|
|
293
|
+
provider: AgentProviderId,
|
|
294
|
+
slideId: string | null,
|
|
295
|
+
): Promise<AgentSession> {
|
|
296
|
+
const session = await request<AgentSession>('/__agent/sessions', post({ provider, slideId }));
|
|
297
|
+
setState((prev) => ({
|
|
298
|
+
full: { ...prev.full, [session.id]: session },
|
|
299
|
+
sessions: upsertSummary(prev.sessions, session),
|
|
300
|
+
}));
|
|
301
|
+
setActiveSession(session.id);
|
|
302
|
+
return session;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function deleteSession(id: string): Promise<void> {
|
|
306
|
+
await request(`/__agent/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
307
|
+
applyEvent({ type: 'session-removed', sessionId: id });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function turnSettings(id: string) {
|
|
311
|
+
const provider = state.full[id]?.provider ?? state.provider;
|
|
312
|
+
const status = state.status?.providers.find((p) => p.id === provider);
|
|
313
|
+
const choice = status ? resolveModelChoice(status.models, state.modelChoices[provider]) : null;
|
|
314
|
+
return {
|
|
315
|
+
autoApprove: state.autoApprove,
|
|
316
|
+
model: choice?.model.id || null,
|
|
317
|
+
effort: choice?.effort ?? null,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function sendMessage(
|
|
322
|
+
id: string,
|
|
323
|
+
text: string,
|
|
324
|
+
context: AgentTurnContext | undefined,
|
|
325
|
+
edit?: string,
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
await request(
|
|
328
|
+
`/__agent/sessions/${encodeURIComponent(id)}/messages`,
|
|
329
|
+
post({ text, context, ...turnSettings(id), edit }),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function stopSession(id: string): Promise<void> {
|
|
334
|
+
await request(`/__agent/sessions/${encodeURIComponent(id)}/stop`, post());
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Answers to questions that ended the turn start the next one, so they carry
|
|
338
|
+
// the same settings and context a new message would.
|
|
339
|
+
export async function respond(
|
|
340
|
+
id: string,
|
|
341
|
+
itemId: string,
|
|
342
|
+
payload: RespondPayload,
|
|
343
|
+
context: AgentTurnContext | undefined,
|
|
344
|
+
): Promise<void> {
|
|
345
|
+
await request(
|
|
346
|
+
`/__agent/sessions/${encodeURIComponent(id)}/respond`,
|
|
347
|
+
post({ itemId, ...payload, context, ...turnSettings(id) }),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function installProvider(provider: AgentProviderId): Promise<void> {
|
|
352
|
+
setState((prev) => ({
|
|
353
|
+
installing: provider,
|
|
354
|
+
connectErrors: { ...prev.connectErrors, [provider]: undefined },
|
|
355
|
+
}));
|
|
356
|
+
try {
|
|
357
|
+
await request('/__agent/install', post({ provider }));
|
|
358
|
+
await refreshStatus();
|
|
359
|
+
} catch (err) {
|
|
360
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
361
|
+
setState((prev) => ({ connectErrors: { ...prev.connectErrors, [provider]: message } }));
|
|
362
|
+
throw err;
|
|
363
|
+
} finally {
|
|
364
|
+
setState({ installing: null });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let autoConnecting: Promise<void> | null = null;
|
|
369
|
+
|
|
370
|
+
export function autoConnect(): Promise<void> {
|
|
371
|
+
if (autoConnecting) return autoConnecting;
|
|
372
|
+
const status = state.status;
|
|
373
|
+
if (!status?.enabled) return Promise.resolve();
|
|
374
|
+
const pending = status.providers.filter(
|
|
375
|
+
(p) => p.cliFound && !p.ready && !state.connectErrors[p.id],
|
|
376
|
+
);
|
|
377
|
+
if (pending.length === 0) return Promise.resolve();
|
|
378
|
+
autoConnecting = (async () => {
|
|
379
|
+
for (const p of pending) {
|
|
380
|
+
await installProvider(p.id).catch(() => {});
|
|
381
|
+
}
|
|
382
|
+
})().finally(() => {
|
|
383
|
+
autoConnecting = null;
|
|
384
|
+
});
|
|
385
|
+
return autoConnecting;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function setActiveSession(id: string | null): void {
|
|
389
|
+
writeStorage(ACTIVE_KEY, id);
|
|
390
|
+
setState({ activeId: id });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function setModelChoice(provider: AgentProviderId, choice: ModelChoice): void {
|
|
394
|
+
const modelChoices = { ...state.modelChoices, [provider]: choice };
|
|
395
|
+
writeStorage(MODEL_KEY, JSON.stringify(modelChoices));
|
|
396
|
+
writeStorage(PROVIDER_KEY, provider);
|
|
397
|
+
setState({ modelChoices, provider });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function resolveModelChoice(
|
|
401
|
+
models: AgentModelOption[],
|
|
402
|
+
choice: ModelChoice | undefined,
|
|
403
|
+
): { model: AgentModelOption; effort: AgentEffort | null } | null {
|
|
404
|
+
const model = models.find((m) => m.id === choice?.model) ?? models[0];
|
|
405
|
+
if (!model) return null;
|
|
406
|
+
const effort = choice?.effort && model.efforts.includes(choice.effort) ? choice.effort : null;
|
|
407
|
+
return { model, effort };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function setAutoApprove(value: boolean): void {
|
|
411
|
+
writeStorage(AUTO_APPROVE_KEY, value ? '1' : null);
|
|
412
|
+
setState({ autoApprove: value });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function setDockOpen(open: boolean): void {
|
|
416
|
+
writeStorage(DOCK_KEY, open ? '1' : null);
|
|
417
|
+
setState({ dockOpen: open });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function requestAgent(req: AgentRequest): void {
|
|
421
|
+
setState({ request: req, dockOpen: true });
|
|
422
|
+
writeStorage(DOCK_KEY, '1');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// The blank deck is created up front and the user lands in it with the dock
|
|
426
|
+
// open; their first message there becomes the brief the agent drafts from.
|
|
427
|
+
let creatingDeck: Promise<void> | null = null;
|
|
428
|
+
|
|
429
|
+
function startBlankDeck(): Promise<void> {
|
|
430
|
+
creatingDeck ??= (async () => {
|
|
431
|
+
const { slideId } = await request<{ slideId: string }>('/__slides', post());
|
|
432
|
+
writeStorage(NEW_DECK_KEY, slideId);
|
|
433
|
+
setActiveSession(null);
|
|
434
|
+
setDockOpen(true);
|
|
435
|
+
window.location.assign(`${basePath()}/s/${encodeURIComponent(slideId)}`);
|
|
436
|
+
})().finally(() => {
|
|
437
|
+
creatingDeck = null;
|
|
438
|
+
});
|
|
439
|
+
return creatingDeck;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function useCreateDeck(): () => Promise<void> {
|
|
443
|
+
const t = useLocale();
|
|
444
|
+
return useCallback(async () => {
|
|
445
|
+
try {
|
|
446
|
+
await startBlankDeck();
|
|
447
|
+
} catch (err) {
|
|
448
|
+
toast.error(t.agent.toastCreateDeckFailed, {
|
|
449
|
+
description: err instanceof Error ? err.message : undefined,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}, [t]);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function clearNewDeck(): void {
|
|
456
|
+
writeStorage(NEW_DECK_KEY, null);
|
|
457
|
+
setState({ newDeck: null });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function consumeRequest(): AgentRequest | null {
|
|
461
|
+
const req = state.request;
|
|
462
|
+
if (req) setState({ request: null });
|
|
463
|
+
return req;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function readyProviders(status: AgentStatus | null) {
|
|
467
|
+
return status?.enabled ? status.providers.filter((p) => p.ready) : [];
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function resolveProvider(status: AgentStatus | null, preferred: AgentProviderId) {
|
|
471
|
+
const ready = readyProviders(status);
|
|
472
|
+
return ready.find((p) => p.id === preferred) ?? ready[0] ?? null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function useAgentStatus() {
|
|
476
|
+
const s = useAgentState();
|
|
477
|
+
useEffect(() => {
|
|
478
|
+
if (!available || s.status) return;
|
|
479
|
+
void refreshStatus();
|
|
480
|
+
}, [s.status]);
|
|
481
|
+
const provider = resolveProvider(s.status, s.provider);
|
|
482
|
+
const choice = provider ? resolveModelChoice(provider.models, s.modelChoices[provider.id]) : null;
|
|
483
|
+
return {
|
|
484
|
+
status: s.status,
|
|
485
|
+
loading: s.status === null && !s.statusError,
|
|
486
|
+
enabled: s.status?.enabled ?? false,
|
|
487
|
+
connected: provider !== null,
|
|
488
|
+
provider,
|
|
489
|
+
providerName: provider?.name ?? null,
|
|
490
|
+
model: choice?.model ?? null,
|
|
491
|
+
effort: choice?.effort ?? null,
|
|
492
|
+
modelChoices: s.modelChoices,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function useAgentSessions() {
|
|
497
|
+
const s = useAgentState();
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
if (!available || s.sessionsLoaded) return;
|
|
500
|
+
void loadSessions();
|
|
501
|
+
}, [s.sessionsLoaded]);
|
|
502
|
+
return { sessions: s.sessions, loaded: s.sessionsLoaded, activeId: s.activeId };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function useAgentSession(id: string | null): AgentSession | null {
|
|
506
|
+
const s = useAgentState();
|
|
507
|
+
const session = id ? (s.full[id] ?? null) : null;
|
|
508
|
+
useEffect(() => {
|
|
509
|
+
if (!available || !id || session) return;
|
|
510
|
+
void loadSession(id);
|
|
511
|
+
}, [id, session]);
|
|
512
|
+
return session;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export type AgentActivity = {
|
|
516
|
+
provider: AgentProviderId;
|
|
517
|
+
phase: 'thinking' | 'tool' | 'waiting';
|
|
518
|
+
title: string | null;
|
|
519
|
+
detail: string | null;
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
export function useAgentActivity(slideId: string): AgentActivity | null {
|
|
523
|
+
const { sessions } = useAgentSessions();
|
|
524
|
+
const summary = sessions.find((s) => s.slideId === slideId && s.status === 'running') ?? null;
|
|
525
|
+
const session = useAgentSession(summary?.id ?? null);
|
|
526
|
+
if (!summary) return null;
|
|
527
|
+
const items = session?.items ?? [];
|
|
528
|
+
const waiting = items.some(
|
|
529
|
+
(i) => (i.kind === 'question' || i.kind === 'permission') && i.status === 'pending',
|
|
530
|
+
);
|
|
531
|
+
if (waiting) return { provider: summary.provider, phase: 'waiting', title: null, detail: null };
|
|
532
|
+
const last = items.at(-1);
|
|
533
|
+
if (last?.kind === 'tool' && last.status === 'running') {
|
|
534
|
+
return { provider: summary.provider, phase: 'tool', title: last.title, detail: last.detail };
|
|
535
|
+
}
|
|
536
|
+
return { provider: summary.provider, phase: 'thinking', title: null, detail: null };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function useAgentDock() {
|
|
540
|
+
const s = useAgentState();
|
|
541
|
+
const toggle = useCallback(() => setDockOpen(!state.dockOpen), []);
|
|
542
|
+
return { open: s.dockOpen, setOpen: setDockOpen, toggle };
|
|
543
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type RefObject, useLayoutEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
export function useAutoGrow(
|
|
4
|
+
ref: RefObject<HTMLTextAreaElement | null>,
|
|
5
|
+
value: string,
|
|
6
|
+
max: number,
|
|
7
|
+
): void {
|
|
8
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-measure whenever the text changes
|
|
9
|
+
useLayoutEffect(() => {
|
|
10
|
+
const node = ref.current;
|
|
11
|
+
if (!node) return;
|
|
12
|
+
node.style.height = '0px';
|
|
13
|
+
node.style.height = `${Math.min(max, Math.max(40, node.scrollHeight))}px`;
|
|
14
|
+
}, [ref, value, max]);
|
|
15
|
+
}
|
package/src/app/lib/utils.ts
CHANGED
|
@@ -9,6 +9,10 @@ export function round2(n: number): number {
|
|
|
9
9
|
return Math.round(n * 100) / 100;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
13
|
+
return Math.min(max, Math.max(min, value));
|
|
14
|
+
}
|
|
15
|
+
|
|
12
16
|
export function pad2(n: number): string {
|
|
13
17
|
return n.toString().padStart(2, '0');
|
|
14
18
|
}
|
package/src/app/routes/home.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
MoreHorizontal,
|
|
9
9
|
Palette,
|
|
10
10
|
Pencil,
|
|
11
|
+
Plus,
|
|
11
12
|
Search,
|
|
12
13
|
Trash2,
|
|
13
14
|
X,
|
|
@@ -30,8 +31,10 @@ import {
|
|
|
30
31
|
DropdownMenuItem,
|
|
31
32
|
DropdownMenuTrigger,
|
|
32
33
|
} from '@/components/ui/dropdown-menu';
|
|
34
|
+
import { useAgentStatus, useCreateDeck } from '@/lib/agent/use-agent';
|
|
33
35
|
import { format, useLocale } from '@/lib/use-locale';
|
|
34
36
|
import { cn, pad2 } from '@/lib/utils';
|
|
37
|
+
import { ProviderLogo } from '../components/agent/provider-logo';
|
|
35
38
|
import { FolderIconChip, SLIDE_DND_MIME, SystemViewIcon } from '../components/sidebar/folder-item';
|
|
36
39
|
import { ALL_SLIDES_ID, DRAFT_ID } from '../components/sidebar/sidebar';
|
|
37
40
|
import { SlideCanvas } from '../components/slide-canvas';
|
|
@@ -193,6 +196,7 @@ export function Home() {
|
|
|
193
196
|
<div className="ml-auto flex w-full items-center gap-2 md:w-auto">
|
|
194
197
|
<SortControl value={sortKey} onChange={setSortKey} />
|
|
195
198
|
<SearchInput value={query} onChange={setQuery} />
|
|
199
|
+
{import.meta.env.DEV && <NewDeckButton />}
|
|
196
200
|
</div>
|
|
197
201
|
</div>
|
|
198
202
|
</header>
|
|
@@ -319,6 +323,36 @@ function SortControl({ value, onChange }: { value: SortKey; onChange: (next: Sor
|
|
|
319
323
|
);
|
|
320
324
|
}
|
|
321
325
|
|
|
326
|
+
// Offered even before an agent is connected: the new deck opens the agent
|
|
327
|
+
// panel, which walks first-time users through installing one.
|
|
328
|
+
function useCreateDeckWithAgent() {
|
|
329
|
+
const { enabled, provider } = useAgentStatus();
|
|
330
|
+
const start = useCreateDeck();
|
|
331
|
+
if (!enabled) return null;
|
|
332
|
+
return { provider, start };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function NewDeckButton() {
|
|
336
|
+
const t = useLocale();
|
|
337
|
+
const agent = useCreateDeckWithAgent();
|
|
338
|
+
if (!agent) return null;
|
|
339
|
+
return (
|
|
340
|
+
<Button
|
|
341
|
+
size="sm"
|
|
342
|
+
className="h-8 shrink-0"
|
|
343
|
+
onClick={agent.start}
|
|
344
|
+
title={
|
|
345
|
+
agent.provider
|
|
346
|
+
? format(t.agent.createDeckWith, { provider: agent.provider.name })
|
|
347
|
+
: t.agent.createDeck
|
|
348
|
+
}
|
|
349
|
+
>
|
|
350
|
+
<Plus />
|
|
351
|
+
{t.agent.createDeck}
|
|
352
|
+
</Button>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
322
356
|
function HomeLoading() {
|
|
323
357
|
const t = useLocale();
|
|
324
358
|
return (
|
|
@@ -360,6 +394,7 @@ function NoResultsState({ query, onClear }: { query: string; onClear: () => void
|
|
|
360
394
|
|
|
361
395
|
function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: string }) {
|
|
362
396
|
const t = useLocale();
|
|
397
|
+
const agent = useCreateDeckWithAgent();
|
|
363
398
|
const folderEmptyTitle = t.home.folderEmptyTitle.replace(
|
|
364
399
|
'{name}',
|
|
365
400
|
folderName ?? t.home.folderEmptyTitle,
|
|
@@ -373,13 +408,22 @@ function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: st
|
|
|
373
408
|
<p className="mt-4 font-heading text-[14px] font-semibold tracking-tight">
|
|
374
409
|
{t.home.noSlidesYet}
|
|
375
410
|
</p>
|
|
376
|
-
|
|
377
|
-
{
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
411
|
+
{agent ? (
|
|
412
|
+
<Button size="sm" className="mt-4" onClick={agent.start}>
|
|
413
|
+
{agent.provider ? <ProviderLogo provider={agent.provider.id} /> : <Plus />}
|
|
414
|
+
{agent.provider
|
|
415
|
+
? format(t.agent.createDeckWith, { provider: agent.provider.name })
|
|
416
|
+
: t.agent.createDeck}
|
|
417
|
+
</Button>
|
|
418
|
+
) : (
|
|
419
|
+
<p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
|
|
420
|
+
{t.home.createSlideHintPrefix}
|
|
421
|
+
<code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
|
|
422
|
+
/create-slide
|
|
423
|
+
</code>
|
|
424
|
+
{t.home.createSlideHintSuffix}
|
|
425
|
+
</p>
|
|
426
|
+
)}
|
|
383
427
|
</>
|
|
384
428
|
) : (
|
|
385
429
|
<>
|