llm_meta_widget 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d7a58b9b553be09b66ca6e9aa01f8264a280bb725f80820ec3de506573701ae
|
|
4
|
+
data.tar.gz: 7b171f0c86c5f45a6250975ab02da586d620602d8fc1bea95261dd963d17e67a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e90d55856a37217816264730d1a65fd32af2ad4783ad0c1a09ed298fb5dfe35a618a1ca149bf77265aab346dba4437b960a98399e91b3b15695aa601ed9d2025
|
|
7
|
+
data.tar.gz: 042b6872987730e708758fee7c6043ac27b0a83b27935e33f0751a910307cd901e746f28a2557dc54dfc32f63d5a4039f43cefcbc1952d93613cc59568e4c44a
|
|
@@ -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,205 @@ 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). Three optional fields a server may declare on a resources/list
|
|
784
|
+
// entry:
|
|
785
|
+
//
|
|
786
|
+
// sizeBytes - byte length of the payload resources/read would return, so a
|
|
787
|
+
// client can decide whether to attach it BEFORE fetching it.
|
|
788
|
+
// volatility - "stable" (content fixed) or "volatile" (varies between
|
|
789
|
+
// turns, so a client re-reads it each turn).
|
|
790
|
+
// autoAttach - may a client attach this without the user asking for it?
|
|
791
|
+
//
|
|
792
|
+
// volatility and autoAttach are separate on purpose. Whether content changes
|
|
793
|
+
// says nothing about whether it may be attached unasked, and the client needs
|
|
794
|
+
// autoAttach as a boolean anyway, because its own byte budget can withhold a
|
|
795
|
+
// resource the server was happy to hand over.
|
|
796
|
+
//
|
|
797
|
+
// All three are optional, and a server declaring none must behave exactly as
|
|
798
|
+
// it did before the extension existed: fetch once, trim to budget, attach on
|
|
799
|
+
// the first turn only.
|
|
800
|
+
export const STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives"
|
|
801
|
+
|
|
802
|
+
const VOLATILITIES = [ "stable", "volatile" ]
|
|
803
|
+
|
|
804
|
+
export function resourceHints(entry) {
|
|
805
|
+
const meta = (entry && entry._meta && entry._meta[STATIC_PRIMITIVES_META]) || {}
|
|
806
|
+
return {
|
|
807
|
+
// A non-numeric or absent size means "unknown", never 0 — 0 would read
|
|
808
|
+
// as a free resource and sail through every budget check.
|
|
809
|
+
sizeBytes: typeof meta.sizeBytes === "number" && isFinite(meta.sizeBytes) ? meta.sizeBytes : null,
|
|
810
|
+
volatility: VOLATILITIES.indexOf(meta.volatility) === -1 ? "stable" : meta.volatility,
|
|
811
|
+
autoAttach: typeof meta.autoAttach === "boolean" ? meta.autoAttach : true
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// The pre-flight decision, made from the resources/list entry alone.
|
|
816
|
+
// `fetch: false` means the bytes never cross the wire at all.
|
|
817
|
+
export function planResourceAttachment(entry, budgetBytes) {
|
|
818
|
+
const { sizeBytes, volatility, autoAttach } = resourceHints(entry)
|
|
819
|
+
const base = { sizeBytes, volatility, uri: entry && entry.uri }
|
|
820
|
+
|
|
821
|
+
if (!autoAttach) {
|
|
822
|
+
return { ...base, fetch: false, autoAttach: false, reason: "not-auto-attach" }
|
|
823
|
+
}
|
|
824
|
+
if (sizeBytes !== null && sizeBytes > budgetBytes) {
|
|
825
|
+
// The client's budget overrides the server's willingness — which is why
|
|
826
|
+
// autoAttach has to be a boolean here rather than a restatement of a hint.
|
|
827
|
+
return { ...base, fetch: false, autoAttach: false, reason: "over-budget" }
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
...base,
|
|
831
|
+
fetch: true,
|
|
832
|
+
autoAttach: true,
|
|
833
|
+
// Unknown size still gets fetched, then trimmed — the trim is the
|
|
834
|
+
// safety net for servers that do not declare a size.
|
|
835
|
+
reason: sizeBytes === null ? "size-unknown" : "within-budget"
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Per-turn gate. A stable resource is attached once and re-used; a volatile
|
|
840
|
+
// one is owed a fresh copy every turn.
|
|
841
|
+
export function createResourceAttacher(plan) {
|
|
842
|
+
let attachedOnce = false
|
|
843
|
+
return {
|
|
844
|
+
take() {
|
|
845
|
+
if (!plan || !plan.autoAttach) return false
|
|
846
|
+
if (plan.volatility === "volatile") return true
|
|
847
|
+
if (attachedOnce) return false
|
|
848
|
+
attachedOnce = true
|
|
849
|
+
return true
|
|
850
|
+
},
|
|
851
|
+
get volatility() { return plan ? plan.volatility : null }
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Safety net for a server that declares no size: shrink an oversized
|
|
856
|
+
// catalog to its names rather than dropping it, and hard-truncate anything
|
|
857
|
+
// that is not a recognised catalog shape.
|
|
858
|
+
export function trimResourceText(text, maxBytes) {
|
|
859
|
+
if (text.length <= maxBytes) return text
|
|
860
|
+
try {
|
|
861
|
+
const parsed = JSON.parse(text)
|
|
862
|
+
const rows = parsed && parsed.dictionaries
|
|
863
|
+
if (Array.isArray(rows)) {
|
|
864
|
+
const names = rows.map((d) => d && d.name).filter(Boolean)
|
|
865
|
+
return JSON.stringify({ dictionaries: names, note: "names only — full catalog too large to inline" })
|
|
866
|
+
}
|
|
867
|
+
} catch (e) { /* fall through to a hard truncation */ }
|
|
868
|
+
return text.slice(0, maxBytes) + "\n…truncated"
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Read one resource and shape it for the system prompt. The trim is applied
|
|
872
|
+
// on every read, not just the first: a declared size can go stale, and a
|
|
873
|
+
// volatile resource is a fresh gamble each turn.
|
|
874
|
+
async function readResourceContext({ endpoint, uri, name, read, budgetBytes }) {
|
|
875
|
+
try {
|
|
876
|
+
const result = await read({ endpoint, uri })
|
|
877
|
+
const entry = ((result && result.contents) || [])[0]
|
|
878
|
+
if (!entry || !entry.text) return null
|
|
879
|
+
return { uri, name: name || uri, text: trimResourceText(entry.text, budgetBytes) }
|
|
880
|
+
} catch (e) {
|
|
881
|
+
// Optional context — a failed read must never block the widget.
|
|
882
|
+
return null
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// The discovery sequence for one endpoint's reference resource, kept here
|
|
887
|
+
// rather than in the panel so the decision AND the call site are testable
|
|
888
|
+
// together: a gate nothing consults is exactly the bug this shape prevents.
|
|
889
|
+
//
|
|
890
|
+
// A volatile resource is NOT read here. Its content is only meaningful for
|
|
891
|
+
// the turn it is attached to, so reading it at boot would buy a copy that is
|
|
892
|
+
// already suspect by the time anyone sends a message.
|
|
893
|
+
export async function loadHostResource({ endpoint, budgetBytes, list, read, onSkip }) {
|
|
894
|
+
const resources = await list({ endpoint })
|
|
895
|
+
const candidate = (resources || []).filter((r) => (r.mimeType || "") === "application/json")[0]
|
|
896
|
+
if (!candidate) return null
|
|
897
|
+
|
|
898
|
+
const plan = planResourceAttachment(candidate, budgetBytes)
|
|
899
|
+
plan.name = candidate.name || candidate.uri
|
|
900
|
+
// Carried so a volatile re-read knows where to go: discovery happens once
|
|
901
|
+
// at boot, but the fetch it authorises happens on every later turn.
|
|
902
|
+
plan.endpoint = endpoint
|
|
903
|
+
if (!plan.fetch) {
|
|
904
|
+
if (onSkip) onSkip(plan)
|
|
905
|
+
return { plan, context: null }
|
|
906
|
+
}
|
|
907
|
+
if (plan.volatility === "volatile") return { plan, context: null }
|
|
908
|
+
|
|
909
|
+
const context = await readResourceContext({
|
|
910
|
+
endpoint, uri: candidate.uri, name: plan.name, read, budgetBytes
|
|
911
|
+
})
|
|
912
|
+
return { plan, context }
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
export function resourceContextLines(context) {
|
|
916
|
+
if (!context) return []
|
|
917
|
+
return [ "", "Reference data — " + context.name + " (" + context.uri + "):", context.text ]
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// What to attach on THIS turn. Asking consumes the turn, so the decision and
|
|
921
|
+
// the fetch live together: a stable resource re-uses the copy read at boot,
|
|
922
|
+
// a volatile one is read again right now.
|
|
923
|
+
export async function resourceLinesForTurn({ plan, attacher, cached, endpoint, read, budgetBytes }) {
|
|
924
|
+
if (!attacher || !attacher.take()) return { lines: [], cached }
|
|
925
|
+
|
|
926
|
+
if (plan && plan.volatility === "volatile") {
|
|
927
|
+
const fresh = await readResourceContext({
|
|
928
|
+
endpoint, uri: plan.uri, name: plan.name, read, budgetBytes
|
|
929
|
+
})
|
|
930
|
+
return fresh ? { lines: resourceContextLines(fresh), cached: fresh } : { lines: [], cached }
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
return { lines: resourceContextLines(cached), cached }
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ---- prompt templates ---------------------------------------------------
|
|
937
|
+
//
|
|
938
|
+
// A server-declared prompt is only useful if the HOST PAGE can fill its
|
|
939
|
+
// arguments, so an argument is matched against the page's own state readers
|
|
940
|
+
// (window.aiState) by name. `dictionaries` is read from a
|
|
941
|
+
// `selected_dictionaries` reader: the page selects a LIST, and the prompt
|
|
942
|
+
// declares the CSV shape the text_annotation tool already takes.
|
|
943
|
+
const PROMPT_ARG_ALIASES = { dictionaries: "selected_dictionaries" }
|
|
944
|
+
|
|
945
|
+
export function promptArgFromState(argName, state) {
|
|
946
|
+
const reader = (state && state[argName]) || (state && state[PROMPT_ARG_ALIASES[argName]])
|
|
947
|
+
if (typeof reader !== "function") return ""
|
|
948
|
+
let value
|
|
949
|
+
try {
|
|
950
|
+
value = reader()
|
|
951
|
+
} catch (e) {
|
|
952
|
+
// A throwing page reader is the page's bug, not a reason to break the
|
|
953
|
+
// widget; treat it as "nothing to fill with".
|
|
954
|
+
return ""
|
|
955
|
+
}
|
|
956
|
+
if (value === null || value === undefined) return ""
|
|
957
|
+
return Array.isArray(value) ? value.join(",") : String(value)
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Splits a prompt's declared arguments into what the page can supply and
|
|
961
|
+
// what it cannot. Naming the missing ones lets the widget point at the empty
|
|
962
|
+
// field on screen instead of relaying the server's -32602.
|
|
963
|
+
export function resolvePromptArguments(prompt, state) {
|
|
964
|
+
const args = {}
|
|
965
|
+
const missing = []
|
|
966
|
+
for (const arg of (prompt && prompt.arguments) || []) {
|
|
967
|
+
const value = promptArgFromState(arg.name, state)
|
|
968
|
+
if (value) args[arg.name] = value
|
|
969
|
+
else if (arg.required) missing.push(arg.name)
|
|
970
|
+
}
|
|
971
|
+
return { args, missing }
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
export function promptButtonProps(prompt) {
|
|
975
|
+
return {
|
|
976
|
+
label: prompt.title || prompt.name,
|
|
977
|
+
title: prompt.description || ("Run the " + prompt.name + " prompt")
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
@@ -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, resourceLinesForTurn, resolvePromptArguments,
|
|
402
|
+
promptButtonProps } 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,103 @@
|
|
|
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 (resourcePlan === 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
|
+
// A volatile resource comes back with no context — it is read
|
|
808
|
+
// per turn instead — so the plan, not the payload, is what says
|
|
809
|
+
// whether this endpoint offered anything worth attaching.
|
|
810
|
+
if (loaded && loaded.plan && loaded.plan.fetch) {
|
|
811
|
+
resourceContext = loaded.context;
|
|
812
|
+
resourcePlan = loaded.plan;
|
|
813
|
+
resourceAttacher = createResourceAttacher(loaded.plan);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
renderPromptButtons();
|
|
732
818
|
})();
|
|
733
819
|
|
|
820
|
+
// ---- server-offered prompt templates --------------------------------
|
|
821
|
+
//
|
|
822
|
+
// One button per prompt the host's MCP server offers. Clicking it fills
|
|
823
|
+
// the prompt's arguments from page state, asks the server to materialise
|
|
824
|
+
// the message (prompts/get), then drops the text into the textarea and
|
|
825
|
+
// submits — so it travels the exact path a typed message does.
|
|
826
|
+
|
|
827
|
+
var promptsEl = root.querySelector(".lmw-prompts");
|
|
828
|
+
|
|
829
|
+
function renderPromptButtons() {
|
|
830
|
+
if (!promptsEl) return;
|
|
831
|
+
promptsEl.textContent = "";
|
|
832
|
+
if (!hostWidePrompts.length) { promptsEl.style.display = "none"; return; }
|
|
833
|
+
|
|
834
|
+
hostWidePrompts.forEach(function(prompt) {
|
|
835
|
+
var props = promptButtonProps(prompt);
|
|
836
|
+
var button = document.createElement("button");
|
|
837
|
+
button.type = "button";
|
|
838
|
+
button.className = "lmw-prompt";
|
|
839
|
+
button.textContent = props.label;
|
|
840
|
+
button.title = props.title;
|
|
841
|
+
button.addEventListener("click", function() { runPromptTemplate(prompt, button); });
|
|
842
|
+
promptsEl.appendChild(button);
|
|
843
|
+
});
|
|
844
|
+
promptsEl.style.display = "";
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async function runPromptTemplate(prompt, button) {
|
|
848
|
+
var resolved = resolvePromptArguments(prompt, window[STATE_GLOBAL] || {});
|
|
849
|
+
var args = resolved.args;
|
|
850
|
+
var missing = resolved.missing;
|
|
851
|
+
|
|
852
|
+
// Say which page field is empty rather than letting the server answer
|
|
853
|
+
// -32602 for something the user can actually fix on screen.
|
|
854
|
+
if (missing.length) {
|
|
855
|
+
appendTurn("error", "Cannot run \"" + (prompt.title || prompt.name) + "\" yet — nothing to use for: " +
|
|
856
|
+
missing.join(", ") + ". Fill that in on the page first.");
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
button.disabled = true;
|
|
861
|
+
try {
|
|
862
|
+
var result = await getMcpPrompt({ endpoint: prompt.endpoint, name: prompt.name, args: args });
|
|
863
|
+
var text = promptMessagesToText(result);
|
|
864
|
+
if (!text) { appendTurn("error", "The server returned an empty prompt."); return; }
|
|
865
|
+
inputEl.value = text;
|
|
866
|
+
if (typeof formEl.requestSubmit === "function") formEl.requestSubmit();
|
|
867
|
+
else formEl.dispatchEvent(new Event("submit", { cancelable: true }));
|
|
868
|
+
} catch (e) {
|
|
869
|
+
appendTurn("error", "Prompt failed: " + (e && e.message ? e.message : e));
|
|
870
|
+
} finally {
|
|
871
|
+
button.disabled = false;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
734
875
|
function appendTurn(role, text) {
|
|
735
876
|
// Class names mirror llm_meta_chat's chats/_message.html.erb —
|
|
736
877
|
// `.message.<role>`, `.message-role`, `.message-content` — so the
|
|
@@ -822,7 +963,7 @@
|
|
|
822
963
|
return out;
|
|
823
964
|
}
|
|
824
965
|
|
|
825
|
-
function currentSystemPrompt() {
|
|
966
|
+
function currentSystemPrompt(resourceLines) {
|
|
826
967
|
return [
|
|
827
968
|
"You are integrated into a web page as an AI assistant. You have tools available to change page state or fetch information.",
|
|
828
969
|
"",
|
|
@@ -834,7 +975,27 @@
|
|
|
834
975
|
"",
|
|
835
976
|
"Current page state:",
|
|
836
977
|
JSON.stringify(currentPageState(), null, 2)
|
|
837
|
-
].join("\n");
|
|
978
|
+
].concat(resourceLines || []).join("\n");
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Whether to attach, and how often, is the SERVER's call — via the
|
|
982
|
+
// extension's autoAttach and volatility fields, which default to
|
|
983
|
+
// attach-once for a server that declares neither.
|
|
984
|
+
//
|
|
985
|
+
// Asking consumes the turn, and a volatile resource is re-read here rather
|
|
986
|
+
// than re-using the boot-time copy — so this runs once per send, before
|
|
987
|
+
// the system prompt is built.
|
|
988
|
+
async function resourceLinesForThisTurn() {
|
|
989
|
+
var turn = await resourceLinesForTurn({
|
|
990
|
+
plan: resourcePlan,
|
|
991
|
+
attacher: resourceAttacher,
|
|
992
|
+
cached: resourceContext,
|
|
993
|
+
endpoint: resourcePlan && resourcePlan.endpoint,
|
|
994
|
+
read: readMcpResource,
|
|
995
|
+
budgetBytes: RESOURCE_BUDGET_BYTES
|
|
996
|
+
});
|
|
997
|
+
resourceContext = turn.cached;
|
|
998
|
+
return turn.lines;
|
|
838
999
|
}
|
|
839
1000
|
|
|
840
1001
|
// Per-turn AbortController — lets the Clear button (or a new submit)
|
|
@@ -845,6 +1006,9 @@
|
|
|
845
1006
|
if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
|
|
846
1007
|
conversation = [];
|
|
847
1008
|
historyEl.innerHTML = "";
|
|
1009
|
+
// Clear starts a new conversation, so a 'once' resource is owed to it
|
|
1010
|
+
// again — the old boolean stayed latched and silently withheld it.
|
|
1011
|
+
if (resourcePlan) resourceAttacher = createResourceAttacher(resourcePlan);
|
|
848
1012
|
});
|
|
849
1013
|
|
|
850
1014
|
// Enter submits, Shift+Enter inserts a newline — matches llm_meta_chat's
|
|
@@ -868,16 +1032,21 @@
|
|
|
868
1032
|
if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
|
|
869
1033
|
currentAbort = new AbortController();
|
|
870
1034
|
|
|
871
|
-
var messages = [{ role: "system", content: currentSystemPrompt() }]
|
|
872
|
-
.concat(conversation)
|
|
873
|
-
.concat([{ role: "user", content: userText }]);
|
|
874
|
-
|
|
875
1035
|
var assistantBody = appendTurn("assistant", "");
|
|
876
1036
|
var assistantMarkdown = ""; // accumulate raw markdown, re-render on each delta
|
|
877
1037
|
|
|
878
1038
|
try {
|
|
1039
|
+
// Discovery has to finish before the system prompt is built: the
|
|
1040
|
+
// resource catalog is attached to the first turn only, so building
|
|
1041
|
+
// messages first would silently ship that turn without it AND mark
|
|
1042
|
+
// it as sent.
|
|
879
1043
|
await wellKnownReady;
|
|
880
1044
|
|
|
1045
|
+
var resourceLines = await resourceLinesForThisTurn();
|
|
1046
|
+
var messages = [{ role: "system", content: currentSystemPrompt(resourceLines) }]
|
|
1047
|
+
.concat(conversation)
|
|
1048
|
+
.concat([{ role: "user", content: userText }]);
|
|
1049
|
+
|
|
881
1050
|
var result = await runChatLoop({
|
|
882
1051
|
baseUrl: META_BASE,
|
|
883
1052
|
apiKeyUuid: API_KEY_UUID,
|