akanjs 3.0.0-beta.7 → 3.0.0-beta.8
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/common/index.ts +1 -0
- package/common/pathGet.ts +12 -2
- package/common/pathSet.ts +2 -3
- package/common/toPathSegments.ts +10 -0
- package/dictionary/base.dictionary.ts +5 -0
- package/index.ts +5 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/service/agent.service.ts +92 -1
- package/service/predefinedAdaptor/llm.adaptor.ts +17 -0
- package/store/agentic/index.ts +1 -0
- package/store/agentic/useAgentReference.ts +49 -0
- package/store/hooks.ts +1 -1
- package/types/common/index.d.ts +1 -0
- package/types/common/toPathSegments.d.ts +9 -0
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/index.d.ts +5 -0
- package/types/service/agent.service.d.ts +54 -0
- package/types/service/predefinedAdaptor/llm.adaptor.d.ts +16 -0
- package/types/store/agentic/index.d.ts +1 -0
- package/types/store/agentic/useAgentReference.d.ts +32 -0
- package/types/store/hooks.d.ts +1 -1
- package/types/ui/Agent/Chat.d.ts +11 -1
- package/types/ui/Agent/Composer.d.ts +9 -2
- package/types/ui/Agent/Menu.d.ts +4 -2
- package/types/ui/Agent/Refer.d.ts +13 -0
- package/types/ui/Agent/useChatQueue.d.ts +3 -1
- package/types/ui/Agent/useChatReferences.d.ts +26 -0
- package/types/ui/Agent/useReferenceMenu.d.ts +43 -0
- package/types/ui/Field/lightRefCache.d.ts +19 -0
- package/types/ui/Field/useRelationOptions.d.ts +39 -0
- package/types/ui/Select.d.ts +2 -0
- package/types/ui/index.d.ts +4 -1
- package/types/vendor/use-agentic/AgentSession.d.ts +54 -1
- package/types/vendor/use-agentic/Reference.d.ts +56 -0
- package/types/vendor/use-agentic/index.d.ts +1 -0
- package/types/vendor/use-agentic/types.d.ts +36 -6
- package/ui/Agent/Bubble.tsx +2 -0
- package/ui/Agent/Chat.tsx +38 -8
- package/ui/Agent/Composer.tsx +18 -1
- package/ui/Agent/Menu.tsx +8 -3
- package/ui/Agent/Queued.tsx +2 -0
- package/ui/Agent/Refer.tsx +44 -0
- package/ui/Agent/sessionHistory.ts +35 -12
- package/ui/Agent/useChatQueue.ts +11 -1
- package/ui/Agent/useChatReferences.ts +67 -0
- package/ui/Agent/useReferenceMenu.ts +108 -0
- package/ui/Field/Relation.tsx +70 -150
- package/ui/Field/lightRefCache.ts +73 -0
- package/ui/Field/useRelationOptions.ts +106 -0
- package/ui/Select.tsx +24 -14
- package/ui/index.ts +8 -0
- package/vendor/use-agentic/AgentSession.ts +127 -1
- package/vendor/use-agentic/Compaction.ts +6 -0
- package/vendor/use-agentic/Reference.ts +99 -0
- package/vendor/use-agentic/Transcript.ts +7 -1
- package/vendor/use-agentic/index.ts +1 -0
- package/vendor/use-agentic/types.ts +38 -7
package/ui/Field/Relation.tsx
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import type { DataList } from "akanjs/base";
|
|
3
3
|
import { cn } from "akanjs/client";
|
|
4
|
-
import { capitalize, lowerlize } from "akanjs/common";
|
|
5
4
|
import type { SliceMeta } from "akanjs/fetch";
|
|
6
|
-
import {
|
|
5
|
+
import { useFieldTool, useRelationFieldTool } from "akanjs/store";
|
|
7
6
|
import type { ReactNode } from "react";
|
|
8
7
|
import { agentAttrs } from "../agentAttrs";
|
|
9
8
|
import { Select } from "../Select";
|
|
10
9
|
import { Label } from "./Label";
|
|
10
|
+
import { useRelationOptions } from "./useRelationOptions";
|
|
11
|
+
|
|
12
|
+
interface UnresolvedProps {
|
|
13
|
+
id: string;
|
|
14
|
+
}
|
|
15
|
+
/** A row nothing could resolve — removed, unreadable, or still being read. The id beats an empty control. */
|
|
16
|
+
const Unresolved = ({ id }: UnresolvedProps) => <span className="font-mono text-foreground/45">{id}</span>;
|
|
17
|
+
|
|
18
|
+
const renderRow = <Light extends { id: string }>(
|
|
19
|
+
models: DataList<Light>,
|
|
20
|
+
id: string,
|
|
21
|
+
render: (model: Light) => ReactNode,
|
|
22
|
+
) => {
|
|
23
|
+
const model = models.get(id);
|
|
24
|
+
return model ? render(model) : <Unresolved id={id} />;
|
|
25
|
+
};
|
|
11
26
|
|
|
12
27
|
export interface ParentProps<Light> {
|
|
13
28
|
label?: string;
|
|
@@ -26,12 +41,6 @@ export interface ParentProps<Light> {
|
|
|
26
41
|
renderOption: (model: Light) => ReactNode;
|
|
27
42
|
renderSelected?: (value: Light) => ReactNode;
|
|
28
43
|
}
|
|
29
|
-
/** The one line an option renders as, so an agent can match an id against what it reads on screen. */
|
|
30
|
-
const optionLabel = <Light extends { id: string }>(model: Light, render: (model: Light) => ReactNode) => {
|
|
31
|
-
const rendered = render(model);
|
|
32
|
-
return typeof rendered === "string" ? rendered : model.id;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
44
|
export const Parent = <Light extends { id: string }>({
|
|
36
45
|
label,
|
|
37
46
|
desc,
|
|
@@ -49,32 +58,15 @@ export const Parent = <Light extends { id: string }>({
|
|
|
49
58
|
renderOption,
|
|
50
59
|
renderSelected = renderOption,
|
|
51
60
|
}: ParentProps<Light>) => {
|
|
52
|
-
const {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
model: modelName,
|
|
60
|
-
modelList: `${modelName}List`,
|
|
61
|
-
modelListLoading: `${modelName}ListLoading`,
|
|
62
|
-
refreshModel: `refresh${ModelName}`,
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
const namesOfSlice = {
|
|
66
|
-
modelList: sliceName.replace(names.model, names.modelList),
|
|
67
|
-
modelListLoading: sliceName.replace(names.model, names.modelListLoading),
|
|
68
|
-
refreshModel: sliceName.replace(names.model, names.refreshModel),
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const modelList = storeUse[namesOfSlice.modelList]() as DataList<Light>;
|
|
72
|
-
useRelationFieldTool(onChange, {
|
|
73
|
-
read: () => storeGet<DataList<Light>>()[namesOfSlice.modelList],
|
|
74
|
-
load: () => storeDo[namesOfSlice.refreshModel]({ invalidate: true, queryArgs: initArgs }),
|
|
75
|
-
label: (model) => optionLabel(model, renderOption),
|
|
76
|
-
disabled,
|
|
61
|
+
const { models, options, optionLabel, listLoading, load, read } = useRelationOptions<Light>({
|
|
62
|
+
slice,
|
|
63
|
+
ids: value ? [value.id] : [],
|
|
64
|
+
pinned: [value],
|
|
65
|
+
initArgs,
|
|
66
|
+
sortOption,
|
|
67
|
+
renderOption,
|
|
77
68
|
});
|
|
69
|
+
useRelationFieldTool(onChange, { read, load, label: optionLabel, disabled });
|
|
78
70
|
|
|
79
71
|
return (
|
|
80
72
|
<div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
|
|
@@ -84,29 +76,16 @@ export const Parent = <Light extends { id: string }>({
|
|
|
84
76
|
selectClassName={selectClassName}
|
|
85
77
|
value={value?.id ?? null}
|
|
86
78
|
searchable
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
renderOption={(modelId) => {
|
|
92
|
-
if (!modelId) return null;
|
|
93
|
-
const model = modelList.get(modelId);
|
|
94
|
-
if (!model) return null;
|
|
95
|
-
return renderOption(model);
|
|
96
|
-
}}
|
|
97
|
-
renderSelected={(modelId) => {
|
|
98
|
-
if (!modelId) return null;
|
|
99
|
-
const model = modelList.get(modelId);
|
|
100
|
-
if (!model) return null;
|
|
101
|
-
return renderSelected(model);
|
|
102
|
-
}}
|
|
79
|
+
loading={listLoading}
|
|
80
|
+
options={options}
|
|
81
|
+
renderOption={(modelId) => (modelId ? renderRow(models, modelId, renderOption) : null)}
|
|
82
|
+
renderSelected={(modelId) => (modelId ? renderRow(models, modelId, renderSelected) : null)}
|
|
103
83
|
onChange={(modelId) => {
|
|
104
|
-
|
|
105
|
-
else onChange(null);
|
|
84
|
+
onChange(modelId ? (models.get(modelId) ?? null) : null);
|
|
106
85
|
}}
|
|
107
86
|
onOpen={() => {
|
|
108
87
|
if (disabled) return;
|
|
109
|
-
void
|
|
88
|
+
void load();
|
|
110
89
|
}}
|
|
111
90
|
onSearch={onSearch}
|
|
112
91
|
/>
|
|
@@ -148,22 +127,13 @@ export const ParentId = <Light extends { id: string }>({
|
|
|
148
127
|
renderOption,
|
|
149
128
|
renderSelected = renderOption,
|
|
150
129
|
}: ParentIdProps<Light>) => {
|
|
151
|
-
const {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
modelListLoading: `${modelName}ListLoading`,
|
|
159
|
-
refreshModel: `refresh${ModelName}`,
|
|
160
|
-
};
|
|
161
|
-
const namesOfSlice = {
|
|
162
|
-
modelList: sliceName.replace(names.model, names.modelList),
|
|
163
|
-
modelListLoading: sliceName.replace(names.model, names.modelListLoading),
|
|
164
|
-
refreshModel: sliceName.replace(names.model, names.refreshModel),
|
|
165
|
-
};
|
|
166
|
-
const modelList = storeUse[namesOfSlice.modelList]() as DataList<Light>;
|
|
130
|
+
const { models, options, optionLabel, listLoading, load } = useRelationOptions<Light>({
|
|
131
|
+
slice,
|
|
132
|
+
ids: value ? [value] : [],
|
|
133
|
+
initArgs,
|
|
134
|
+
sortOption,
|
|
135
|
+
renderOption,
|
|
136
|
+
});
|
|
167
137
|
|
|
168
138
|
useFieldTool(onChange, { disabled });
|
|
169
139
|
|
|
@@ -175,25 +145,16 @@ export const ParentId = <Light extends { id: string }>({
|
|
|
175
145
|
searchable
|
|
176
146
|
selectClassName={selectClassName}
|
|
177
147
|
value={value}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (!model) return null;
|
|
183
|
-
return renderOption?.(model) ?? null;
|
|
184
|
-
}}
|
|
185
|
-
renderSelected={(renderId) => {
|
|
186
|
-
if (!renderId) return null;
|
|
187
|
-
const model = modelList.get(renderId);
|
|
188
|
-
if (!model) return null;
|
|
189
|
-
return renderSelected?.(model) ?? null;
|
|
190
|
-
}}
|
|
148
|
+
loading={listLoading}
|
|
149
|
+
options={options}
|
|
150
|
+
renderOption={(renderId) => (renderId ? renderRow(models, renderId, renderOption ?? optionLabel) : null)}
|
|
151
|
+
renderSelected={(renderId) => (renderId ? renderRow(models, renderId, renderSelected ?? optionLabel) : null)}
|
|
191
152
|
onOpen={() => {
|
|
192
153
|
if (disabled) return;
|
|
193
|
-
void
|
|
154
|
+
void load();
|
|
194
155
|
}}
|
|
195
156
|
onChange={(modelId) => {
|
|
196
|
-
if (modelId) onChange(modelId,
|
|
157
|
+
if (modelId) onChange(modelId, models.get(modelId) ?? null);
|
|
197
158
|
else onChange(null, null);
|
|
198
159
|
}}
|
|
199
160
|
onSearch={onSearch}
|
|
@@ -236,29 +197,15 @@ export const Children = <Light extends { id: string }>({
|
|
|
236
197
|
renderOption,
|
|
237
198
|
renderSelected = renderOption,
|
|
238
199
|
}: ChildrenProps<Light>) => {
|
|
239
|
-
const {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
modelList: `${modelName}List`,
|
|
247
|
-
modelListLoading: `${modelName}ListLoading`,
|
|
248
|
-
refreshModel: `refresh${ModelName}`,
|
|
249
|
-
};
|
|
250
|
-
const namesOfSlice = {
|
|
251
|
-
modelList: sliceName.replace(names.model, names.modelList),
|
|
252
|
-
modelListLoading: sliceName.replace(names.model, names.modelListLoading),
|
|
253
|
-
refreshModel: sliceName.replace(names.model, names.refreshModel),
|
|
254
|
-
};
|
|
255
|
-
const modelList = storeUse[namesOfSlice.modelList]() as DataList<Light>;
|
|
256
|
-
useRelationFieldTool(onChange, {
|
|
257
|
-
read: () => storeGet<DataList<Light>>()[namesOfSlice.modelList],
|
|
258
|
-
load: () => storeDo[namesOfSlice.refreshModel]({ invalidate: true, queryArgs: initArgs }),
|
|
259
|
-
label: (model) => optionLabel(model, renderOption),
|
|
260
|
-
disabled,
|
|
200
|
+
const { models, options, optionLabel, listLoading, load, read } = useRelationOptions<Light>({
|
|
201
|
+
slice,
|
|
202
|
+
ids: (value ?? []).map((model) => model.id),
|
|
203
|
+
pinned: value ?? [],
|
|
204
|
+
initArgs,
|
|
205
|
+
sortOption,
|
|
206
|
+
renderOption,
|
|
261
207
|
});
|
|
208
|
+
useRelationFieldTool(onChange, { read, load, label: optionLabel, disabled });
|
|
262
209
|
|
|
263
210
|
return (
|
|
264
211
|
<div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
|
|
@@ -269,28 +216,16 @@ export const Children = <Light extends { id: string }>({
|
|
|
269
216
|
selectClassName={selectClassName}
|
|
270
217
|
multiple
|
|
271
218
|
value={(value ?? []).map((model) => model.id)}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return { label: typeof label === "string" ? label : model.id, value: model.id };
|
|
275
|
-
})}
|
|
219
|
+
loading={listLoading}
|
|
220
|
+
options={options}
|
|
276
221
|
onOpen={() => {
|
|
277
222
|
if (disabled) return;
|
|
278
|
-
void
|
|
279
|
-
}}
|
|
280
|
-
renderOption={(modelId: string) => {
|
|
281
|
-
const model = modelList.get(modelId);
|
|
282
|
-
if (!model) return null;
|
|
283
|
-
return renderOption(model);
|
|
284
|
-
}}
|
|
285
|
-
renderSelected={(modelId: string) => {
|
|
286
|
-
const model = modelList.get(modelId);
|
|
287
|
-
if (!model) return null;
|
|
288
|
-
return renderSelected(model);
|
|
223
|
+
void load();
|
|
289
224
|
}}
|
|
225
|
+
renderOption={(modelId: string) => renderRow(models, modelId, renderOption)}
|
|
226
|
+
renderSelected={(modelId: string) => renderRow(models, modelId, renderSelected)}
|
|
290
227
|
onChange={(modelIds: string[]) => {
|
|
291
|
-
onChange(
|
|
292
|
-
modelIds.map((id) => modelList.get(id)).filter((model) => model !== undefined) as unknown as Light[],
|
|
293
|
-
);
|
|
228
|
+
onChange(modelIds.map((id) => models.get(id)).filter((model): model is Light => !!model));
|
|
294
229
|
}}
|
|
295
230
|
onSearch={onSearch}
|
|
296
231
|
/>
|
|
@@ -328,22 +263,13 @@ export const ChildrenId = <Light extends { id: string }>({
|
|
|
328
263
|
sortOption,
|
|
329
264
|
renderOption,
|
|
330
265
|
}: ChildrenIdProps<Light>) => {
|
|
331
|
-
const {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
modelListLoading: `${modelName}ListLoading`,
|
|
339
|
-
refreshModel: `refresh${ModelName}`,
|
|
340
|
-
};
|
|
341
|
-
const namesOfSlice = {
|
|
342
|
-
modelList: sliceName.replace(names.model, names.modelList),
|
|
343
|
-
modelListLoading: sliceName.replace(names.model, names.modelListLoading),
|
|
344
|
-
refreshModel: sliceName.replace(names.model, names.refreshModel),
|
|
345
|
-
};
|
|
346
|
-
const modelList = storeUse[namesOfSlice.modelList]() as DataList<Light>;
|
|
266
|
+
const { models, options, listLoading, load } = useRelationOptions<Light>({
|
|
267
|
+
slice,
|
|
268
|
+
ids: value ?? [],
|
|
269
|
+
initArgs,
|
|
270
|
+
sortOption,
|
|
271
|
+
renderOption,
|
|
272
|
+
});
|
|
347
273
|
|
|
348
274
|
useFieldTool(onChange, { disabled });
|
|
349
275
|
|
|
@@ -354,20 +280,14 @@ export const ChildrenId = <Light extends { id: string }>({
|
|
|
354
280
|
nullable={nullable}
|
|
355
281
|
searchable
|
|
356
282
|
multiple
|
|
357
|
-
|
|
358
283
|
value={value ?? []}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
renderOption={(renderId) => {
|
|
364
|
-
const model = modelList.get(renderId);
|
|
365
|
-
if (!model) return null;
|
|
366
|
-
return renderOption(model);
|
|
367
|
-
}}
|
|
284
|
+
loading={listLoading}
|
|
285
|
+
options={options}
|
|
286
|
+
renderOption={(renderId: string) => renderRow(models, renderId, renderOption)}
|
|
287
|
+
renderSelected={(renderId: string) => renderRow(models, renderId, renderOption)}
|
|
368
288
|
onOpen={() => {
|
|
369
289
|
if (disabled) return;
|
|
370
|
-
void
|
|
290
|
+
void load();
|
|
371
291
|
}}
|
|
372
292
|
onChange={(modelIds) => {
|
|
373
293
|
onChange(modelIds);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { fetch, isClientRuntimeRegistered } from "akanjs/client";
|
|
3
|
+
import { capitalize } from "akanjs/common";
|
|
4
|
+
|
|
5
|
+
interface LightRow {
|
|
6
|
+
id: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Light rows read one id at a time, for a control holding an id its option list does not carry — a form opened
|
|
11
|
+
* through `edit<Model>` before the dropdown was ever opened, or a row outside the one `limit`-sized window a
|
|
12
|
+
* slice list holds.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately not the model's store: that store is a singleton, so reading into it would overwrite whatever
|
|
15
|
+
* listing of the same model is already on the screen — the same reason `Data/RefPicker` keeps its rows local.
|
|
16
|
+
*/
|
|
17
|
+
export class LightRefCache {
|
|
18
|
+
static #rows = new Map<string, LightRow | null>();
|
|
19
|
+
static #inflight = new Map<string, Promise<void>>();
|
|
20
|
+
static #listeners = new Set<(refName: string, id: string) => void>();
|
|
21
|
+
|
|
22
|
+
static get<Light extends LightRow>(refName: string, id: string) {
|
|
23
|
+
return (LightRefCache.#rows.get(LightRefCache.#keyOf(refName, id)) ?? null) as Light | null;
|
|
24
|
+
}
|
|
25
|
+
static subscribe(listener: (refName: string, id: string) => void) {
|
|
26
|
+
LightRefCache.#listeners.add(listener);
|
|
27
|
+
return () => {
|
|
28
|
+
LightRefCache.#listeners.delete(listener);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
static load(refName: string, ids: string[]) {
|
|
32
|
+
for (const id of ids) LightRefCache.#read(refName, id);
|
|
33
|
+
}
|
|
34
|
+
static reset() {
|
|
35
|
+
LightRefCache.#rows.clear();
|
|
36
|
+
LightRefCache.#inflight.clear();
|
|
37
|
+
}
|
|
38
|
+
static #keyOf(refName: string, id: string) {
|
|
39
|
+
return `${refName}:${id}`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A row that cannot be read is remembered as `null` rather than retried: `light<Model>` carries the model's own
|
|
43
|
+
* `get` guards, which can be narrower than the slice the control lists from, and a removed row answers the same
|
|
44
|
+
* way every time. A runtime that is not registered yet is not an answer, so nothing is remembered for it.
|
|
45
|
+
*/
|
|
46
|
+
static #read(refName: string, id: string) {
|
|
47
|
+
const key = LightRefCache.#keyOf(refName, id);
|
|
48
|
+
if (LightRefCache.#rows.has(key) || LightRefCache.#inflight.has(key)) return;
|
|
49
|
+
if (!isClientRuntimeRegistered()) return;
|
|
50
|
+
const read = (fetch as unknown as { [key: string]: unknown })[`light${capitalize(refName)}`];
|
|
51
|
+
if (typeof read !== "function") {
|
|
52
|
+
LightRefCache.#write(refName, id, null);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
LightRefCache.#inflight.set(
|
|
56
|
+
key,
|
|
57
|
+
(read as (id: string) => Promise<LightRow | null>)(id)
|
|
58
|
+
.then((row) => {
|
|
59
|
+
LightRefCache.#write(refName, id, row?.id ? row : null);
|
|
60
|
+
})
|
|
61
|
+
.catch(() => {
|
|
62
|
+
LightRefCache.#write(refName, id, null);
|
|
63
|
+
})
|
|
64
|
+
.finally(() => {
|
|
65
|
+
LightRefCache.#inflight.delete(key);
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
static #write(refName: string, id: string, row: LightRow | null) {
|
|
70
|
+
LightRefCache.#rows.set(LightRefCache.#keyOf(refName, id), row);
|
|
71
|
+
for (const listener of LightRefCache.#listeners) listener(refName, id);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { DataList } from "akanjs/base";
|
|
3
|
+
import { capitalize, lowerlize } from "akanjs/common";
|
|
4
|
+
import { ConstantRegistry, labelOf } from "akanjs/constant";
|
|
5
|
+
import type { SliceMeta } from "akanjs/fetch";
|
|
6
|
+
import { st } from "akanjs/store";
|
|
7
|
+
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
|
8
|
+
import { LightRefCache } from "./lightRefCache";
|
|
9
|
+
|
|
10
|
+
export interface RelationOptionsSource<Light extends { id: string }> {
|
|
11
|
+
slice: SliceMeta;
|
|
12
|
+
/** Ids the control holds right now, whether or not the option list carries them. */
|
|
13
|
+
ids: string[];
|
|
14
|
+
/** Rows the control was handed outright, which a `Light`-valued field's own value already is. */
|
|
15
|
+
pinned?: (Light | null)[];
|
|
16
|
+
initArgs?: any[];
|
|
17
|
+
sortOption?: (a: Light, b: Light) => number;
|
|
18
|
+
renderOption?: (model: Light) => ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const merge = <Light extends { id: string }>(
|
|
22
|
+
list: DataList<Light>,
|
|
23
|
+
rows: (Light | null | undefined)[],
|
|
24
|
+
sortOption?: (a: Light, b: Light) => number,
|
|
25
|
+
) => {
|
|
26
|
+
const merged = new DataList<Light>(list);
|
|
27
|
+
|
|
28
|
+
for (const row of rows) if (row && !merged.get(row.id)) merged.set(row);
|
|
29
|
+
return sortOption ? merged.sort(sortOption) : merged;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The options a relation control offers, which are never only the slice list: that list arrives when the dropdown
|
|
34
|
+
* opens and holds one page when it does, so a control rendering a value the list does not carry would render an
|
|
35
|
+
* empty field over a form that holds the value. What is missing from it is filled from the value the control was
|
|
36
|
+
* handed, and — for an id-valued control, which holds no row at all — read by id through {@link LightRefCache}.
|
|
37
|
+
*/
|
|
38
|
+
export const useRelationOptions = <Light extends { id: string }>({
|
|
39
|
+
slice,
|
|
40
|
+
ids,
|
|
41
|
+
pinned,
|
|
42
|
+
initArgs,
|
|
43
|
+
sortOption,
|
|
44
|
+
renderOption,
|
|
45
|
+
}: RelationOptionsSource<Light>) => {
|
|
46
|
+
const { refName, sliceName } = slice;
|
|
47
|
+
const [modelName, ModelName] = [lowerlize(refName), capitalize(refName)];
|
|
48
|
+
const names = {
|
|
49
|
+
modelList: sliceName.replace(modelName, `${modelName}List`),
|
|
50
|
+
modelListLoading: sliceName.replace(modelName, `${modelName}ListLoading`),
|
|
51
|
+
refreshModel: sliceName.replace(modelName, `refresh${ModelName}`),
|
|
52
|
+
};
|
|
53
|
+
const storeUse = st.use as { [key: string]: () => unknown };
|
|
54
|
+
const storeDo = st.do as unknown as { [key: string]: (...args: any[]) => Promise<void> };
|
|
55
|
+
const storeGet = st.get as unknown as <V>() => { [key: string]: V };
|
|
56
|
+
const sliceList = storeUse[names.modelList]() as DataList<Light>;
|
|
57
|
+
const listLoading = storeUse[names.modelListLoading]() as boolean;
|
|
58
|
+
const [resolvedCount, setResolvedCount] = useState(0);
|
|
59
|
+
|
|
60
|
+
const held = (pinned ?? []).filter((row): row is Light => !!row);
|
|
61
|
+
const heldKey = held.map((row) => row.id).join(",");
|
|
62
|
+
const missing = ids.filter((id) => !!id && !sliceList.get(id) && !held.some((row) => row.id === id));
|
|
63
|
+
const missingKey = missing.join(",");
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!missingKey) return;
|
|
67
|
+
const wanted = new Set(missingKey.split(","));
|
|
68
|
+
const off = LightRefCache.subscribe((readRefName, id) => {
|
|
69
|
+
if (readRefName === refName && wanted.has(id)) setResolvedCount((count) => count + 1);
|
|
70
|
+
});
|
|
71
|
+
LightRefCache.load(refName, [...wanted]);
|
|
72
|
+
return off;
|
|
73
|
+
}, [refName, missingKey]);
|
|
74
|
+
|
|
75
|
+
const cnst = useMemo(() => ConstantRegistry.getDatabase(refName, { allowEmpty: true }), [refName]);
|
|
76
|
+
const models = useMemo(
|
|
77
|
+
() => merge(sliceList, [...held, ...missing.map((id) => LightRefCache.get<Light>(refName, id))], sortOption),
|
|
78
|
+
[sliceList, heldKey, missingKey, resolvedCount],
|
|
79
|
+
);
|
|
80
|
+
/** The one line an option reads as, so a search matches it and an agent can match an id against the screen. */
|
|
81
|
+
const optionLabel = (model: Light) => {
|
|
82
|
+
const rendered = renderOption?.(model);
|
|
83
|
+
if (typeof rendered === "string" && rendered) return rendered;
|
|
84
|
+
return labelOf(cnst?.full, model) ?? model.id;
|
|
85
|
+
};
|
|
86
|
+
const options = useMemo(() => models.map((model) => ({ label: optionLabel(model), value: model.id })), [models]);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
models,
|
|
90
|
+
options,
|
|
91
|
+
optionLabel,
|
|
92
|
+
listLoading,
|
|
93
|
+
/**
|
|
94
|
+
* `invalidate: false` so an open reuses a list the page already loaded rather than replacing it: the slice
|
|
95
|
+
* list is one store key, so refetching it here overwrites whatever listing of the same model is on screen.
|
|
96
|
+
*/
|
|
97
|
+
load: () => storeDo[names.refreshModel]({ invalidate: false, queryArgs: initArgs }),
|
|
98
|
+
/** Live rather than the rendered list, because the agent's list tool loads and reads within one call. */
|
|
99
|
+
read: () =>
|
|
100
|
+
merge(
|
|
101
|
+
storeGet<DataList<Light>>()[names.modelList],
|
|
102
|
+
[...held, ...ids.map((id) => LightRefCache.get<Light>(refName, id))],
|
|
103
|
+
sortOption,
|
|
104
|
+
),
|
|
105
|
+
};
|
|
106
|
+
};
|
package/ui/Select.tsx
CHANGED
|
@@ -21,9 +21,12 @@ import { BsQuestionCircleFill } from "react-icons/bs";
|
|
|
21
21
|
import { TiDelete } from "react-icons/ti";
|
|
22
22
|
import { agentAttrs } from "./agentAttrs";
|
|
23
23
|
import { Label } from "./Field/Label";
|
|
24
|
+
import { Spin } from "./Loading/Spin";
|
|
24
25
|
import { overlayZ, useOverlayLayerProps } from "./overlayLayer";
|
|
25
26
|
import { useOverlayPosition } from "./overlayPosition";
|
|
26
|
-
import { useUiOverride } from "./UiOverride";
|
|
27
|
+
import { createOverridable, useUiOverride } from "./UiOverride";
|
|
28
|
+
|
|
29
|
+
const LoadingSpin = createOverridable("LoadingSpin", Spin);
|
|
27
30
|
|
|
28
31
|
interface LabelOption<T> {
|
|
29
32
|
label: string | boolean | number;
|
|
@@ -59,6 +62,8 @@ export interface SelectProps<
|
|
|
59
62
|
nullable?: boolean;
|
|
60
63
|
/** Disable open and selection behavior. */
|
|
61
64
|
disabled?: boolean;
|
|
65
|
+
/** The options are still being loaded: shows a spinner in place of the empty placeholder. */
|
|
66
|
+
loading?: boolean;
|
|
62
67
|
/** Called when the dropdown opens. */
|
|
63
68
|
onOpen?: () => void;
|
|
64
69
|
/** Controlled change callback. Receives next and previous value. */
|
|
@@ -87,6 +92,7 @@ const DefaultSelect = <
|
|
|
87
92
|
options,
|
|
88
93
|
nullable,
|
|
89
94
|
disabled,
|
|
95
|
+
loading,
|
|
90
96
|
multiple,
|
|
91
97
|
searchable,
|
|
92
98
|
placeholder,
|
|
@@ -286,20 +292,20 @@ const DefaultSelect = <
|
|
|
286
292
|
<span className="flex w-full flex-wrap items-center gap-1">
|
|
287
293
|
{multiple
|
|
288
294
|
? selectedValues.map((v, index) => {
|
|
295
|
+
|
|
289
296
|
const optionValue = labeledOptions.find((option) => option.value === v);
|
|
290
|
-
if (!optionValue) return null;
|
|
291
297
|
return (
|
|
292
298
|
<div
|
|
293
299
|
key={index}
|
|
294
300
|
className="flex items-center gap-2 rounded-field bg-primary px-2 py-1 text-primary-foreground text-xs"
|
|
295
301
|
>
|
|
296
|
-
{renderSelected ? renderSelected(
|
|
302
|
+
{renderSelected ? renderSelected(v) : (optionValue?.label ?? String(v))}
|
|
297
303
|
<button
|
|
298
304
|
type="button"
|
|
299
305
|
onClick={(e) => {
|
|
300
306
|
e.stopPropagation();
|
|
301
307
|
e.preventDefault();
|
|
302
|
-
onSelect(
|
|
308
|
+
onSelect(v);
|
|
303
309
|
}}
|
|
304
310
|
className="opacity-50 duration-300 hover:opacity-100"
|
|
305
311
|
>
|
|
@@ -314,8 +320,8 @@ const DefaultSelect = <
|
|
|
314
320
|
{(() => {
|
|
315
321
|
|
|
316
322
|
const optionValue = labeledOptions.find((option) => selected(option.value));
|
|
317
|
-
|
|
318
|
-
return renderSelected ? renderSelected(
|
|
323
|
+
const v = selectedValues[0] as T;
|
|
324
|
+
return renderSelected ? renderSelected(v) : (optionValue?.label ?? String(v));
|
|
319
325
|
})()}
|
|
320
326
|
</div>
|
|
321
327
|
) : null}
|
|
@@ -431,14 +437,18 @@ const DefaultSelect = <
|
|
|
431
437
|
</div>
|
|
432
438
|
);
|
|
433
439
|
})}
|
|
434
|
-
{searchOptions.length
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
440
|
+
{searchOptions.length ? null : loading ? (
|
|
441
|
+
<div className="flex size-full items-center justify-center p-4">
|
|
442
|
+
<LoadingSpin />
|
|
443
|
+
</div>
|
|
444
|
+
) : (
|
|
445
|
+
(empty ?? (
|
|
446
|
+
<div className="flex size-full flex-col items-center justify-center gap-2 p-2 text-center text-5xl text-foreground/50">
|
|
447
|
+
<BsQuestionCircleFill />
|
|
448
|
+
<div className="text-sm">{l("base.noOptions")}</div>
|
|
449
|
+
</div>
|
|
450
|
+
))
|
|
451
|
+
)}
|
|
442
452
|
</div>,
|
|
443
453
|
portal,
|
|
444
454
|
)
|
package/ui/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
+
export { type AgentReferenceInput, useAgentReference } from "akanjs/store";
|
|
3
4
|
export {
|
|
4
5
|
AgentProvider,
|
|
5
6
|
type AgentProviderProps,
|
|
@@ -11,7 +12,9 @@ export {
|
|
|
11
12
|
type ContextBlock,
|
|
12
13
|
httpRunner,
|
|
13
14
|
type MessageAttachment,
|
|
15
|
+
type MessageReference,
|
|
14
16
|
type PublishedTool,
|
|
17
|
+
Reference,
|
|
15
18
|
type RunnerEvent,
|
|
16
19
|
type RunnerRequest,
|
|
17
20
|
SessionContext,
|
|
@@ -41,10 +44,15 @@ export { type CodeProps, DefaultCode, DefaultMarkdown, type MarkdownProps } from
|
|
|
41
44
|
export { DefaultAgentMenu, type MenuProps as AgentMenuProps, type MenuRow } from "./Agent/Menu";
|
|
42
45
|
export { DefaultQuestion, type QuestionProps } from "./Agent/Question";
|
|
43
46
|
export { DefaultQueued, type QueuedProps } from "./Agent/Queued";
|
|
47
|
+
export {
|
|
48
|
+
ReferenceChips as AgentReferences,
|
|
49
|
+
type ReferenceChipsProps as AgentReferencesProps,
|
|
50
|
+
} from "./Agent/Refer";
|
|
44
51
|
export type { PersistOption } from "./Agent/sessionHistory";
|
|
45
52
|
export type { AgentBuiltin, BuiltinOption } from "./Agent/sessionView";
|
|
46
53
|
export { tokenCount } from "./Agent/tokenCount";
|
|
47
54
|
export type { QueuedMessage } from "./Agent/useChatQueue";
|
|
55
|
+
export type { ReferenceCandidate, ReferenceSource } from "./Agent/useReferenceMenu";
|
|
48
56
|
export type { VoiceEngine, VoiceHandlers, VoiceListener, VoiceSpeech } from "./Agent/voice";
|
|
49
57
|
export { agentAttrs } from "./agentAttrs";
|
|
50
58
|
export { animated } from "./animated";
|