clementine-agent 1.0.72 → 1.0.73
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.
|
@@ -17,6 +17,17 @@
|
|
|
17
17
|
* {{fieldKey}} — user-supplied value
|
|
18
18
|
* {{slug}} — the feed's computed slug (used for folder + dedup)
|
|
19
19
|
*/
|
|
20
|
+
export interface RecipeFieldPicker {
|
|
21
|
+
/** Full tool name, e.g. "mcp__claude_ai_Google_Drive__search_files" */
|
|
22
|
+
tool: string;
|
|
23
|
+
/** Natural-language instruction to the probe agent — becomes the body of
|
|
24
|
+
* "Call the tool X to {intent}, return a JSON array of {id, label}". */
|
|
25
|
+
intent: string;
|
|
26
|
+
/** If true, user can type a custom value instead of picking from the list
|
|
27
|
+
* (falls back to the raw text). Useful when the source allows queries
|
|
28
|
+
* that aren't enumerable. */
|
|
29
|
+
allowCustom?: boolean;
|
|
30
|
+
}
|
|
20
31
|
export interface RecipeField {
|
|
21
32
|
key: string;
|
|
22
33
|
label: string;
|
|
@@ -24,6 +35,8 @@ export interface RecipeField {
|
|
|
24
35
|
help?: string;
|
|
25
36
|
required?: boolean;
|
|
26
37
|
defaultValue?: string;
|
|
38
|
+
/** When present, render as a searchable picker populated by /api/brain/mcp/probe. */
|
|
39
|
+
picker?: RecipeFieldPicker;
|
|
27
40
|
}
|
|
28
41
|
export interface ConnectorRecipe {
|
|
29
42
|
/** Stable id, used as the job-name prefix. */
|
|
@@ -40,8 +40,17 @@ export const RECIPES = [
|
|
|
40
40
|
integration: 'Google_Drive',
|
|
41
41
|
requiredTools: ['search_files', 'read_file_content'],
|
|
42
42
|
fields: [
|
|
43
|
-
{
|
|
44
|
-
|
|
43
|
+
{
|
|
44
|
+
key: 'folder',
|
|
45
|
+
label: 'Folder',
|
|
46
|
+
required: true,
|
|
47
|
+
help: 'Pick a folder from your Google Drive. You can also type a name to filter the list.',
|
|
48
|
+
picker: {
|
|
49
|
+
tool: 'mcp__claude_ai_Google_Drive__search_files',
|
|
50
|
+
intent: 'find up to 20 Google Drive folders. Call search_files exactly once with mimeType filter "application/vnd.google-apps.folder". For each returned folder, output {id: folder.id, label: folder.name, sublabel: "Modified " + folder.modifiedTime}.',
|
|
51
|
+
allowCustom: true,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
45
54
|
],
|
|
46
55
|
defaultSchedule: '0 8 * * *',
|
|
47
56
|
tier: 2,
|
|
@@ -163,7 +172,17 @@ Steps:
|
|
|
163
172
|
integration: 'Microsoft_365',
|
|
164
173
|
requiredTools: ['sharepoint_folder_search', 'read_resource'],
|
|
165
174
|
fields: [
|
|
166
|
-
{
|
|
175
|
+
{
|
|
176
|
+
key: 'folder',
|
|
177
|
+
label: 'Folder',
|
|
178
|
+
required: true,
|
|
179
|
+
help: 'Pick a SharePoint folder. Type to filter.',
|
|
180
|
+
picker: {
|
|
181
|
+
tool: 'mcp__claude_ai_Microsoft_365__sharepoint_folder_search',
|
|
182
|
+
intent: 'list available SharePoint folders and document libraries. Use the folder\'s drive-item id as id, the folder name as label, and the site/library path as sublabel.',
|
|
183
|
+
allowCustom: true,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
167
186
|
],
|
|
168
187
|
defaultSchedule: '0 8 * * *',
|
|
169
188
|
tier: 2,
|
|
@@ -177,6 +196,40 @@ Steps:
|
|
|
177
196
|
2. For each file, use \`read_resource\` to fetch content.
|
|
178
197
|
3. For each record: \`externalId\` = the SharePoint item id, \`title\` = the file name, \`content\` = the extracted text, \`metadata\` = \`{path, modifiedAt, author, size}\`.
|
|
179
198
|
4. ${COMMIT_INSTRUCTIONS.replace(/{{slug}}/g, ctx.slug).replace(/{{targetFolder}}/g, ctx.targetFolder)}
|
|
199
|
+
`,
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'imessage-thread',
|
|
203
|
+
label: 'iMessage: watch a conversation',
|
|
204
|
+
description: 'Ingest recent messages from a specific iMessage contact into the brain.',
|
|
205
|
+
icon: '💬',
|
|
206
|
+
integration: 'imessage',
|
|
207
|
+
requiredTools: ['search_contacts', 'read_imessages'],
|
|
208
|
+
fields: [
|
|
209
|
+
{
|
|
210
|
+
key: 'contact',
|
|
211
|
+
label: 'Contact',
|
|
212
|
+
required: true,
|
|
213
|
+
help: 'Pick an iMessage contact. Their phone number or email becomes the thread id.',
|
|
214
|
+
picker: {
|
|
215
|
+
tool: 'mcp__imessage__search_contacts',
|
|
216
|
+
intent: 'list my iMessage contacts. Use the handle (phone number or email) as id, the display name (or handle if no name) as label, and the most-recent-message date as sublabel.',
|
|
217
|
+
allowCustom: true,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
{ key: 'limit', label: 'Max messages per run', placeholder: '50', defaultValue: '50' },
|
|
221
|
+
],
|
|
222
|
+
defaultSchedule: '0 */6 * * *',
|
|
223
|
+
tier: 2,
|
|
224
|
+
slugFromValues: (v) => `imessage-${slugify(v.contact || 'thread')}`,
|
|
225
|
+
buildPrompt: (v, ctx) => `You are running the iMessage feed for contact "${v.contact}".
|
|
226
|
+
|
|
227
|
+
Goal: ingest up to ${v.limit || '50'} recent iMessage messages from the thread with ${v.contact} into the brain.
|
|
228
|
+
|
|
229
|
+
Steps:
|
|
230
|
+
1. Call \`read_imessages\` (iMessage) for contact "${v.contact}" with limit ${v.limit || '50'}.
|
|
231
|
+
2. For each message: \`externalId\` = the message id (or a stable hash of contact + timestamp + text if no id), \`title\` = first 60 chars of the text or "[attachment]", \`content\` = "From: <handle>\\nDate: <iso>\\n\\n<text>", \`metadata\` = \`{contact, sender, timestamp, isFromMe, service}\`.
|
|
232
|
+
3. ${COMMIT_INSTRUCTIONS.replace(/{{slug}}/g, ctx.slug).replace(/{{targetFolder}}/g, ctx.targetFolder)}
|
|
180
233
|
`,
|
|
181
234
|
},
|
|
182
235
|
{
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -2560,17 +2560,61 @@ export async function cmdDashboard(opts) {
|
|
|
2560
2560
|
// calls brain_ingest_folder to commit them.
|
|
2561
2561
|
app.get('/api/brain/connectors', async (_req, res) => {
|
|
2562
2562
|
try {
|
|
2563
|
-
const { getClaudeIntegrations } = await import('../agent/mcp-bridge.js');
|
|
2563
|
+
const { getClaudeIntegrations, loadToolInventory } = await import('../agent/mcp-bridge.js');
|
|
2564
2564
|
const { RECIPES } = await import('../brain/connector-recipes.js');
|
|
2565
|
-
|
|
2566
|
-
//
|
|
2567
|
-
//
|
|
2568
|
-
const
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2565
|
+
// Claude Desktop integrations carry richer metadata (label, connected
|
|
2566
|
+
// state, firstSeen/lastUsed). Everything else we infer from the SDK
|
|
2567
|
+
// tool inventory by grouping mcp__<server>__<tool> by server prefix.
|
|
2568
|
+
const claudeDesktop = getClaudeIntegrations();
|
|
2569
|
+
const claudeDesktopByName = new Map(claudeDesktop.map((c) => [c.name, c]));
|
|
2570
|
+
const inv = loadToolInventory();
|
|
2571
|
+
const mcpByServer = new Map();
|
|
2572
|
+
for (const toolName of (inv?.tools ?? [])) {
|
|
2573
|
+
if (!toolName.startsWith('mcp__'))
|
|
2574
|
+
continue;
|
|
2575
|
+
const body = toolName.slice(5); // strip "mcp__"
|
|
2576
|
+
const idx = body.indexOf('__');
|
|
2577
|
+
if (idx < 1)
|
|
2578
|
+
continue;
|
|
2579
|
+
const server = body.slice(0, idx);
|
|
2580
|
+
const tool = body.slice(idx + 2);
|
|
2581
|
+
if (!mcpByServer.has(server))
|
|
2582
|
+
mcpByServer.set(server, []);
|
|
2583
|
+
mcpByServer.get(server).push(tool);
|
|
2584
|
+
}
|
|
2585
|
+
const integrations = [];
|
|
2586
|
+
// First, Claude Desktop integrations (match claude_ai_<Name>)
|
|
2587
|
+
for (const [server, tools] of mcpByServer.entries()) {
|
|
2588
|
+
if (!server.startsWith('claude_ai_'))
|
|
2589
|
+
continue;
|
|
2590
|
+
const name = server.slice('claude_ai_'.length);
|
|
2591
|
+
const cd = claudeDesktopByName.get(name);
|
|
2592
|
+
integrations.push({
|
|
2593
|
+
name,
|
|
2594
|
+
label: cd?.label ?? name.replace(/_/g, ' '),
|
|
2595
|
+
kind: 'claude-desktop',
|
|
2596
|
+
tools,
|
|
2597
|
+
connected: cd?.connected ?? true,
|
|
2598
|
+
hasFeedReadyTools: tools.some((t) => !/^(authenticate|complete_authentication)$/.test(t)),
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
2601
|
+
// Then, every other MCP server. These are directly reachable through
|
|
2602
|
+
// the Agent SDK because probeAvailableTools() saw them in init.tools.
|
|
2603
|
+
for (const [server, tools] of mcpByServer.entries()) {
|
|
2604
|
+
if (server.startsWith('claude_ai_'))
|
|
2605
|
+
continue;
|
|
2606
|
+
integrations.push({
|
|
2607
|
+
name: server,
|
|
2608
|
+
label: server.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
2609
|
+
kind: 'mcp-server',
|
|
2610
|
+
tools,
|
|
2611
|
+
connected: true, // if it's in the inventory, the SDK can call it
|
|
2612
|
+
hasFeedReadyTools: tools.length > 0,
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
integrations.sort((a, b) => a.label.localeCompare(b.label));
|
|
2572
2616
|
res.json({
|
|
2573
|
-
integrations
|
|
2617
|
+
integrations,
|
|
2574
2618
|
recipes: RECIPES.map((r) => ({
|
|
2575
2619
|
id: r.id, label: r.label, description: r.description, icon: r.icon,
|
|
2576
2620
|
integration: r.integration, fields: r.fields,
|
|
@@ -2582,6 +2626,80 @@ export async function cmdDashboard(opts) {
|
|
|
2582
2626
|
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
2583
2627
|
}
|
|
2584
2628
|
});
|
|
2629
|
+
// In-memory probe cache. Each entry is (tool, intent) → {items, expiresAt}.
|
|
2630
|
+
// 10-minute TTL so repeated wizard opens don't repeatedly hit Haiku, but
|
|
2631
|
+
// fresh enough that newly-created folders / labels appear within minutes.
|
|
2632
|
+
const brainProbeCache = new Map();
|
|
2633
|
+
const BRAIN_PROBE_TTL_MS = 10 * 60 * 1000;
|
|
2634
|
+
app.post('/api/brain/mcp/probe', async (req, res) => {
|
|
2635
|
+
try {
|
|
2636
|
+
const body = (req.body ?? {});
|
|
2637
|
+
const tool = String(body.tool ?? '').trim();
|
|
2638
|
+
const intent = String(body.intent ?? '').trim();
|
|
2639
|
+
if (!tool) {
|
|
2640
|
+
res.status(400).json({ error: 'tool is required' });
|
|
2641
|
+
return;
|
|
2642
|
+
}
|
|
2643
|
+
if (!intent) {
|
|
2644
|
+
res.status(400).json({ error: 'intent is required' });
|
|
2645
|
+
return;
|
|
2646
|
+
}
|
|
2647
|
+
const cacheKey = `${tool}::${intent}`;
|
|
2648
|
+
if (!body.force) {
|
|
2649
|
+
const cached = brainProbeCache.get(cacheKey);
|
|
2650
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
2651
|
+
res.json({ items: cached.items, cached: true });
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
|
2656
|
+
const { parseJsonResponse } = await import('../brain/llm-client.js');
|
|
2657
|
+
const { MODELS } = await import('../config.js');
|
|
2658
|
+
// Strict prompt: force JSON-only output. The tool lookup goes through
|
|
2659
|
+
// the SDK so claude_ai_* and regular MCP servers work uniformly.
|
|
2660
|
+
const stream = query({
|
|
2661
|
+
prompt: `Call the tool \`${tool}\` to ${intent}.
|
|
2662
|
+
Return ONLY a JSON array. Each element must be an object with shape \`{"id": string, "label": string, "sublabel"?: string}\`. Use the source system's stable id for \`id\` (so the feed can reference it later). Use a short human-readable title for \`label\`. Optional \`sublabel\` can include path, email, date, or any disambiguating detail.
|
|
2663
|
+
Do NOT include prose, markdown, code fences, or explanation. Just the JSON array.
|
|
2664
|
+
If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
2665
|
+
options: {
|
|
2666
|
+
model: MODELS.haiku,
|
|
2667
|
+
maxTurns: 3,
|
|
2668
|
+
systemPrompt: 'You are a data enumerator. You call the given tool once, extract the items from its response, and emit a strict JSON array. No commentary.',
|
|
2669
|
+
allowedTools: [tool],
|
|
2670
|
+
permissionMode: 'bypassPermissions',
|
|
2671
|
+
settingSources: [],
|
|
2672
|
+
},
|
|
2673
|
+
});
|
|
2674
|
+
let text = '';
|
|
2675
|
+
for await (const msg of stream) {
|
|
2676
|
+
if (msg.type === 'assistant') {
|
|
2677
|
+
const content = msg.message?.content ?? [];
|
|
2678
|
+
for (const block of content) {
|
|
2679
|
+
if (block.type === 'text' && typeof block.text === 'string')
|
|
2680
|
+
text += block.text;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
else if (msg.type === 'result') {
|
|
2684
|
+
break;
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
const parsed = parseJsonResponse(text);
|
|
2688
|
+
const items = Array.isArray(parsed)
|
|
2689
|
+
? parsed
|
|
2690
|
+
.filter((p) => p && typeof p.id === 'string' && typeof p.label === 'string')
|
|
2691
|
+
.map((p) => ({ id: p.id, label: p.label, sublabel: typeof p.sublabel === 'string' ? p.sublabel : undefined }))
|
|
2692
|
+
: [];
|
|
2693
|
+
brainProbeCache.set(cacheKey, { items, expiresAt: Date.now() + BRAIN_PROBE_TTL_MS });
|
|
2694
|
+
// Always include a short preview of the agent's raw output so the UI
|
|
2695
|
+
// can explain why a picker is empty (common causes: tool errored, tool
|
|
2696
|
+
// returned nothing matching, agent refused in prose).
|
|
2697
|
+
res.json({ items, cached: false, rawPreview: text.slice(0, 400) });
|
|
2698
|
+
}
|
|
2699
|
+
catch (err) {
|
|
2700
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
2701
|
+
}
|
|
2702
|
+
});
|
|
2585
2703
|
app.get('/api/brain/feeds', (_req, res) => {
|
|
2586
2704
|
try {
|
|
2587
2705
|
const { parsed, jobs } = readCronFileAt(CRON_FILE);
|
|
@@ -10666,6 +10784,75 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
10666
10784
|
brainFeedWizardRender();
|
|
10667
10785
|
}
|
|
10668
10786
|
|
|
10787
|
+
async function brainRenderFieldPicker(field, values) {
|
|
10788
|
+
const container = document.querySelector('[data-field-picker="' + field.key + '"]');
|
|
10789
|
+
const hidden = document.querySelector('input[type=hidden][data-field="' + field.key + '"]');
|
|
10790
|
+
if (!container || !hidden) return;
|
|
10791
|
+
const picker = field.picker;
|
|
10792
|
+
|
|
10793
|
+
try {
|
|
10794
|
+
const resp = await apiFetch('/api/brain/mcp/probe', {
|
|
10795
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
10796
|
+
body: JSON.stringify({ tool: picker.tool, intent: picker.intent }),
|
|
10797
|
+
});
|
|
10798
|
+
const data = await resp.json();
|
|
10799
|
+
if (!resp.ok) throw new Error(data.error || 'probe failed');
|
|
10800
|
+
|
|
10801
|
+
const items = data.items || [];
|
|
10802
|
+
const currentVal = hidden.value || values[field.key] || '';
|
|
10803
|
+
|
|
10804
|
+
if (!items.length) {
|
|
10805
|
+
// No items returned. Fall back to a plain text input so the user
|
|
10806
|
+
// can still set the value manually.
|
|
10807
|
+
container.innerHTML =
|
|
10808
|
+
'<input type="text" value="' + escapeHtml(currentVal) + '" placeholder="(type a value)" style="width:100%" oninput="(document.querySelector(\\'input[type=hidden][data-field=' + field.key + ']\\')||{}).value=this.value">' +
|
|
10809
|
+
'<div style="color:#8a5a00;font-size:11px;margin-top:4px">Nothing returned from the probe — type a value manually' + (data.rawPreview ? ' (probe output: ' + escapeHtml(data.rawPreview.slice(0, 120)) + '…)' : '') + '</div>';
|
|
10810
|
+
return;
|
|
10811
|
+
}
|
|
10812
|
+
|
|
10813
|
+
const options = items.map(function(it) {
|
|
10814
|
+
const selected = it.id === currentVal ? ' selected' : '';
|
|
10815
|
+
const lbl = it.label + (it.sublabel ? ' — ' + it.sublabel : '');
|
|
10816
|
+
return '<option value="' + escapeHtml(it.id) + '"' + selected + '>' + escapeHtml(lbl) + '</option>';
|
|
10817
|
+
}).join('');
|
|
10818
|
+
|
|
10819
|
+
const customAllowed = picker.allowCustom;
|
|
10820
|
+
container.innerHTML =
|
|
10821
|
+
'<select style="width:100%;padding:6px" onchange="(document.querySelector(\\'input[type=hidden][data-field=' + field.key + ']\\')||{}).value=this.value">' +
|
|
10822
|
+
(currentVal && !items.find(function(i) { return i.id === currentVal; }) ? '<option value="' + escapeHtml(currentVal) + '" selected>' + escapeHtml(currentVal) + ' (custom)</option>' : '') +
|
|
10823
|
+
'<option value="">— pick one —</option>' +
|
|
10824
|
+
options +
|
|
10825
|
+
'</select>' +
|
|
10826
|
+
'<div style="font-size:11px;color:var(--muted);margin-top:4px">' +
|
|
10827
|
+
items.length + ' result' + (items.length === 1 ? '' : 's') + (data.cached ? ' (cached)' : ' from ' + escapeHtml(picker.tool)) +
|
|
10828
|
+
(customAllowed ? ' · <a href="#" onclick="brainFieldPickerToggleCustom(\\'' + field.key + '\\', \\'' + encodeURIComponent(JSON.stringify(picker)) + '\\');return false">type a custom value instead</a>' : '') +
|
|
10829
|
+
'</div>';
|
|
10830
|
+
// Ensure the hidden input matches the initial selection
|
|
10831
|
+
if (!hidden.value && items[0]) hidden.value = '';
|
|
10832
|
+
} catch (err) {
|
|
10833
|
+
container.innerHTML =
|
|
10834
|
+
'<input type="text" value="' + escapeHtml(values[field.key] || '') + '" placeholder="(probe failed — type manually)" style="width:100%" oninput="(document.querySelector(\\'input[type=hidden][data-field=' + field.key + ']\\')||{}).value=this.value">' +
|
|
10835
|
+
'<div style="color:#e66;font-size:11px;margin-top:4px">Picker failed: ' + escapeHtml(String(err && err.message ? err.message : err)) + ' — type a value manually.</div>';
|
|
10836
|
+
}
|
|
10837
|
+
}
|
|
10838
|
+
|
|
10839
|
+
function brainFieldPickerToggleCustom(fieldKey, encodedPicker) {
|
|
10840
|
+
const container = document.querySelector('[data-field-picker="' + fieldKey + '"]');
|
|
10841
|
+
const hidden = document.querySelector('input[type=hidden][data-field="' + fieldKey + '"]');
|
|
10842
|
+
if (!container || !hidden) return;
|
|
10843
|
+
container.innerHTML =
|
|
10844
|
+
'<input type="text" value="' + escapeHtml(hidden.value || '') + '" placeholder="(type a value)" style="width:100%" oninput="(document.querySelector(\\'input[type=hidden][data-field=' + fieldKey + ']\\')||{}).value=this.value">' +
|
|
10845
|
+
'<div style="font-size:11px;margin-top:4px"><a href="#" onclick="brainFieldPickerReload(\\'' + fieldKey + '\\', \\'' + encodedPicker + '\\');return false">← back to picker</a></div>';
|
|
10846
|
+
}
|
|
10847
|
+
|
|
10848
|
+
async function brainFieldPickerReload(fieldKey, encodedPicker) {
|
|
10849
|
+
const picker = JSON.parse(decodeURIComponent(encodedPicker));
|
|
10850
|
+
const s = brainFeedWizardState;
|
|
10851
|
+
if (!s) return;
|
|
10852
|
+
const field = (s.recipe.fields || []).find(function(f) { return f.key === fieldKey; });
|
|
10853
|
+
if (field) await brainRenderFieldPicker(field, s.values);
|
|
10854
|
+
}
|
|
10855
|
+
|
|
10669
10856
|
function brainFeedWizardRender() {
|
|
10670
10857
|
if (!brainFeedWizardState) return;
|
|
10671
10858
|
const s = brainFeedWizardState;
|
|
@@ -10711,13 +10898,30 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
10711
10898
|
if (!fields.length) {
|
|
10712
10899
|
html = '<div style="color:var(--muted)">This recipe has no fields. Click Next to pick a schedule.</div>';
|
|
10713
10900
|
} else {
|
|
10714
|
-
html = '<div style="display:grid;grid-template-columns:180px 1fr;gap:10px;align-items:
|
|
10901
|
+
html = '<div style="display:grid;grid-template-columns:180px 1fr;gap:10px;align-items:start">' +
|
|
10715
10902
|
fields.map(function(f) {
|
|
10716
10903
|
const val = s.values[f.key] != null ? s.values[f.key] : (f.defaultValue || '');
|
|
10717
10904
|
const help = f.help ? '<div style="font-size:11px;color:var(--muted);margin-top:2px">' + escapeHtml(f.help) + '</div>' : '';
|
|
10718
|
-
|
|
10719
|
-
|
|
10905
|
+
let control;
|
|
10906
|
+
if (f.picker) {
|
|
10907
|
+
// Pickers render their own container; fetchPicker fills it.
|
|
10908
|
+
control = '<div data-field-picker="' + f.key + '" style="width:100%">' +
|
|
10909
|
+
'<div style="color:var(--muted);font-size:13px;padding:6px">Loading choices…</div>' +
|
|
10910
|
+
'</div>' +
|
|
10911
|
+
'<input type="hidden" data-field="' + f.key + '" value="' + escapeHtml(val) + '">';
|
|
10912
|
+
} else {
|
|
10913
|
+
control = '<input type="text" data-field="' + f.key + '" value="' + escapeHtml(val) + '" placeholder="' + escapeHtml(f.placeholder || '') + '" style="width:100%">';
|
|
10914
|
+
}
|
|
10915
|
+
return '<label style="font-weight:500;padding-top:6px">' + escapeHtml(f.label) + (f.required ? ' <span style="color:#e66">*</span>' : '') + '</label>' +
|
|
10916
|
+
'<div>' + control + help + '</div>';
|
|
10720
10917
|
}).join('') + '</div>';
|
|
10918
|
+
|
|
10919
|
+
// After render, fire probes for any picker fields.
|
|
10920
|
+
setTimeout(function() {
|
|
10921
|
+
for (const f of fields) {
|
|
10922
|
+
if (f.picker) brainRenderFieldPicker(f, s.values);
|
|
10923
|
+
}
|
|
10924
|
+
}, 0);
|
|
10721
10925
|
}
|
|
10722
10926
|
} else if (s.step === 3) {
|
|
10723
10927
|
const chips = brainScheduleChips();
|