llm_meta_widget 0.1.0 → 0.2.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.
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f32625e105ab751f473e79517d113c8b84465e676cddaab0f3db861c8f3e563e
|
|
4
|
+
data.tar.gz: 249d09f198b93e1b8382b2f5ef719736b260fc4c0c2f53d77e68c203b8e236b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 841f81296bed31dbe0ebd7c67253b08990be954a0e5b6939f0563c3d97cb525b8826654149350dbae4afd74ce5b61750058f289c9df25ed4fca5b577783db72b
|
|
7
|
+
data.tar.gz: ced44aaf75dc424014f878360a4f769247cda1856abb7ffde7e3d66e829e8ae953e1c5b0ba21aea6521abab19b10ac821631e490d9ca47cba7876ea41b5ef8d1
|
|
@@ -594,19 +594,20 @@ export async function fetchMcpManifest(manifestUrl) {
|
|
|
594
594
|
// `result` value (or throws on JSON-RPC error / HTTP failure). Supports
|
|
595
595
|
// both JSON and SSE responses (MCP over HTTP allows either).
|
|
596
596
|
let _mcpReqId = 0
|
|
597
|
-
|
|
597
|
+
|
|
598
|
+
// One JSON-RPC round trip to an MCP endpoint. Extracted from callMcpTool so
|
|
599
|
+
// prompts/* and resources/* reuse the same transport — MCP over HTTP may
|
|
600
|
+
// answer with either JSON or SSE, and duplicating that handling per method
|
|
601
|
+
// is how the two drift apart.
|
|
602
|
+
export async function mcpRpc({ endpoint, method, params, signal, label }) {
|
|
603
|
+
const what = label || method
|
|
598
604
|
const response = await fetch(endpoint, {
|
|
599
605
|
method: "POST",
|
|
600
606
|
headers: {
|
|
601
607
|
"Content-Type": "application/json",
|
|
602
608
|
"Accept": "application/json, text/event-stream"
|
|
603
609
|
},
|
|
604
|
-
body: JSON.stringify({
|
|
605
|
-
jsonrpc: "2.0",
|
|
606
|
-
id: ++_mcpReqId,
|
|
607
|
-
method: "tools/call",
|
|
608
|
-
params: { name, arguments: args || {} }
|
|
609
|
-
}),
|
|
610
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: ++_mcpReqId, method, params: params || {} }),
|
|
610
611
|
signal,
|
|
611
612
|
// Send session cookies for same-origin MCP endpoints. Cross-origin CORS
|
|
612
613
|
// with credentials requires the server to echo Access-Control-Allow-
|
|
@@ -617,16 +618,14 @@ export async function callMcpTool({ endpoint, name, args, signal }) {
|
|
|
617
618
|
})
|
|
618
619
|
if (!response.ok) {
|
|
619
620
|
const text = await response.text().catch(() => "")
|
|
620
|
-
throw new Error(
|
|
621
|
+
throw new Error(`${what}: HTTP ${response.status}${text ? " — " + text.slice(0, 200) : ""}`)
|
|
621
622
|
}
|
|
622
623
|
|
|
623
624
|
const contentType = response.headers.get("content-type") || ""
|
|
624
625
|
|
|
625
626
|
if (contentType.includes("application/json")) {
|
|
626
627
|
const body = await response.json()
|
|
627
|
-
if (body?.error) {
|
|
628
|
-
throw new Error(`callMcpTool(${name}): ${body.error.message || "JSON-RPC error"}`)
|
|
629
|
-
}
|
|
628
|
+
if (body?.error) throw new Error(`${what}: ${body.error.message || "JSON-RPC error"}`)
|
|
630
629
|
return body?.result
|
|
631
630
|
}
|
|
632
631
|
|
|
@@ -635,15 +634,77 @@ export async function callMcpTool({ endpoint, name, args, signal }) {
|
|
|
635
634
|
for await (const evt of parseSseStream(response.body, signal)) {
|
|
636
635
|
const payload = evt.data
|
|
637
636
|
if (!payload || typeof payload !== "object") continue
|
|
638
|
-
if (payload.error) {
|
|
639
|
-
throw new Error(`callMcpTool(${name}): ${payload.error.message || "JSON-RPC error"}`)
|
|
640
|
-
}
|
|
637
|
+
if (payload.error) throw new Error(`${what}: ${payload.error.message || "JSON-RPC error"}`)
|
|
641
638
|
if ("result" in payload) return payload.result
|
|
642
639
|
}
|
|
643
|
-
throw new Error(
|
|
640
|
+
throw new Error(`${what}: SSE stream ended without result`)
|
|
644
641
|
}
|
|
645
642
|
|
|
646
|
-
throw new Error(
|
|
643
|
+
throw new Error(`${what}: unexpected content-type ${contentType}`)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export async function callMcpTool({ endpoint, name, args, signal }) {
|
|
647
|
+
return mcpRpc({
|
|
648
|
+
endpoint, signal, method: "tools/call",
|
|
649
|
+
params: { name, arguments: args || {} },
|
|
650
|
+
label: `callMcpTool(${name})`
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
// Prompt and resource primitives
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
//
|
|
658
|
+
// Optional in MCP, so every one of these returns empty rather than throwing
|
|
659
|
+
// when a server doesn't implement them: the widget must stay usable against a
|
|
660
|
+
// tools-only server. A server that answers `prompts/list` with -32601 is
|
|
661
|
+
// telling us "not supported"; one that errors some other way is broken, and
|
|
662
|
+
// either way the widget carries on with whatever it did get.
|
|
663
|
+
|
|
664
|
+
export async function listMcpPrompts({ endpoint, signal }) {
|
|
665
|
+
try {
|
|
666
|
+
const result = await mcpRpc({ endpoint, method: "prompts/list", signal })
|
|
667
|
+
return (result?.prompts || []).map((p) => ({ ...p, endpoint }))
|
|
668
|
+
} catch { return [] }
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export async function getMcpPrompt({ endpoint, name, args, signal }) {
|
|
672
|
+
return mcpRpc({
|
|
673
|
+
endpoint, signal, method: "prompts/get",
|
|
674
|
+
params: { name, arguments: args || {} },
|
|
675
|
+
label: `getMcpPrompt(${name})`
|
|
676
|
+
})
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export async function listMcpResources({ endpoint, signal }) {
|
|
680
|
+
try {
|
|
681
|
+
const result = await mcpRpc({ endpoint, method: "resources/list", signal })
|
|
682
|
+
return (result?.resources || []).map((r) => ({ ...r, endpoint }))
|
|
683
|
+
} catch { return [] }
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export async function readMcpResource({ endpoint, uri, signal }) {
|
|
687
|
+
return mcpRpc({
|
|
688
|
+
endpoint, signal, method: "resources/read",
|
|
689
|
+
params: { uri }, label: `readMcpResource(${uri})`
|
|
690
|
+
})
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Flatten a prompts/get result into the plain string the hub's messages take.
|
|
694
|
+
// The spec allows `content` to be an object or an array of content blocks;
|
|
695
|
+
// anything non-text is dropped, which is worth knowing — a prompt carrying an
|
|
696
|
+
// image would silently lose it on this path.
|
|
697
|
+
export function promptMessagesToText(result) {
|
|
698
|
+
return (result?.messages || [])
|
|
699
|
+
.map((m) => {
|
|
700
|
+
const c = m.content
|
|
701
|
+
if (typeof c === "string") return c
|
|
702
|
+
if (Array.isArray(c)) return c.filter((b) => b?.type === "text").map((b) => b.text).join("\n")
|
|
703
|
+
if (c && c.type === "text") return c.text
|
|
704
|
+
return ""
|
|
705
|
+
})
|
|
706
|
+
.filter(Boolean)
|
|
707
|
+
.join("\n\n")
|
|
647
708
|
}
|
|
648
709
|
|
|
649
710
|
// Standalone remote dispatcher — POSTs one tool_call to the meta-server's
|
|
@@ -715,3 +776,170 @@ function parseSseFrame(raw) {
|
|
|
715
776
|
}
|
|
716
777
|
return { name, data }
|
|
717
778
|
}
|
|
779
|
+
|
|
780
|
+
// ---- static-primitives extension ---------------------------------------
|
|
781
|
+
//
|
|
782
|
+
// Prototype of `io.modelcontextprotocol/static-primitives` (the SEP-2127
|
|
783
|
+
// follow-on): two optional fields a server may declare on a resources/list
|
|
784
|
+
// entry. `sizeBytes` is the byte length of the payload resources/read would
|
|
785
|
+
// return, so a client can decide whether to attach it BEFORE fetching it.
|
|
786
|
+
// `attachmentHint` says how often attaching is worth it.
|
|
787
|
+
//
|
|
788
|
+
// Both are optional, and a server that declares neither must behave exactly
|
|
789
|
+
// as it did before the extension existed: fetch, trim to budget, attach once.
|
|
790
|
+
export const STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives"
|
|
791
|
+
|
|
792
|
+
const ATTACHMENT_HINTS = [ "once", "each-turn", "on-demand" ]
|
|
793
|
+
|
|
794
|
+
export function resourceHints(entry) {
|
|
795
|
+
const meta = (entry && entry._meta && entry._meta[STATIC_PRIMITIVES_META]) || {}
|
|
796
|
+
const hint = meta.attachmentHint
|
|
797
|
+
return {
|
|
798
|
+
// A non-numeric or absent size means "unknown", never 0 — 0 would read
|
|
799
|
+
// as a free resource and sail through every budget check.
|
|
800
|
+
sizeBytes: typeof meta.sizeBytes === "number" && isFinite(meta.sizeBytes) ? meta.sizeBytes : null,
|
|
801
|
+
attachmentHint: ATTACHMENT_HINTS.indexOf(hint) === -1 ? "once" : hint
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// The pre-flight decision, made from the resources/list entry alone.
|
|
806
|
+
// `fetch: false` means the bytes never cross the wire at all.
|
|
807
|
+
export function planResourceAttachment(entry, budgetBytes) {
|
|
808
|
+
const { sizeBytes, attachmentHint } = resourceHints(entry)
|
|
809
|
+
const base = { sizeBytes, attachmentHint, uri: entry && entry.uri }
|
|
810
|
+
|
|
811
|
+
if (attachmentHint === "on-demand") {
|
|
812
|
+
return { ...base, fetch: false, autoAttach: false, reason: "on-demand" }
|
|
813
|
+
}
|
|
814
|
+
if (sizeBytes !== null && sizeBytes > budgetBytes) {
|
|
815
|
+
return { ...base, fetch: false, autoAttach: false, reason: "over-budget" }
|
|
816
|
+
}
|
|
817
|
+
return {
|
|
818
|
+
...base,
|
|
819
|
+
fetch: true,
|
|
820
|
+
autoAttach: true,
|
|
821
|
+
// Unknown size still gets fetched, then trimmed — the trim is the
|
|
822
|
+
// safety net for servers that do not declare a size.
|
|
823
|
+
reason: sizeBytes === null ? "size-unknown" : "within-budget"
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Per-turn gate. Replaces a single `sent` boolean, which silently assumed
|
|
828
|
+
// every resource was "once" and would have re-sent nothing for a resource
|
|
829
|
+
// whose content actually varies between turns.
|
|
830
|
+
export function createResourceAttacher(plan) {
|
|
831
|
+
let attachedOnce = false
|
|
832
|
+
return {
|
|
833
|
+
// Returns whether to attach on THIS turn, and records the answer.
|
|
834
|
+
take() {
|
|
835
|
+
if (!plan || !plan.autoAttach) return false
|
|
836
|
+
if (plan.attachmentHint === "each-turn") return true
|
|
837
|
+
if (attachedOnce) return false
|
|
838
|
+
attachedOnce = true
|
|
839
|
+
return true
|
|
840
|
+
},
|
|
841
|
+
get attachmentHint() { return plan ? plan.attachmentHint : null }
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Safety net for a server that declares no size: shrink an oversized
|
|
846
|
+
// catalog to its names rather than dropping it, and hard-truncate anything
|
|
847
|
+
// that is not a recognised catalog shape.
|
|
848
|
+
export function trimResourceText(text, maxBytes) {
|
|
849
|
+
if (text.length <= maxBytes) return text
|
|
850
|
+
try {
|
|
851
|
+
const parsed = JSON.parse(text)
|
|
852
|
+
const rows = parsed && parsed.dictionaries
|
|
853
|
+
if (Array.isArray(rows)) {
|
|
854
|
+
const names = rows.map((d) => d && d.name).filter(Boolean)
|
|
855
|
+
return JSON.stringify({ dictionaries: names, note: "names only — full catalog too large to inline" })
|
|
856
|
+
}
|
|
857
|
+
} catch (e) { /* fall through to a hard truncation */ }
|
|
858
|
+
return text.slice(0, maxBytes) + "\n…truncated"
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// The whole discovery sequence for one endpoint's reference resource, kept
|
|
862
|
+
// here rather than in the panel so the decision AND the call site are
|
|
863
|
+
// testable together: a gate that nothing consults is the failure mode this
|
|
864
|
+
// replaces. `list` and `read` are injected so a test can assert that an
|
|
865
|
+
// over-budget resource is never read.
|
|
866
|
+
export async function loadHostResource({ endpoint, budgetBytes, list, read, onSkip }) {
|
|
867
|
+
const resources = await list({ endpoint })
|
|
868
|
+
const candidate = (resources || []).filter((r) => (r.mimeType || "") === "application/json")[0]
|
|
869
|
+
if (!candidate) return null
|
|
870
|
+
|
|
871
|
+
const plan = planResourceAttachment(candidate, budgetBytes)
|
|
872
|
+
if (!plan.fetch) {
|
|
873
|
+
if (onSkip) onSkip(plan)
|
|
874
|
+
return { plan, context: null }
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
try {
|
|
878
|
+
const result = await read({ endpoint, uri: candidate.uri })
|
|
879
|
+
const entry = ((result && result.contents) || [])[0]
|
|
880
|
+
if (!entry || !entry.text) return { plan, context: null }
|
|
881
|
+
return {
|
|
882
|
+
plan,
|
|
883
|
+
context: {
|
|
884
|
+
uri: candidate.uri,
|
|
885
|
+
name: candidate.name || candidate.uri,
|
|
886
|
+
text: trimResourceText(entry.text, budgetBytes)
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
} catch (e) {
|
|
890
|
+
// Optional context — a failed read must never block the widget.
|
|
891
|
+
return { plan, context: null }
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ---- prompt templates ---------------------------------------------------
|
|
896
|
+
//
|
|
897
|
+
// A server-declared prompt is only useful if the HOST PAGE can fill its
|
|
898
|
+
// arguments, so an argument is matched against the page's own state readers
|
|
899
|
+
// (window.aiState) by name. `dictionaries` is read from a
|
|
900
|
+
// `selected_dictionaries` reader: the page selects a LIST, and the prompt
|
|
901
|
+
// declares the CSV shape the text_annotation tool already takes.
|
|
902
|
+
const PROMPT_ARG_ALIASES = { dictionaries: "selected_dictionaries" }
|
|
903
|
+
|
|
904
|
+
export function promptArgFromState(argName, state) {
|
|
905
|
+
const reader = (state && state[argName]) || (state && state[PROMPT_ARG_ALIASES[argName]])
|
|
906
|
+
if (typeof reader !== "function") return ""
|
|
907
|
+
let value
|
|
908
|
+
try {
|
|
909
|
+
value = reader()
|
|
910
|
+
} catch (e) {
|
|
911
|
+
// A throwing page reader is the page's bug, not a reason to break the
|
|
912
|
+
// widget; treat it as "nothing to fill with".
|
|
913
|
+
return ""
|
|
914
|
+
}
|
|
915
|
+
if (value === null || value === undefined) return ""
|
|
916
|
+
return Array.isArray(value) ? value.join(",") : String(value)
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Splits a prompt's declared arguments into what the page can supply and
|
|
920
|
+
// what it cannot. Naming the missing ones lets the widget point at the empty
|
|
921
|
+
// field on screen instead of relaying the server's -32602.
|
|
922
|
+
export function resolvePromptArguments(prompt, state) {
|
|
923
|
+
const args = {}
|
|
924
|
+
const missing = []
|
|
925
|
+
for (const arg of (prompt && prompt.arguments) || []) {
|
|
926
|
+
const value = promptArgFromState(arg.name, state)
|
|
927
|
+
if (value) args[arg.name] = value
|
|
928
|
+
else if (arg.required) missing.push(arg.name)
|
|
929
|
+
}
|
|
930
|
+
return { args, missing }
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
export function promptButtonProps(prompt) {
|
|
934
|
+
return {
|
|
935
|
+
label: prompt.title || prompt.name,
|
|
936
|
+
title: prompt.description || ("Run the " + prompt.name + " prompt")
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// The per-turn attach decision AND its formatting, so the two cannot drift
|
|
941
|
+
// apart: asking whether to attach is what consumes the turn.
|
|
942
|
+
export function nextResourceContextLines(context, attacher) {
|
|
943
|
+
if (!context || !attacher || !attacher.take()) return []
|
|
944
|
+
return [ "", "Reference data — " + context.name + " (" + context.uri + "):", context.text ]
|
|
945
|
+
}
|
|
@@ -268,6 +268,31 @@
|
|
|
268
268
|
border-radius: 8px;
|
|
269
269
|
padding: 8px;
|
|
270
270
|
}
|
|
271
|
+
/* Server-offered prompt templates. The row is display:none in markup and
|
|
272
|
+
* gets its display back only when a prompt actually exists, so the inline
|
|
273
|
+
* style and this rule have to agree on "flex". */
|
|
274
|
+
#llm-meta-widget-chat .lmw-prompts {
|
|
275
|
+
display: flex;
|
|
276
|
+
flex-wrap: wrap;
|
|
277
|
+
gap: 6px;
|
|
278
|
+
margin-bottom: 8px;
|
|
279
|
+
}
|
|
280
|
+
#llm-meta-widget-chat .lmw-prompt {
|
|
281
|
+
background-color: #eff6ff;
|
|
282
|
+
color: #1d4ed8;
|
|
283
|
+
border: 1px solid #bfdbfe;
|
|
284
|
+
border-radius: 999px;
|
|
285
|
+
padding: 4px 12px;
|
|
286
|
+
font-size: 12px;
|
|
287
|
+
font-family: inherit;
|
|
288
|
+
cursor: pointer;
|
|
289
|
+
transition: background-color 0.2s, border-color 0.2s;
|
|
290
|
+
}
|
|
291
|
+
#llm-meta-widget-chat .lmw-prompt:hover:not(:disabled) {
|
|
292
|
+
background-color: #dbeafe;
|
|
293
|
+
border-color: #93c5fd;
|
|
294
|
+
}
|
|
295
|
+
#llm-meta-widget-chat .lmw-prompt:disabled { opacity: 0.5; cursor: default; }
|
|
271
296
|
#llm-meta-widget-chat .lmw-form { display: flex; flex-direction: column; gap: 8px; }
|
|
272
297
|
#llm-meta-widget-chat .lmw-input-wrapper { position: relative; flex: 1; }
|
|
273
298
|
#llm-meta-widget-chat .lmw-input {
|
|
@@ -326,6 +351,10 @@
|
|
|
326
351
|
</div>
|
|
327
352
|
<div class="lmw-messages"></div>
|
|
328
353
|
<div class="lmw-input-container">
|
|
354
|
+
<%# Server-offered prompt templates (MCP prompts/list). Hidden until a
|
|
355
|
+
host MCP server actually offers one, so a tools-only server leaves
|
|
356
|
+
the widget exactly as it was. %>
|
|
357
|
+
<div class="lmw-prompts" style="display:none"></div>
|
|
329
358
|
<form class="lmw-form">
|
|
330
359
|
<div class="lmw-input-wrapper">
|
|
331
360
|
<textarea class="lmw-input" placeholder="Enter your message..." rows="2" autocomplete="off"></textarea>
|
|
@@ -367,7 +396,10 @@
|
|
|
367
396
|
</div>
|
|
368
397
|
|
|
369
398
|
<script type="module">
|
|
370
|
-
import { runChatLoop, fetchMcpManifest
|
|
399
|
+
import { runChatLoop, fetchMcpManifest, listMcpPrompts, getMcpPrompt,
|
|
400
|
+
listMcpResources, readMcpResource, promptMessagesToText,
|
|
401
|
+
loadHostResource, createResourceAttacher, resolvePromptArguments,
|
|
402
|
+
promptButtonProps, nextResourceContextLines } from "<%= orchestrator_path %>";
|
|
371
403
|
import { marked } from "/llm_meta_widget_assets/marked.esm.js";
|
|
372
404
|
|
|
373
405
|
// Standard prose settings — GFM (tables, autolinks, strikethrough),
|
|
@@ -721,6 +753,20 @@
|
|
|
721
753
|
// Fetch well-known MCP manifests at boot. Auto-discovers same origin
|
|
722
754
|
// if WELL_KNOWN_URLS is null; empty array disables entirely.
|
|
723
755
|
var hostWideTools = [];
|
|
756
|
+
var hostWidePrompts = [];
|
|
757
|
+
var resourceContext = null; // payload of the host's reference resource
|
|
758
|
+
var resourceAttacher = null; // per-turn gate, built from the server's hint
|
|
759
|
+
var resourcePlan = null; // the pre-flight decision, kept so Clear can re-arm the gate
|
|
760
|
+
|
|
761
|
+
// Beyond ~2k tokens a reference resource starts crowding out the
|
|
762
|
+
// conversation on a 32k-context local model — production PubDictionaries
|
|
763
|
+
// is 218 dictionaries / ~35KB / ~10k tokens.
|
|
764
|
+
var RESOURCE_BUDGET_BYTES = 8000;
|
|
765
|
+
|
|
766
|
+
// Set when a server declared a resource we deliberately did not attach,
|
|
767
|
+
// so the reason is inspectable rather than a silent absence.
|
|
768
|
+
var resourceSkip = null;
|
|
769
|
+
|
|
724
770
|
var wellKnownReady = (async function() {
|
|
725
771
|
var urls = WELL_KNOWN_URLS === null
|
|
726
772
|
? [ window.location.origin + "/.well-known/mcp.json" ]
|
|
@@ -729,8 +775,100 @@
|
|
|
729
775
|
var tools = await fetchMcpManifest(urls[i]);
|
|
730
776
|
hostWideTools = hostWideTools.concat(tools);
|
|
731
777
|
}
|
|
778
|
+
|
|
779
|
+
// The manifest publishes TOOLS only, so the endpoints are learned from
|
|
780
|
+
// them. A server offering only prompts or resources is invisible here —
|
|
781
|
+
// worth knowing when deciding what a static manifest should carry.
|
|
782
|
+
var endpoints = [];
|
|
783
|
+
hostWideTools.forEach(function(t) {
|
|
784
|
+
if (t.endpoint && endpoints.indexOf(t.endpoint) === -1) endpoints.push(t.endpoint);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
for (var j = 0; j < endpoints.length; j++) {
|
|
788
|
+
var endpoint = endpoints[j];
|
|
789
|
+
var prompts = await listMcpPrompts({ endpoint: endpoint });
|
|
790
|
+
hostWidePrompts = hostWidePrompts.concat(prompts);
|
|
791
|
+
|
|
792
|
+
if (resourceContext === null) {
|
|
793
|
+
// Listing, size gate and read all live in the orchestrator, where
|
|
794
|
+
// they are tested together — a gate nothing consults is exactly
|
|
795
|
+
// the bug this shape prevents.
|
|
796
|
+
var loaded = await loadHostResource({
|
|
797
|
+
endpoint: endpoint,
|
|
798
|
+
budgetBytes: RESOURCE_BUDGET_BYTES,
|
|
799
|
+
list: listMcpResources,
|
|
800
|
+
read: readMcpResource,
|
|
801
|
+
onSkip: function(plan) {
|
|
802
|
+
resourceSkip = plan;
|
|
803
|
+
console.info("[llm_meta_widget] not attaching " + plan.uri +
|
|
804
|
+
" (" + plan.reason + ", sizeBytes=" + plan.sizeBytes + ")");
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
if (loaded && loaded.context) {
|
|
808
|
+
resourceContext = loaded.context;
|
|
809
|
+
resourcePlan = loaded.plan;
|
|
810
|
+
resourceAttacher = createResourceAttacher(loaded.plan);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
renderPromptButtons();
|
|
732
815
|
})();
|
|
733
816
|
|
|
817
|
+
// ---- server-offered prompt templates --------------------------------
|
|
818
|
+
//
|
|
819
|
+
// One button per prompt the host's MCP server offers. Clicking it fills
|
|
820
|
+
// the prompt's arguments from page state, asks the server to materialise
|
|
821
|
+
// the message (prompts/get), then drops the text into the textarea and
|
|
822
|
+
// submits — so it travels the exact path a typed message does.
|
|
823
|
+
|
|
824
|
+
var promptsEl = root.querySelector(".lmw-prompts");
|
|
825
|
+
|
|
826
|
+
function renderPromptButtons() {
|
|
827
|
+
if (!promptsEl) return;
|
|
828
|
+
promptsEl.textContent = "";
|
|
829
|
+
if (!hostWidePrompts.length) { promptsEl.style.display = "none"; return; }
|
|
830
|
+
|
|
831
|
+
hostWidePrompts.forEach(function(prompt) {
|
|
832
|
+
var props = promptButtonProps(prompt);
|
|
833
|
+
var button = document.createElement("button");
|
|
834
|
+
button.type = "button";
|
|
835
|
+
button.className = "lmw-prompt";
|
|
836
|
+
button.textContent = props.label;
|
|
837
|
+
button.title = props.title;
|
|
838
|
+
button.addEventListener("click", function() { runPromptTemplate(prompt, button); });
|
|
839
|
+
promptsEl.appendChild(button);
|
|
840
|
+
});
|
|
841
|
+
promptsEl.style.display = "";
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function runPromptTemplate(prompt, button) {
|
|
845
|
+
var resolved = resolvePromptArguments(prompt, window[STATE_GLOBAL] || {});
|
|
846
|
+
var args = resolved.args;
|
|
847
|
+
var missing = resolved.missing;
|
|
848
|
+
|
|
849
|
+
// Say which page field is empty rather than letting the server answer
|
|
850
|
+
// -32602 for something the user can actually fix on screen.
|
|
851
|
+
if (missing.length) {
|
|
852
|
+
appendTurn("error", "Cannot run \"" + (prompt.title || prompt.name) + "\" yet — nothing to use for: " +
|
|
853
|
+
missing.join(", ") + ". Fill that in on the page first.");
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
button.disabled = true;
|
|
858
|
+
try {
|
|
859
|
+
var result = await getMcpPrompt({ endpoint: prompt.endpoint, name: prompt.name, args: args });
|
|
860
|
+
var text = promptMessagesToText(result);
|
|
861
|
+
if (!text) { appendTurn("error", "The server returned an empty prompt."); return; }
|
|
862
|
+
inputEl.value = text;
|
|
863
|
+
if (typeof formEl.requestSubmit === "function") formEl.requestSubmit();
|
|
864
|
+
else formEl.dispatchEvent(new Event("submit", { cancelable: true }));
|
|
865
|
+
} catch (e) {
|
|
866
|
+
appendTurn("error", "Prompt failed: " + (e && e.message ? e.message : e));
|
|
867
|
+
} finally {
|
|
868
|
+
button.disabled = false;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
734
872
|
function appendTurn(role, text) {
|
|
735
873
|
// Class names mirror llm_meta_chat's chats/_message.html.erb —
|
|
736
874
|
// `.message.<role>`, `.message-role`, `.message-content` — so the
|
|
@@ -834,7 +972,16 @@
|
|
|
834
972
|
"",
|
|
835
973
|
"Current page state:",
|
|
836
974
|
JSON.stringify(currentPageState(), null, 2)
|
|
837
|
-
].join("\n");
|
|
975
|
+
].concat(resourceContextLines()).join("\n");
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// How often the resource is attached is the SERVER's call, via the
|
|
979
|
+
// extension's attachmentHint: 'once' for static reference data (the
|
|
980
|
+
// default, and what a hint-unaware server gets), 'each-turn' for content
|
|
981
|
+
// that varies. The attacher records each turn's answer, replacing a
|
|
982
|
+
// client-side boolean that assumed every resource was static.
|
|
983
|
+
function resourceContextLines() {
|
|
984
|
+
return nextResourceContextLines(resourceContext, resourceAttacher);
|
|
838
985
|
}
|
|
839
986
|
|
|
840
987
|
// Per-turn AbortController — lets the Clear button (or a new submit)
|
|
@@ -845,6 +992,9 @@
|
|
|
845
992
|
if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
|
|
846
993
|
conversation = [];
|
|
847
994
|
historyEl.innerHTML = "";
|
|
995
|
+
// Clear starts a new conversation, so a 'once' resource is owed to it
|
|
996
|
+
// again — the old boolean stayed latched and silently withheld it.
|
|
997
|
+
if (resourcePlan) resourceAttacher = createResourceAttacher(resourcePlan);
|
|
848
998
|
});
|
|
849
999
|
|
|
850
1000
|
// Enter submits, Shift+Enter inserts a newline — matches llm_meta_chat's
|
|
@@ -868,16 +1018,20 @@
|
|
|
868
1018
|
if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
|
|
869
1019
|
currentAbort = new AbortController();
|
|
870
1020
|
|
|
871
|
-
var messages = [{ role: "system", content: currentSystemPrompt() }]
|
|
872
|
-
.concat(conversation)
|
|
873
|
-
.concat([{ role: "user", content: userText }]);
|
|
874
|
-
|
|
875
1021
|
var assistantBody = appendTurn("assistant", "");
|
|
876
1022
|
var assistantMarkdown = ""; // accumulate raw markdown, re-render on each delta
|
|
877
1023
|
|
|
878
1024
|
try {
|
|
1025
|
+
// Discovery has to finish before the system prompt is built: the
|
|
1026
|
+
// resource catalog is attached to the first turn only, so building
|
|
1027
|
+
// messages first would silently ship that turn without it AND mark
|
|
1028
|
+
// it as sent.
|
|
879
1029
|
await wellKnownReady;
|
|
880
1030
|
|
|
1031
|
+
var messages = [{ role: "system", content: currentSystemPrompt() }]
|
|
1032
|
+
.concat(conversation)
|
|
1033
|
+
.concat([{ role: "user", content: userText }]);
|
|
1034
|
+
|
|
881
1035
|
var result = await runChatLoop({
|
|
882
1036
|
baseUrl: META_BASE,
|
|
883
1037
|
apiKeyUuid: API_KEY_UUID,
|