broapp 0.1.0 → 0.3.0

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.
Files changed (39) hide show
  1. package/README.md +13 -4
  2. package/package.json +10 -2
  3. package/src/ai/host/adapter.ts +109 -0
  4. package/src/ai/host/create-ai.ts +266 -0
  5. package/src/ai/host/fake.ts +230 -0
  6. package/src/ai/host/from-contract.ts +105 -0
  7. package/src/ai/host/index.ts +37 -0
  8. package/src/ai/host/registry.ts +232 -0
  9. package/src/ai/host/run-types.ts +14 -0
  10. package/src/ai/host/run.ts +540 -0
  11. package/src/ai/host/secrets.ts +118 -0
  12. package/src/ai/host/settings.ts +83 -0
  13. package/src/ai/host/threads.ts +366 -0
  14. package/src/ai/host/tool.ts +95 -0
  15. package/src/ai/react/AiChat.tsx +242 -0
  16. package/src/ai/react/AiSettings.tsx +228 -0
  17. package/src/ai/react/ai.css +166 -0
  18. package/src/ai/react/index.tsx +38 -0
  19. package/src/ai/react/provider.tsx +97 -0
  20. package/src/ai/react/use-ai-chat.ts +317 -0
  21. package/src/ai/react/use-ai-models.ts +70 -0
  22. package/src/ai/react/use-ai-settings.ts +105 -0
  23. package/src/ai/shared/contract.ts +247 -0
  24. package/src/ai/shared/index.ts +19 -0
  25. package/src/ai/shared/types.check.ts +71 -0
  26. package/src/ai/shared/types.ts +147 -0
  27. package/src/host/app.ts +183 -36
  28. package/src/host/approvals.ts +115 -0
  29. package/src/host/gate.ts +380 -0
  30. package/src/host/index.ts +25 -0
  31. package/src/host/paths.ts +6 -2
  32. package/src/host/runtime.ts +23 -2
  33. package/src/react/hooks.tsx +37 -3
  34. package/src/react/index.ts +1 -0
  35. package/src/shared/contract.ts +99 -2
  36. package/src/shared/countdown.ts +36 -0
  37. package/src/shared/errors.ts +66 -2
  38. package/src/shared/index.ts +14 -3
  39. package/src/shared/schema.ts +141 -28
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shared AI state for everything below it.
3
+ *
4
+ * The settings panel and the chat panel both need to know whether AI is
5
+ * configured, and they must not disagree — a chat that says "not set up" next
6
+ * to a panel that just saved a key is worse than either alone. So the settings
7
+ * are fetched once, here, and both read them from the same place.
8
+ */
9
+ import * as React from 'react';
10
+
11
+ import type { BroappClient } from '../../client/client.ts';
12
+ import { BroappError } from '../../shared/errors.ts';
13
+ import { useBroapp, useBroappContract, useBroappReady, useConnection } from '../../react/hooks.tsx';
14
+ import type { AiContract } from '../shared/contract.ts';
15
+ import type { AiSettings } from '../shared/types.ts';
16
+
17
+ /** The AI client: the same connection, typed against the AI contract. */
18
+ export type AiClient = BroappClient<AiContract>;
19
+
20
+ interface AiContextValue {
21
+ readonly settings: AiSettings | null;
22
+ readonly error: BroappError | null;
23
+ readonly loading: boolean;
24
+ refresh(): Promise<void>;
25
+ /** Write the settings other hooks just fetched, so one round trip serves all. */
26
+ put(settings: AiSettings): void;
27
+ client(): Promise<AiClient>;
28
+ }
29
+
30
+ const AiContext = React.createContext<AiContextValue | null>(null);
31
+
32
+ /** The route every installation of the AI contract has. */
33
+ const PROBE_ROUTE = 'ai.settingsGet';
34
+
35
+ const MISSING_CONTRACT =
36
+ 'AiProvider needs the AI contract: <BroappProvider contract={contract} extensions={[aiContract]}>';
37
+
38
+ /** Owns the AI settings for the components below it. */
39
+ export function AiProvider({ children }: { children: React.ReactNode }): React.ReactElement {
40
+ const contract = useBroappContract();
41
+ if (!Object.prototype.hasOwnProperty.call(contract.operations, PROBE_ROUTE)) {
42
+ // Thrown at render rather than at the first call: the mistake is in the
43
+ // provider setup, and that is where a developer should be sent.
44
+ throw new Error(MISSING_CONTRACT);
45
+ }
46
+
47
+ const broapp = useBroapp<AiContract>();
48
+ const ready = useBroappReady<AiContract>();
49
+ const connection = useConnection();
50
+ const [settings, setSettings] = React.useState<AiSettings | null>(null);
51
+ const [error, setError] = React.useState<BroappError | null>(null);
52
+ const [loading, setLoading] = React.useState(false);
53
+
54
+ const client = React.useCallback(
55
+ async (): Promise<AiClient> => broapp ?? (await ready),
56
+ [broapp, ready],
57
+ );
58
+
59
+ const refresh = React.useCallback(async (): Promise<void> => {
60
+ setLoading(true);
61
+ try {
62
+ const connected = await client();
63
+ setSettings(await connected.call('ai.settingsGet', undefined));
64
+ setError(null);
65
+ } catch (cause) {
66
+ setError(
67
+ cause instanceof BroappError
68
+ ? cause
69
+ : new BroappError('internal', 'The AI settings could not be read.', cause),
70
+ );
71
+ } finally {
72
+ setLoading(false);
73
+ }
74
+ }, [client]);
75
+
76
+ // Once, when there is something to talk to. A settings read before the
77
+ // connection settles would only fail and have to be retried.
78
+ const fetched = React.useRef(false);
79
+ React.useEffect(() => {
80
+ if (connection.phase !== 'ready' || fetched.current) return;
81
+ fetched.current = true;
82
+ void refresh();
83
+ }, [connection.phase, refresh]);
84
+
85
+ const value = React.useMemo<AiContextValue>(
86
+ () => ({ settings, error, loading, refresh, put: setSettings, client }),
87
+ [settings, error, loading, refresh, client],
88
+ );
89
+ return <AiContext.Provider value={value}>{children}</AiContext.Provider>;
90
+ }
91
+
92
+ /** The shared AI state. Throws outside {@link AiProvider}. */
93
+ export function useAiContext(): AiContextValue {
94
+ const value = React.useContext(AiContext);
95
+ if (value === null) throw new Error('This hook must be used inside <AiProvider>');
96
+ return value;
97
+ }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * One conversation.
3
+ *
4
+ * `useStream` is not used here: it keeps only the most recent event, and a
5
+ * chat needs every one of them in order. What it does copy exactly is
6
+ * `useStream`'s unmount behaviour — cancelling the subscription — because the
7
+ * producer is a process on the user's own machine and a stream nobody cancels
8
+ * goes on running the model.
9
+ */
10
+ import * as React from 'react';
11
+
12
+ import type { Subscription } from '../../client/client.ts';
13
+ import { BroappError } from '../../shared/errors.ts';
14
+ import type { ChatEvent, ChatTurn } from '../shared/types.ts';
15
+
16
+ import { useAiContext } from './provider.tsx';
17
+
18
+ /** What one tool call is doing. */
19
+ export interface ToolCallState {
20
+ readonly callId: string;
21
+ readonly tool: string;
22
+ readonly input: unknown;
23
+ readonly status: 'running' | 'awaiting-confirmation' | 'done' | 'denied';
24
+ readonly output?: unknown;
25
+ /** While awaiting confirmation: when the question stops waiting. */
26
+ readonly expiresAt?: number;
27
+ }
28
+
29
+ /** One message in the transcript. */
30
+ export type ChatMessage =
31
+ | { readonly id: string; readonly role: 'user'; readonly content: string }
32
+ | {
33
+ readonly id: string;
34
+ readonly role: 'assistant';
35
+ readonly content: string;
36
+ readonly toolCalls: ToolCallState[];
37
+ readonly pending: boolean;
38
+ };
39
+
40
+ /** What {@link useAiChat} returns. */
41
+ export interface AiChatHook {
42
+ readonly messages: ChatMessage[];
43
+ readonly status: 'idle' | 'streaming' | 'awaiting-confirmation' | 'error';
44
+ readonly error: string | null;
45
+ readonly usage: { inputTokens: number; outputTokens: number } | null;
46
+ send(text: string): Promise<void>;
47
+ cancel(): void;
48
+ confirm(callId: string, approve: boolean): Promise<void>;
49
+ clear(): void;
50
+ }
51
+
52
+ /** The contract caps history at 100 turns; the oldest are dropped. */
53
+ const MAX_HISTORY = 100;
54
+
55
+ /** A run id matching the contract's pattern. */
56
+ function newRunId(): string {
57
+ return crypto.randomUUID().replace(/-/g, '');
58
+ }
59
+
60
+ /** Every completed turn, as the model should see it. */
61
+ function toHistory(messages: readonly ChatMessage[]): ChatTurn[] {
62
+ const turns: ChatTurn[] = [];
63
+ for (const message of messages) {
64
+ if (message.role === 'assistant' && (message.pending || message.content === '')) continue;
65
+ turns.push({ role: message.role, content: message.content });
66
+ }
67
+ return turns.slice(-MAX_HISTORY);
68
+ }
69
+
70
+ /** Options for {@link useAiChat}. */
71
+ export interface AiChatOptions {
72
+ /** Records the user is looking at, sent with every message. */
73
+ readonly refs?: readonly string[];
74
+ /**
75
+ * Called when a tool call settles.
76
+ *
77
+ * An application uses it to refetch whatever the model just changed. It is
78
+ * called for denied calls too, so a panel can stop showing them as pending.
79
+ */
80
+ onToolResult?(call: ToolCallState): void;
81
+ }
82
+
83
+ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
84
+ const shared = useAiContext();
85
+ const [messages, setMessages] = React.useState<ChatMessage[]>([]);
86
+ const [status, setStatus] = React.useState<AiChatHook['status']>('idle');
87
+ const [error, setError] = React.useState<string | null>(null);
88
+ const [usage, setUsage] = React.useState<AiChatHook['usage']>(null);
89
+ const active = React.useRef<Subscription | null>(null);
90
+ const runId = React.useRef<string>('');
91
+ const mounted = React.useRef(true);
92
+ const refs = options.refs ?? [];
93
+ // Read inside the subscription callbacks, which are created once per send.
94
+ const refsRef = React.useRef<readonly string[]>(refs);
95
+ refsRef.current = refs;
96
+ const onToolResult = React.useRef<AiChatOptions['onToolResult']>(undefined);
97
+ onToolResult.current = options.onToolResult;
98
+ // The tool calls of the turn in progress, by call id. A state updater cannot
99
+ // be used to find one: React runs it when it chooses, so anything read out
100
+ // of it is read too late to hand to a callback.
101
+ const calls = React.useRef(new Map<string, ToolCallState>());
102
+
103
+ React.useEffect(() => {
104
+ mounted.current = true;
105
+ return () => {
106
+ mounted.current = false;
107
+ active.current?.cancel();
108
+ active.current = null;
109
+ };
110
+ }, []);
111
+
112
+ /** Change the assistant message this turn is writing into. */
113
+ const patchPending = React.useCallback(
114
+ (change: (message: Extract<ChatMessage, { role: 'assistant' }>) => ChatMessage): void => {
115
+ setMessages((current) => {
116
+ const index = current.length - 1;
117
+ const last = current[index];
118
+ if (last === undefined || last.role !== 'assistant') return current;
119
+ const next = [...current];
120
+ next[index] = change(last);
121
+ return next;
122
+ });
123
+ },
124
+ [],
125
+ );
126
+
127
+ const apply = React.useCallback(
128
+ (event: ChatEvent): void => {
129
+ switch (event.type) {
130
+ case 'text':
131
+ patchPending((message) => ({ ...message, content: message.content + (event.text ?? '') }));
132
+ break;
133
+ case 'tool-call':
134
+ calls.current.set(event.callId ?? '', {
135
+ callId: event.callId ?? '',
136
+ tool: event.tool ?? '',
137
+ input: event.input,
138
+ status: 'running',
139
+ });
140
+ patchPending((message) => ({
141
+ ...message,
142
+ toolCalls: [
143
+ ...message.toolCalls,
144
+ {
145
+ callId: event.callId ?? '',
146
+ tool: event.tool ?? '',
147
+ input: event.input,
148
+ status: 'running',
149
+ },
150
+ ],
151
+ }));
152
+ break;
153
+ case 'confirm':
154
+ setStatus('awaiting-confirmation');
155
+ patchPending((message) => ({
156
+ ...message,
157
+ toolCalls: message.toolCalls.map((call) =>
158
+ call.callId === event.callId
159
+ ? {
160
+ ...call,
161
+ status: 'awaiting-confirmation',
162
+ ...(event.expiresAt === undefined ? {} : { expiresAt: event.expiresAt }),
163
+ }
164
+ : call,
165
+ ),
166
+ }));
167
+ break;
168
+ case 'tool-result': {
169
+ setStatus((current) => (current === 'awaiting-confirmation' ? 'streaming' : current));
170
+ const started = calls.current.get(event.callId ?? '');
171
+ const settled: ToolCallState = {
172
+ callId: event.callId ?? '',
173
+ tool: event.tool ?? started?.tool ?? '',
174
+ input: started?.input,
175
+ status: event.denied === true ? 'denied' : 'done',
176
+ output: event.output,
177
+ };
178
+ calls.current.set(settled.callId, settled);
179
+ patchPending((message) => ({
180
+ ...message,
181
+ toolCalls: message.toolCalls.map((call) =>
182
+ call.callId === settled.callId ? settled : call,
183
+ ),
184
+ }));
185
+ // The application refetches whatever the model just changed.
186
+ onToolResult.current?.(settled);
187
+ break;
188
+ }
189
+ case 'usage':
190
+ setUsage({
191
+ inputTokens: event.inputTokens ?? 0,
192
+ outputTokens: event.outputTokens ?? 0,
193
+ });
194
+ break;
195
+ case 'done':
196
+ active.current = null;
197
+ setStatus('idle');
198
+ patchPending((message) => ({ ...message, pending: false }));
199
+ break;
200
+ case 'error':
201
+ active.current = null;
202
+ setStatus('error');
203
+ setError(event.message ?? 'The AI provider returned an error.');
204
+ patchPending((message) => ({ ...message, pending: false }));
205
+ break;
206
+ }
207
+ },
208
+ [patchPending],
209
+ );
210
+
211
+ const send = React.useCallback(
212
+ async (text: string): Promise<void> => {
213
+ // A second question while the first is still being answered would need a
214
+ // second run and a second transcript. Ignored rather than queued.
215
+ if (active.current !== null || status === 'streaming' || status === 'awaiting-confirmation') {
216
+ return;
217
+ }
218
+ const trimmed = text.trim();
219
+ if (trimmed === '') return;
220
+
221
+ const history = toHistory(messages);
222
+ calls.current.clear();
223
+ const id = newRunId();
224
+ runId.current = id;
225
+ setError(null);
226
+ setUsage(null);
227
+ setStatus('streaming');
228
+ setMessages((current) => [
229
+ ...current,
230
+ { id: `${id}-user`, role: 'user', content: trimmed },
231
+ { id: `${id}-assistant`, role: 'assistant', content: '', toolCalls: [], pending: true },
232
+ ]);
233
+
234
+ try {
235
+ const connected = await shared.client();
236
+ if (!mounted.current) return;
237
+ const subscription = await connected.subscribe(
238
+ 'ai.chat',
239
+ { runId: id, message: trimmed, refs: [...refsRef.current], history },
240
+ {
241
+ onEvent: (event) => {
242
+ if (mounted.current) apply(event);
243
+ },
244
+ onDone: () => {
245
+ if (!mounted.current) return;
246
+ active.current = null;
247
+ setStatus((current) => (current === 'error' ? current : 'idle'));
248
+ patchPending((message) => ({ ...message, pending: false }));
249
+ },
250
+ onError: (cause) => {
251
+ if (!mounted.current) return;
252
+ active.current = null;
253
+ setStatus('error');
254
+ setError(cause.message);
255
+ patchPending((message) => ({ ...message, pending: false }));
256
+ },
257
+ },
258
+ );
259
+ if (!mounted.current) {
260
+ subscription.cancel();
261
+ return;
262
+ }
263
+ active.current = subscription;
264
+ } catch (cause) {
265
+ if (!mounted.current) return;
266
+ active.current = null;
267
+ setStatus('error');
268
+ setError(
269
+ cause instanceof BroappError ? cause.message : 'The conversation could not be started.',
270
+ );
271
+ patchPending((message) => ({ ...message, pending: false }));
272
+ }
273
+ },
274
+ [shared, status, messages, apply, patchPending],
275
+ );
276
+
277
+ const cancel = React.useCallback((): void => {
278
+ if (active.current === null) return;
279
+ active.current.cancel();
280
+ active.current = null;
281
+ setStatus('idle');
282
+ // The text so far is kept: the user asked to stop, not to undo.
283
+ patchPending((message) => ({ ...message, pending: false }));
284
+ }, [patchPending]);
285
+
286
+ const confirm = React.useCallback(
287
+ async (callId: string, approve: boolean): Promise<void> => {
288
+ try {
289
+ const connected = await shared.client();
290
+ const result = await connected.call('ai.chatConfirm', {
291
+ runId: runId.current,
292
+ callId,
293
+ approve,
294
+ });
295
+ if (!result.accepted) {
296
+ // Nobody was waiting: the turn timed out or was cancelled while the
297
+ // question was on screen.
298
+ setError('That request has expired.');
299
+ }
300
+ } catch (cause) {
301
+ setError(cause instanceof BroappError ? cause.message : 'That answer could not be sent.');
302
+ }
303
+ },
304
+ [shared],
305
+ );
306
+
307
+ const clear = React.useCallback((): void => {
308
+ active.current?.cancel();
309
+ active.current = null;
310
+ setMessages([]);
311
+ setStatus('idle');
312
+ setError(null);
313
+ setUsage(null);
314
+ }, []);
315
+
316
+ return { messages, status, error, usage, send, cancel, confirm, clear };
317
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The models the configured provider offers.
3
+ *
4
+ * Refetched whenever something that would change the answer changes — the
5
+ * provider, its address, or whether a key is set. Not on every settings write:
6
+ * choosing a model must not send the application back to the provider to ask
7
+ * what the models are.
8
+ */
9
+ import * as React from 'react';
10
+
11
+ import { BroappError } from '../../shared/errors.ts';
12
+ import type { BroappModel } from '../shared/types.ts';
13
+
14
+ import { useAiContext } from './provider.tsx';
15
+
16
+ /** What {@link useAiModels} returns. */
17
+ export interface AiModelsHook {
18
+ readonly models: BroappModel[];
19
+ readonly pending: boolean;
20
+ readonly error: BroappError | null;
21
+ refresh(): Promise<void>;
22
+ }
23
+
24
+ export function useAiModels(): AiModelsHook {
25
+ const shared = useAiContext();
26
+ const [models, setModels] = React.useState<BroappModel[]>([]);
27
+ const [pending, setPending] = React.useState(false);
28
+ const [error, setError] = React.useState<BroappError | null>(null);
29
+ const generation = React.useRef(0);
30
+
31
+ const provider = shared.settings?.provider ?? null;
32
+ const baseUrl = shared.settings?.baseUrl ?? null;
33
+ const hasKey = shared.settings?.hasKey ?? false;
34
+
35
+ const refresh = React.useCallback(async (): Promise<void> => {
36
+ if (provider === null) {
37
+ setModels([]);
38
+ return;
39
+ }
40
+ const mine = (generation.current += 1);
41
+ setPending(true);
42
+ setError(null);
43
+ try {
44
+ const connected = await shared.client();
45
+ const result = await connected.call('ai.modelsList', undefined);
46
+ // A slow answer for a provider the user has since changed must not
47
+ // replace the list they are looking at now.
48
+ if (generation.current !== mine) return;
49
+ setModels(result.models);
50
+ } catch (cause) {
51
+ if (generation.current !== mine) return;
52
+ setModels([]);
53
+ setError(
54
+ cause instanceof BroappError
55
+ ? cause
56
+ : new BroappError('internal', 'The model list could not be read.', cause),
57
+ );
58
+ } finally {
59
+ if (generation.current === mine) setPending(false);
60
+ }
61
+ }, [shared, provider]);
62
+
63
+ React.useEffect(() => {
64
+ void refresh();
65
+ // `baseUrl` and `hasKey` are not used inside `refresh`; they are here
66
+ // because changing either changes what the provider will answer.
67
+ }, [refresh, baseUrl, hasKey]);
68
+
69
+ return { models, pending, error, refresh };
70
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Reading and changing the AI settings.
3
+ *
4
+ * Every change is written straight through: there is no Save button, because
5
+ * there is nothing to batch and a half-saved provider configuration is a state
6
+ * worth not having. The result of each write replaces the shared settings, so
7
+ * the rest of the interface updates without a second round trip.
8
+ */
9
+ import * as React from 'react';
10
+
11
+ import type { OperationInput } from '../../shared/contract.ts';
12
+ import { BroappError } from '../../shared/errors.ts';
13
+ import type { AiContract } from '../shared/contract.ts';
14
+ import type { AiSettings, ProviderInfo } from '../shared/types.ts';
15
+
16
+ import { useAiContext } from './provider.tsx';
17
+
18
+ /** What `ai.settingsUpdate` accepts. */
19
+ export type UpdatePatch = OperationInput<AiContract, 'ai.settingsUpdate'>;
20
+
21
+ /** The result of a connection test. */
22
+ export interface ConnectionResult {
23
+ readonly ok: boolean;
24
+ readonly message: string;
25
+ readonly latencyMs: number;
26
+ }
27
+
28
+ /** What {@link useAiSettings} returns. */
29
+ export interface AiSettingsHook {
30
+ readonly settings: AiSettings | null;
31
+ readonly providers: ProviderInfo[];
32
+ readonly pending: boolean;
33
+ readonly error: BroappError | null;
34
+ update(patch: UpdatePatch): Promise<void>;
35
+ test(): Promise<ConnectionResult | null>;
36
+ refresh(): Promise<void>;
37
+ }
38
+
39
+ function asBroappError(cause: unknown, fallback: string): BroappError {
40
+ return cause instanceof BroappError ? cause : new BroappError('internal', fallback, cause);
41
+ }
42
+
43
+ /** The settings, the providers this build has, and the two ways to change them. */
44
+ export function useAiSettings(): AiSettingsHook {
45
+ const shared = useAiContext();
46
+ const [providers, setProviders] = React.useState<ProviderInfo[]>([]);
47
+ const [pending, setPending] = React.useState(false);
48
+ const [error, setError] = React.useState<BroappError | null>(null);
49
+
50
+ // The provider list cannot change while the application runs — it is what
51
+ // was compiled in — so it is fetched once.
52
+ const fetched = React.useRef(false);
53
+ React.useEffect(() => {
54
+ if (fetched.current) return;
55
+ fetched.current = true;
56
+ void (async () => {
57
+ try {
58
+ const connected = await shared.client();
59
+ setProviders((await connected.call('ai.providersList', undefined)).providers);
60
+ } catch (cause) {
61
+ setError(asBroappError(cause, 'The provider list could not be read.'));
62
+ }
63
+ })();
64
+ }, [shared]);
65
+
66
+ const update = React.useCallback(
67
+ async (patch: UpdatePatch): Promise<void> => {
68
+ setPending(true);
69
+ setError(null);
70
+ try {
71
+ const connected = await shared.client();
72
+ shared.put(await connected.call('ai.settingsUpdate', patch));
73
+ } catch (cause) {
74
+ setError(asBroappError(cause, 'That setting could not be saved.'));
75
+ } finally {
76
+ setPending(false);
77
+ }
78
+ },
79
+ [shared],
80
+ );
81
+
82
+ const test = React.useCallback(async (): Promise<ConnectionResult | null> => {
83
+ setPending(true);
84
+ setError(null);
85
+ try {
86
+ const connected = await shared.client();
87
+ return await connected.call('ai.connectionTest', undefined);
88
+ } catch (cause) {
89
+ setError(asBroappError(cause, 'The connection could not be tested.'));
90
+ return null;
91
+ } finally {
92
+ setPending(false);
93
+ }
94
+ }, [shared]);
95
+
96
+ return {
97
+ settings: shared.settings,
98
+ providers,
99
+ pending: pending || shared.loading,
100
+ error: error ?? shared.error,
101
+ update,
102
+ test,
103
+ refresh: shared.refresh,
104
+ };
105
+ }