clementine-agent 1.0.72 → 1.0.74
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,27 @@
|
|
|
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
|
+
* For typeahead pickers, may include the placeholder `{{query}}` which
|
|
26
|
+
* the server substitutes with the user's typed search string. */
|
|
27
|
+
intent: string;
|
|
28
|
+
/** When set, the picker renders as a typeahead: user types at least
|
|
29
|
+
* `minQueryLength` chars (default 2), we debounce ~400ms, then fire the
|
|
30
|
+
* probe with `{{query}}` replaced. Use for tools whose list operation
|
|
31
|
+
* requires a query argument (e.g. search_contacts, Gmail search). */
|
|
32
|
+
queryArg?: string;
|
|
33
|
+
/** Minimum characters the user must type before firing the probe. Ignored
|
|
34
|
+
* when queryArg is unset. Default 2. */
|
|
35
|
+
minQueryLength?: number;
|
|
36
|
+
/** If true, user can type a custom value instead of picking from the list
|
|
37
|
+
* (falls back to the raw text). Useful when the source allows queries
|
|
38
|
+
* that aren't enumerable. */
|
|
39
|
+
allowCustom?: boolean;
|
|
40
|
+
}
|
|
20
41
|
export interface RecipeField {
|
|
21
42
|
key: string;
|
|
22
43
|
label: string;
|
|
@@ -24,6 +45,8 @@ export interface RecipeField {
|
|
|
24
45
|
help?: string;
|
|
25
46
|
required?: boolean;
|
|
26
47
|
defaultValue?: string;
|
|
48
|
+
/** When present, render as a searchable picker populated by /api/brain/mcp/probe. */
|
|
49
|
+
picker?: RecipeFieldPicker;
|
|
27
50
|
}
|
|
28
51
|
export interface ConnectorRecipe {
|
|
29
52
|
/** 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,42 @@ 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: 'call search_contacts with query "{{query}}". For each match, output {id: the contact\'s phone number or email handle, label: the display name (or the handle if no name), sublabel: "handle: " + handle}. Return up to 15 results.',
|
|
217
|
+
queryArg: 'query',
|
|
218
|
+
minQueryLength: 2,
|
|
219
|
+
allowCustom: true,
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
{ key: 'limit', label: 'Max messages per run', placeholder: '50', defaultValue: '50' },
|
|
223
|
+
],
|
|
224
|
+
defaultSchedule: '0 */6 * * *',
|
|
225
|
+
tier: 2,
|
|
226
|
+
slugFromValues: (v) => `imessage-${slugify(v.contact || 'thread')}`,
|
|
227
|
+
buildPrompt: (v, ctx) => `You are running the iMessage feed for contact "${v.contact}".
|
|
228
|
+
|
|
229
|
+
Goal: ingest up to ${v.limit || '50'} recent iMessage messages from the thread with ${v.contact} into the brain.
|
|
230
|
+
|
|
231
|
+
Steps:
|
|
232
|
+
1. Call \`read_imessages\` (iMessage) for contact "${v.contact}" with limit ${v.limit || '50'}.
|
|
233
|
+
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}\`.
|
|
234
|
+
3. ${COMMIT_INSTRUCTIONS.replace(/{{slug}}/g, ctx.slug).replace(/{{targetFolder}}/g, ctx.targetFolder)}
|
|
180
235
|
`,
|
|
181
236
|
},
|
|
182
237
|
{
|
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,85 @@ 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 rawIntent = String(body.intent ?? '').trim();
|
|
2639
|
+
const userQuery = String(body.query ?? '').trim();
|
|
2640
|
+
if (!tool) {
|
|
2641
|
+
res.status(400).json({ error: 'tool is required' });
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
if (!rawIntent) {
|
|
2645
|
+
res.status(400).json({ error: 'intent is required' });
|
|
2646
|
+
return;
|
|
2647
|
+
}
|
|
2648
|
+
// Typeahead pickers include a {{query}} placeholder. Substitute (and
|
|
2649
|
+
// escape any JSON-breakers) before cache key + prompt.
|
|
2650
|
+
const safeQuery = userQuery.replace(/"/g, '\\"').slice(0, 200);
|
|
2651
|
+
const intent = rawIntent.replace(/\{\{query\}\}/g, safeQuery);
|
|
2652
|
+
const cacheKey = `${tool}::${intent}`;
|
|
2653
|
+
if (!body.force) {
|
|
2654
|
+
const cached = brainProbeCache.get(cacheKey);
|
|
2655
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
2656
|
+
res.json({ items: cached.items, cached: true });
|
|
2657
|
+
return;
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
|
2661
|
+
const { parseJsonResponse } = await import('../brain/llm-client.js');
|
|
2662
|
+
const { MODELS } = await import('../config.js');
|
|
2663
|
+
// Strict prompt: force JSON-only output. The tool lookup goes through
|
|
2664
|
+
// the SDK so claude_ai_* and regular MCP servers work uniformly.
|
|
2665
|
+
const stream = query({
|
|
2666
|
+
prompt: `Call the tool \`${tool}\` to ${intent}.
|
|
2667
|
+
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.
|
|
2668
|
+
Do NOT include prose, markdown, code fences, or explanation. Just the JSON array.
|
|
2669
|
+
If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
2670
|
+
options: {
|
|
2671
|
+
model: MODELS.haiku,
|
|
2672
|
+
maxTurns: 3,
|
|
2673
|
+
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.',
|
|
2674
|
+
allowedTools: [tool],
|
|
2675
|
+
permissionMode: 'bypassPermissions',
|
|
2676
|
+
settingSources: [],
|
|
2677
|
+
},
|
|
2678
|
+
});
|
|
2679
|
+
let text = '';
|
|
2680
|
+
for await (const msg of stream) {
|
|
2681
|
+
if (msg.type === 'assistant') {
|
|
2682
|
+
const content = msg.message?.content ?? [];
|
|
2683
|
+
for (const block of content) {
|
|
2684
|
+
if (block.type === 'text' && typeof block.text === 'string')
|
|
2685
|
+
text += block.text;
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
else if (msg.type === 'result') {
|
|
2689
|
+
break;
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
const parsed = parseJsonResponse(text);
|
|
2693
|
+
const items = Array.isArray(parsed)
|
|
2694
|
+
? parsed
|
|
2695
|
+
.filter((p) => p && typeof p.id === 'string' && typeof p.label === 'string')
|
|
2696
|
+
.map((p) => ({ id: p.id, label: p.label, sublabel: typeof p.sublabel === 'string' ? p.sublabel : undefined }))
|
|
2697
|
+
: [];
|
|
2698
|
+
brainProbeCache.set(cacheKey, { items, expiresAt: Date.now() + BRAIN_PROBE_TTL_MS });
|
|
2699
|
+
// Always include a short preview of the agent's raw output so the UI
|
|
2700
|
+
// can explain why a picker is empty (common causes: tool errored, tool
|
|
2701
|
+
// returned nothing matching, agent refused in prose).
|
|
2702
|
+
res.json({ items, cached: false, rawPreview: text.slice(0, 400) });
|
|
2703
|
+
}
|
|
2704
|
+
catch (err) {
|
|
2705
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
2706
|
+
}
|
|
2707
|
+
});
|
|
2585
2708
|
app.get('/api/brain/feeds', (_req, res) => {
|
|
2586
2709
|
try {
|
|
2587
2710
|
const { parsed, jobs } = readCronFileAt(CRON_FILE);
|
|
@@ -10666,6 +10789,156 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
10666
10789
|
brainFeedWizardRender();
|
|
10667
10790
|
}
|
|
10668
10791
|
|
|
10792
|
+
// Per-field typeahead state: { [fieldKey]: { timer, lastQuery } }
|
|
10793
|
+
const brainPickerTypeahead = {};
|
|
10794
|
+
|
|
10795
|
+
async function brainRenderFieldPicker(field, values) {
|
|
10796
|
+
const container = document.querySelector('[data-field-picker="' + field.key + '"]');
|
|
10797
|
+
const hidden = document.querySelector('input[type=hidden][data-field="' + field.key + '"]');
|
|
10798
|
+
if (!container || !hidden) return;
|
|
10799
|
+
const picker = field.picker;
|
|
10800
|
+
|
|
10801
|
+
// Typeahead mode: render a search box. User types → debounce → probe.
|
|
10802
|
+
if (picker.queryArg) {
|
|
10803
|
+
const minLen = picker.minQueryLength || 2;
|
|
10804
|
+
const currentVal = hidden.value || values[field.key] || '';
|
|
10805
|
+
container.innerHTML =
|
|
10806
|
+
'<input type="text" id="brain-picker-search-' + field.key + '" placeholder="Type to search (min ' + minLen + ' chars)…" style="width:100%" value="">' +
|
|
10807
|
+
'<div id="brain-picker-results-' + field.key + '" style="margin-top:6px;max-height:240px;overflow-y:auto"></div>' +
|
|
10808
|
+
(currentVal
|
|
10809
|
+
? '<div style="font-size:12px;margin-top:6px">Selected: <code>' + escapeHtml(currentVal) + '</code></div>'
|
|
10810
|
+
: '');
|
|
10811
|
+
const searchEl = document.getElementById('brain-picker-search-' + field.key);
|
|
10812
|
+
searchEl.oninput = function() {
|
|
10813
|
+
const q = this.value.trim();
|
|
10814
|
+
if (brainPickerTypeahead[field.key] && brainPickerTypeahead[field.key].timer) {
|
|
10815
|
+
clearTimeout(brainPickerTypeahead[field.key].timer);
|
|
10816
|
+
}
|
|
10817
|
+
if (q.length < minLen) {
|
|
10818
|
+
document.getElementById('brain-picker-results-' + field.key).innerHTML =
|
|
10819
|
+
'<div style="color:var(--muted);font-size:12px;padding:6px">Type at least ' + minLen + ' characters…</div>';
|
|
10820
|
+
return;
|
|
10821
|
+
}
|
|
10822
|
+
brainPickerTypeahead[field.key] = {
|
|
10823
|
+
timer: setTimeout(function() { brainFireTypeaheadProbe(field, q); }, 400),
|
|
10824
|
+
};
|
|
10825
|
+
};
|
|
10826
|
+
return;
|
|
10827
|
+
}
|
|
10828
|
+
|
|
10829
|
+
try {
|
|
10830
|
+
const resp = await apiFetch('/api/brain/mcp/probe', {
|
|
10831
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
10832
|
+
body: JSON.stringify({ tool: picker.tool, intent: picker.intent }),
|
|
10833
|
+
});
|
|
10834
|
+
const data = await resp.json();
|
|
10835
|
+
if (!resp.ok) throw new Error(data.error || 'probe failed');
|
|
10836
|
+
|
|
10837
|
+
const items = data.items || [];
|
|
10838
|
+
const currentVal = hidden.value || values[field.key] || '';
|
|
10839
|
+
|
|
10840
|
+
if (!items.length) {
|
|
10841
|
+
// No items returned. Fall back to a plain text input so the user
|
|
10842
|
+
// can still set the value manually.
|
|
10843
|
+
container.innerHTML =
|
|
10844
|
+
'<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">' +
|
|
10845
|
+
'<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>';
|
|
10846
|
+
return;
|
|
10847
|
+
}
|
|
10848
|
+
|
|
10849
|
+
const options = items.map(function(it) {
|
|
10850
|
+
const selected = it.id === currentVal ? ' selected' : '';
|
|
10851
|
+
const lbl = it.label + (it.sublabel ? ' — ' + it.sublabel : '');
|
|
10852
|
+
return '<option value="' + escapeHtml(it.id) + '"' + selected + '>' + escapeHtml(lbl) + '</option>';
|
|
10853
|
+
}).join('');
|
|
10854
|
+
|
|
10855
|
+
const customAllowed = picker.allowCustom;
|
|
10856
|
+
container.innerHTML =
|
|
10857
|
+
'<select style="width:100%;padding:6px" onchange="(document.querySelector(\\'input[type=hidden][data-field=' + field.key + ']\\')||{}).value=this.value">' +
|
|
10858
|
+
(currentVal && !items.find(function(i) { return i.id === currentVal; }) ? '<option value="' + escapeHtml(currentVal) + '" selected>' + escapeHtml(currentVal) + ' (custom)</option>' : '') +
|
|
10859
|
+
'<option value="">— pick one —</option>' +
|
|
10860
|
+
options +
|
|
10861
|
+
'</select>' +
|
|
10862
|
+
'<div style="font-size:11px;color:var(--muted);margin-top:4px">' +
|
|
10863
|
+
items.length + ' result' + (items.length === 1 ? '' : 's') + (data.cached ? ' (cached)' : ' from ' + escapeHtml(picker.tool)) +
|
|
10864
|
+
(customAllowed ? ' · <a href="#" onclick="brainFieldPickerToggleCustom(\\'' + field.key + '\\', \\'' + encodeURIComponent(JSON.stringify(picker)) + '\\');return false">type a custom value instead</a>' : '') +
|
|
10865
|
+
'</div>';
|
|
10866
|
+
// Ensure the hidden input matches the initial selection
|
|
10867
|
+
if (!hidden.value && items[0]) hidden.value = '';
|
|
10868
|
+
} catch (err) {
|
|
10869
|
+
container.innerHTML =
|
|
10870
|
+
'<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">' +
|
|
10871
|
+
'<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>';
|
|
10872
|
+
}
|
|
10873
|
+
}
|
|
10874
|
+
|
|
10875
|
+
async function brainFireTypeaheadProbe(field, query) {
|
|
10876
|
+
const resultsEl = document.getElementById('brain-picker-results-' + field.key);
|
|
10877
|
+
const hidden = document.querySelector('input[type=hidden][data-field="' + field.key + '"]');
|
|
10878
|
+
if (!resultsEl || !hidden) return;
|
|
10879
|
+
resultsEl.innerHTML = '<div style="color:var(--muted);font-size:12px;padding:6px">Searching…</div>';
|
|
10880
|
+
try {
|
|
10881
|
+
const resp = await apiFetch('/api/brain/mcp/probe', {
|
|
10882
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
10883
|
+
body: JSON.stringify({ tool: field.picker.tool, intent: field.picker.intent, query }),
|
|
10884
|
+
});
|
|
10885
|
+
const data = await resp.json();
|
|
10886
|
+
if (!resp.ok) throw new Error(data.error || 'probe failed');
|
|
10887
|
+
const items = data.items || [];
|
|
10888
|
+
if (!items.length) {
|
|
10889
|
+
resultsEl.innerHTML = '<div style="color:#8a5a00;font-size:12px;padding:6px">No matches for "' + escapeHtml(query) + '"' + (data.rawPreview ? ' (' + escapeHtml(data.rawPreview.slice(0, 100)) + ')' : '') + '</div>';
|
|
10890
|
+
return;
|
|
10891
|
+
}
|
|
10892
|
+
resultsEl.innerHTML = items.map(function(it) {
|
|
10893
|
+
const lbl = escapeHtml(it.label) + (it.sublabel ? ' <span style="color:var(--muted);font-size:11px">— ' + escapeHtml(it.sublabel) + '</span>' : '');
|
|
10894
|
+
return '<button class="btn" onclick="brainPickTypeaheadItem(\\'' + field.key + '\\', \\'' + encodeURIComponent(it.id) + '\\', \\'' + encodeURIComponent(it.label) + '\\')" style="display:block;width:100%;text-align:left;padding:6px 10px;margin-bottom:2px">' + lbl + '</button>';
|
|
10895
|
+
}).join('') +
|
|
10896
|
+
'<div style="font-size:11px;color:var(--muted);margin-top:4px">' + items.length + ' result' + (items.length === 1 ? '' : 's') + (data.cached ? ' (cached)' : '') + '</div>';
|
|
10897
|
+
} catch (err) {
|
|
10898
|
+
resultsEl.innerHTML = '<div style="color:#e66;font-size:12px;padding:6px">' + escapeHtml(String(err && err.message ? err.message : err)) + '</div>';
|
|
10899
|
+
}
|
|
10900
|
+
}
|
|
10901
|
+
|
|
10902
|
+
function brainPickTypeaheadItem(fieldKey, encodedId, encodedLabel) {
|
|
10903
|
+
const id = decodeURIComponent(encodedId);
|
|
10904
|
+
const label = decodeURIComponent(encodedLabel);
|
|
10905
|
+
const hidden = document.querySelector('input[type=hidden][data-field="' + fieldKey + '"]');
|
|
10906
|
+
if (hidden) hidden.value = id;
|
|
10907
|
+
// Show a confirmation chip and collapse the results.
|
|
10908
|
+
const container = document.querySelector('[data-field-picker="' + fieldKey + '"]');
|
|
10909
|
+
if (!container) return;
|
|
10910
|
+
const searchEl = document.getElementById('brain-picker-search-' + fieldKey);
|
|
10911
|
+
if (searchEl) searchEl.value = label;
|
|
10912
|
+
const resultsEl = document.getElementById('brain-picker-results-' + fieldKey);
|
|
10913
|
+
if (resultsEl) resultsEl.innerHTML = '';
|
|
10914
|
+
// Inject a "selected" line
|
|
10915
|
+
let sel = container.querySelector('.brain-picker-selected');
|
|
10916
|
+
if (!sel) {
|
|
10917
|
+
sel = document.createElement('div');
|
|
10918
|
+
sel.className = 'brain-picker-selected';
|
|
10919
|
+
sel.style.cssText = 'font-size:12px;margin-top:6px';
|
|
10920
|
+
container.appendChild(sel);
|
|
10921
|
+
}
|
|
10922
|
+
sel.innerHTML = '✓ Selected: <b>' + escapeHtml(label) + '</b> <code style="font-size:11px;color:var(--muted)">' + escapeHtml(id) + '</code>';
|
|
10923
|
+
}
|
|
10924
|
+
|
|
10925
|
+
function brainFieldPickerToggleCustom(fieldKey, encodedPicker) {
|
|
10926
|
+
const container = document.querySelector('[data-field-picker="' + fieldKey + '"]');
|
|
10927
|
+
const hidden = document.querySelector('input[type=hidden][data-field="' + fieldKey + '"]');
|
|
10928
|
+
if (!container || !hidden) return;
|
|
10929
|
+
container.innerHTML =
|
|
10930
|
+
'<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">' +
|
|
10931
|
+
'<div style="font-size:11px;margin-top:4px"><a href="#" onclick="brainFieldPickerReload(\\'' + fieldKey + '\\', \\'' + encodedPicker + '\\');return false">← back to picker</a></div>';
|
|
10932
|
+
}
|
|
10933
|
+
|
|
10934
|
+
async function brainFieldPickerReload(fieldKey, encodedPicker) {
|
|
10935
|
+
const picker = JSON.parse(decodeURIComponent(encodedPicker));
|
|
10936
|
+
const s = brainFeedWizardState;
|
|
10937
|
+
if (!s) return;
|
|
10938
|
+
const field = (s.recipe.fields || []).find(function(f) { return f.key === fieldKey; });
|
|
10939
|
+
if (field) await brainRenderFieldPicker(field, s.values);
|
|
10940
|
+
}
|
|
10941
|
+
|
|
10669
10942
|
function brainFeedWizardRender() {
|
|
10670
10943
|
if (!brainFeedWizardState) return;
|
|
10671
10944
|
const s = brainFeedWizardState;
|
|
@@ -10711,13 +10984,30 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
10711
10984
|
if (!fields.length) {
|
|
10712
10985
|
html = '<div style="color:var(--muted)">This recipe has no fields. Click Next to pick a schedule.</div>';
|
|
10713
10986
|
} else {
|
|
10714
|
-
html = '<div style="display:grid;grid-template-columns:180px 1fr;gap:10px;align-items:
|
|
10987
|
+
html = '<div style="display:grid;grid-template-columns:180px 1fr;gap:10px;align-items:start">' +
|
|
10715
10988
|
fields.map(function(f) {
|
|
10716
10989
|
const val = s.values[f.key] != null ? s.values[f.key] : (f.defaultValue || '');
|
|
10717
10990
|
const help = f.help ? '<div style="font-size:11px;color:var(--muted);margin-top:2px">' + escapeHtml(f.help) + '</div>' : '';
|
|
10718
|
-
|
|
10719
|
-
|
|
10991
|
+
let control;
|
|
10992
|
+
if (f.picker) {
|
|
10993
|
+
// Pickers render their own container; fetchPicker fills it.
|
|
10994
|
+
control = '<div data-field-picker="' + f.key + '" style="width:100%">' +
|
|
10995
|
+
'<div style="color:var(--muted);font-size:13px;padding:6px">Loading choices…</div>' +
|
|
10996
|
+
'</div>' +
|
|
10997
|
+
'<input type="hidden" data-field="' + f.key + '" value="' + escapeHtml(val) + '">';
|
|
10998
|
+
} else {
|
|
10999
|
+
control = '<input type="text" data-field="' + f.key + '" value="' + escapeHtml(val) + '" placeholder="' + escapeHtml(f.placeholder || '') + '" style="width:100%">';
|
|
11000
|
+
}
|
|
11001
|
+
return '<label style="font-weight:500;padding-top:6px">' + escapeHtml(f.label) + (f.required ? ' <span style="color:#e66">*</span>' : '') + '</label>' +
|
|
11002
|
+
'<div>' + control + help + '</div>';
|
|
10720
11003
|
}).join('') + '</div>';
|
|
11004
|
+
|
|
11005
|
+
// After render, fire probes for any picker fields.
|
|
11006
|
+
setTimeout(function() {
|
|
11007
|
+
for (const f of fields) {
|
|
11008
|
+
if (f.picker) brainRenderFieldPicker(f, s.values);
|
|
11009
|
+
}
|
|
11010
|
+
}, 0);
|
|
10721
11011
|
}
|
|
10722
11012
|
} else if (s.step === 3) {
|
|
10723
11013
|
const chips = brainScheduleChips();
|