@kitn.ai/ui 0.22.2 → 0.23.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.
- package/dist/mcp.es.js +365 -117
- package/package.json +5 -2
- package/src/agent-tooling/integrations/vercel-ai-sdk.ts +255 -14
- package/src/agent-tooling/mcp/tools/scaffold.ts +19 -187
- package/src/agent-tooling/route-emit.ts +281 -0
package/dist/mcp.es.js
CHANGED
|
@@ -1559,13 +1559,50 @@ const vercelAiSdk = {
|
|
|
1559
1559
|
// No per-framework templates: the handler below is web-standard, so the
|
|
1560
1560
|
// scaffolder wraps it in the target framework's own route declaration.
|
|
1561
1561
|
routeTemplates: {},
|
|
1562
|
-
webRoute: `import { streamText } from 'ai';
|
|
1563
|
-
import type {
|
|
1562
|
+
webRoute: `import { dynamicTool, jsonSchema, streamText } from 'ai';
|
|
1563
|
+
import type {
|
|
1564
|
+
AssistantContent,
|
|
1565
|
+
FilePart,
|
|
1566
|
+
JSONSchema7,
|
|
1567
|
+
ModelMessage,
|
|
1568
|
+
SystemModelMessage,
|
|
1569
|
+
ToolResultPart,
|
|
1570
|
+
ToolSet,
|
|
1571
|
+
UserContent,
|
|
1572
|
+
} from 'ai';
|
|
1564
1573
|
|
|
1565
1574
|
// Next.js only: add \`export const maxDuration = 30\` to the route file to allow
|
|
1566
1575
|
// long streaming responses. It is a Next route-segment config, not part of the
|
|
1567
1576
|
// handler, so it lives in the file rather than in here.
|
|
1568
1577
|
|
|
1578
|
+
/**
|
|
1579
|
+
* The model, PINNED — and deliberately NOT read off the request body.
|
|
1580
|
+
*
|
|
1581
|
+
* Change THIS LINE to change the model; it is the only place one is named. The
|
|
1582
|
+
* AI Gateway takes a \`creator/model-name\` string, so any id it routes works
|
|
1583
|
+
* here without touching another import.
|
|
1584
|
+
*
|
|
1585
|
+
* Why it is not forwarded from the client, unlike the openai / openrouter /
|
|
1586
|
+
* anthropic routes:
|
|
1587
|
+
*
|
|
1588
|
+
* · Those three POST to ONE host with ONE id space, so the scaffold can seed a
|
|
1589
|
+
* valid default. The Gateway is a router across every vendor's id space at
|
|
1590
|
+
* once, so there is no default that is right for it — only one vendor's guess
|
|
1591
|
+
* baked into a provider-agnostic template.
|
|
1592
|
+
* · A forwarded model id on a \`needs-proxy\` route is a spend lever handed to
|
|
1593
|
+
* anything that can POST here, and the Gateway bills per token per model.
|
|
1594
|
+
*
|
|
1595
|
+
* WHY THIS ID. It is the one this route has actually been driven against live —
|
|
1596
|
+
* text, a single tool call and a multi-round tool loop, through the Gateway —
|
|
1597
|
+
* and, unlike a frontier default, it ANSWERS ON A FREE GATEWAY ACCOUNT. A paid
|
|
1598
|
+
* id fails a first \`npm run dev\` with
|
|
1599
|
+
* \`Free tier users do not have access to this model\`, which reads as a broken
|
|
1600
|
+
* scaffold rather than as a billing setting. It also supports tools and
|
|
1601
|
+
* reasoning, so the two things this route re-frames are reachable by default,
|
|
1602
|
+
* and it is cheaper than gpt-4o by roughly two orders of magnitude.
|
|
1603
|
+
*/
|
|
1604
|
+
const MODEL = 'openai/gpt-oss-120b';
|
|
1605
|
+
|
|
1569
1606
|
/**
|
|
1570
1607
|
* One attachment to an AI SDK FilePart.
|
|
1571
1608
|
*
|
|
@@ -1637,21 +1674,211 @@ function toModelMessages(messages: ChatRequestBody['messages']): ModelMessage[]
|
|
|
1637
1674
|
});
|
|
1638
1675
|
}
|
|
1639
1676
|
|
|
1677
|
+
/** The OpenAI function-calling envelope the front end sends, narrowed from
|
|
1678
|
+
* \`unknown[]\`. Declared as this integration's \`clientToolFormat\`. */
|
|
1679
|
+
type OpenAIFunctionTool = {
|
|
1680
|
+
function?: { name?: string; description?: string; parameters?: unknown };
|
|
1681
|
+
};
|
|
1682
|
+
|
|
1683
|
+
/**
|
|
1684
|
+
* OpenAI function schemas -> the AI SDK's own ToolSet.
|
|
1685
|
+
*
|
|
1686
|
+
* \`dynamicTool\` is the helper for a schema known only at RUNTIME. The ordinary
|
|
1687
|
+
* \`tool()\` infers its input type from a Zod schema written in the route, which
|
|
1688
|
+
* a list arriving in the request body cannot have.
|
|
1689
|
+
*
|
|
1690
|
+
* NO \`execute\`, deliberately. A tool the SDK can run makes the ROUTE the loop
|
|
1691
|
+
* owner: streamText would call it, feed the result back and answer in a single
|
|
1692
|
+
* response, so the tool call would never reach the browser and \`<kai-tool>\`
|
|
1693
|
+
* would have nothing to render. Without \`execute\` the SDK emits the call and
|
|
1694
|
+
* stops, which is the contract the kit's front end already implements — run the
|
|
1695
|
+
* tool, \`applyToolOutput\`, POST the thread again.
|
|
1696
|
+
*/
|
|
1697
|
+
function toToolSet(tools: ChatRequestBody['tools']): ToolSet | undefined {
|
|
1698
|
+
if (!tools?.length) return undefined;
|
|
1699
|
+
const out: ToolSet = {};
|
|
1700
|
+
for (const raw of tools) {
|
|
1701
|
+
const fn = (raw as OpenAIFunctionTool).function;
|
|
1702
|
+
if (!fn?.name) continue;
|
|
1703
|
+
out[fn.name] = dynamicTool({
|
|
1704
|
+
description: fn.description ?? '',
|
|
1705
|
+
inputSchema: jsonSchema((fn.parameters as JSONSchema7 | undefined) ?? { type: 'object' }),
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/** AI SDK finish reasons -> OpenAI's spelling. They agree on 'stop', 'length'
|
|
1712
|
+
* and 'error' and disagree on the other two, and readOpenAIStream's table reads
|
|
1713
|
+
* OpenAI's — so an unmapped 'tool-calls' normalises to 'other' and the turn
|
|
1714
|
+
* stops saying why it stopped. */
|
|
1715
|
+
const FINISH_REASONS: Record<string, string> = {
|
|
1716
|
+
'tool-calls': 'tool_calls',
|
|
1717
|
+
'content-filter': 'content_filter',
|
|
1718
|
+
};
|
|
1719
|
+
|
|
1640
1720
|
async function chatHandler(request: Request): Promise<Response> {
|
|
1641
|
-
const { messages } = await readChatRequest(request);
|
|
1721
|
+
const { messages, tools } = await readChatRequest(request);
|
|
1722
|
+
const toolSet = toToolSet(tools);
|
|
1723
|
+
const prompt = toModelMessages(messages);
|
|
1642
1724
|
|
|
1725
|
+
// THE SYSTEM TURN DOES NOT GO IN \`messages\`, and this is the one that costs a
|
|
1726
|
+
// live run to find. \`SystemModelMessage\` is still part of the \`ModelMessage\`
|
|
1727
|
+
// union, so a system entry in this array TYPECHECKS — and then \`ai\` v7's
|
|
1728
|
+
// \`standardizePrompt\` throws \`InvalidPromptError: System messages are not
|
|
1729
|
+
// allowed in the prompt or messages fields. Use the instructions option
|
|
1730
|
+
// instead.\` The kit's own encoder puts the system prompt at \`messages[0]\`, so
|
|
1731
|
+
// that is every single turn of a scaffolded app, not an edge case.
|
|
1732
|
+
//
|
|
1733
|
+
// Hoisted rather than joined into a string: \`instructions\` takes the message
|
|
1734
|
+
// array, so several system turns keep their order and their count.
|
|
1735
|
+
//
|
|
1736
|
+
// On \`ai\` v5/v6 there is no \`instructions\` option and a system message in
|
|
1737
|
+
// \`messages\` is correct — drop this split and pass \`prompt\` straight through
|
|
1738
|
+
// if you pin an older SDK.
|
|
1739
|
+
const instructions = prompt.filter((m): m is SystemModelMessage => m.role === 'system');
|
|
1740
|
+
const conversation = prompt.filter((m) => m.role !== 'system');
|
|
1741
|
+
|
|
1742
|
+
// \`streamText\` is not awaited: it returns synchronously and does its work as
|
|
1743
|
+
// the stream is iterated. Prompt validation is part of that work, so an
|
|
1744
|
+
// invalid prompt surfaces from \`for await (… of result.fullStream)\` below
|
|
1745
|
+
// rather than from this line — which is why the catch that reports it lives
|
|
1746
|
+
// in the stream and not around this call. Confirmed by observation: a rejected
|
|
1747
|
+
// prompt reached the browser as an in-band error frame, not as a 500.
|
|
1643
1748
|
const result = streamText({
|
|
1644
|
-
model:
|
|
1645
|
-
|
|
1749
|
+
model: MODEL, // AI Gateway id; needs AI_GATEWAY_API_KEY
|
|
1750
|
+
...(instructions.length > 0 ? { instructions } : {}),
|
|
1751
|
+
messages: conversation,
|
|
1752
|
+
...(toolSet ? { tools: toolSet } : {}),
|
|
1646
1753
|
});
|
|
1647
1754
|
|
|
1648
1755
|
const encoder = new TextEncoder();
|
|
1756
|
+
|
|
1757
|
+
// OpenAI correlates tool-call fragments by their POSITION in the tool_calls
|
|
1758
|
+
// array; the SDK identifies each call by id and never sends a position. So one
|
|
1759
|
+
// is derived from the other, in first-seen order, and every fragment of a call
|
|
1760
|
+
// carries the same number. Getting this wrong does not throw — the fragments
|
|
1761
|
+
// land on the wrong call and the arguments come out as spliced JSON.
|
|
1762
|
+
const toolIndex = new Map<string, number>();
|
|
1763
|
+
const indexOf = (id: string): number => {
|
|
1764
|
+
const known = toolIndex.get(id);
|
|
1765
|
+
if (known !== undefined) return known;
|
|
1766
|
+
const next = toolIndex.size;
|
|
1767
|
+
toolIndex.set(id, next);
|
|
1768
|
+
return next;
|
|
1769
|
+
};
|
|
1770
|
+
// How many argument characters a call streamed. \`tool-call\` re-sends the whole
|
|
1771
|
+
// input at the end, so emitting it unconditionally would DOUBLE the arguments
|
|
1772
|
+
// of every call that streamed — and skipping it unconditionally would empty
|
|
1773
|
+
// the arguments of any provider that does not stream them. Neither is safe to
|
|
1774
|
+
// assume, so the decision is made per call from what actually arrived.
|
|
1775
|
+
const streamedArgs = new Map<string, number>();
|
|
1776
|
+
|
|
1649
1777
|
const sse = new ReadableStream({
|
|
1650
1778
|
async start(controller) {
|
|
1779
|
+
const send = (chunk: unknown): void => {
|
|
1780
|
+
controller.enqueue(encoder.encode(\`data: \${JSON.stringify(chunk)}\\n\\n\`));
|
|
1781
|
+
};
|
|
1651
1782
|
try {
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1783
|
+
// fullStream, NOT textStream. textStream is text deltas only: a tool call
|
|
1784
|
+
// or a reasoning block goes past it silently, so a route built on it
|
|
1785
|
+
// emits a plain answer and nothing else however the model replied.
|
|
1786
|
+
for await (const part of result.fullStream) {
|
|
1787
|
+
switch (part.type) {
|
|
1788
|
+
case 'text-delta':
|
|
1789
|
+
send({ choices: [{ delta: { content: part.text } }] });
|
|
1790
|
+
break;
|
|
1791
|
+
|
|
1792
|
+
case 'reasoning-delta':
|
|
1793
|
+
send({ choices: [{ delta: { reasoning: part.text } }] });
|
|
1794
|
+
break;
|
|
1795
|
+
|
|
1796
|
+
// The call is ANNOUNCED here, before its arguments exist, which is
|
|
1797
|
+
// what lets <kai-tool> open a panel with the tool's name in it while
|
|
1798
|
+
// the arguments are still being written.
|
|
1799
|
+
case 'tool-input-start':
|
|
1800
|
+
streamedArgs.set(part.id, 0);
|
|
1801
|
+
send({
|
|
1802
|
+
choices: [{
|
|
1803
|
+
delta: {
|
|
1804
|
+
tool_calls: [{
|
|
1805
|
+
index: indexOf(part.id),
|
|
1806
|
+
id: part.id,
|
|
1807
|
+
type: 'function',
|
|
1808
|
+
function: { name: part.toolName, arguments: '' },
|
|
1809
|
+
}],
|
|
1810
|
+
},
|
|
1811
|
+
}],
|
|
1812
|
+
});
|
|
1813
|
+
break;
|
|
1814
|
+
|
|
1815
|
+
case 'tool-input-delta':
|
|
1816
|
+
streamedArgs.set(part.id, (streamedArgs.get(part.id) ?? 0) + part.delta.length);
|
|
1817
|
+
send({
|
|
1818
|
+
choices: [{
|
|
1819
|
+
delta: { tool_calls: [{ index: indexOf(part.id), function: { arguments: part.delta } }] },
|
|
1820
|
+
}],
|
|
1821
|
+
});
|
|
1822
|
+
break;
|
|
1823
|
+
|
|
1824
|
+
case 'tool-call':
|
|
1825
|
+
// Only when nothing streamed: see \`streamedArgs\`.
|
|
1826
|
+
if ((streamedArgs.get(part.toolCallId) ?? 0) === 0) {
|
|
1827
|
+
send({
|
|
1828
|
+
choices: [{
|
|
1829
|
+
delta: {
|
|
1830
|
+
tool_calls: [{
|
|
1831
|
+
index: indexOf(part.toolCallId),
|
|
1832
|
+
id: part.toolCallId,
|
|
1833
|
+
type: 'function',
|
|
1834
|
+
function: {
|
|
1835
|
+
name: part.toolName,
|
|
1836
|
+
arguments: JSON.stringify(part.input ?? {}),
|
|
1837
|
+
},
|
|
1838
|
+
}],
|
|
1839
|
+
},
|
|
1840
|
+
}],
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
break;
|
|
1844
|
+
|
|
1845
|
+
// One frame carries both, the way chat-completions sends them.
|
|
1846
|
+
// \`reasoning_tokens\` is the number that proves thinking happened even
|
|
1847
|
+
// when the provider streamed no reasoning text.
|
|
1848
|
+
case 'finish':
|
|
1849
|
+
send({
|
|
1850
|
+
choices: [{
|
|
1851
|
+
delta: {},
|
|
1852
|
+
finish_reason: FINISH_REASONS[part.finishReason] ?? part.finishReason,
|
|
1853
|
+
}],
|
|
1854
|
+
usage: {
|
|
1855
|
+
prompt_tokens: part.totalUsage.inputTokens,
|
|
1856
|
+
completion_tokens: part.totalUsage.outputTokens,
|
|
1857
|
+
total_tokens: part.totalUsage.totalTokens,
|
|
1858
|
+
completion_tokens_details: {
|
|
1859
|
+
reasoning_tokens: part.totalUsage.outputTokenDetails.reasoningTokens,
|
|
1860
|
+
},
|
|
1861
|
+
},
|
|
1862
|
+
});
|
|
1863
|
+
break;
|
|
1864
|
+
|
|
1865
|
+
// An error the SDK caught mid-stream. The status is long spent, so it
|
|
1866
|
+
// goes IN BAND like the catch below.
|
|
1867
|
+
case 'error':
|
|
1868
|
+
send({
|
|
1869
|
+
error: {
|
|
1870
|
+
message: part.error instanceof Error ? part.error.message : String(part.error),
|
|
1871
|
+
},
|
|
1872
|
+
});
|
|
1873
|
+
break;
|
|
1874
|
+
|
|
1875
|
+
// Everything else — text-start/end, tool-input-end, sources, files,
|
|
1876
|
+
// step boundaries, raw provider frames — has no OpenAI-wire spelling
|
|
1877
|
+
// and is dropped. \`source\` is the one worth knowing about: map it to
|
|
1878
|
+
// \`delta.annotations[].url_citation\` if your model cites its sources.
|
|
1879
|
+
default:
|
|
1880
|
+
break;
|
|
1881
|
+
}
|
|
1655
1882
|
}
|
|
1656
1883
|
} catch (err) {
|
|
1657
1884
|
// The status is spent by the time the SDK fails — the headers went out
|
|
@@ -1659,7 +1886,7 @@ async function chatHandler(request: Request): Promise<Response> {
|
|
|
1659
1886
|
// this on turn.error and keeps whatever streamed before it. Without it a
|
|
1660
1887
|
// failed key is an empty bubble and nothing in the console.
|
|
1661
1888
|
const message = err instanceof Error ? err.message : 'Model stream failed';
|
|
1662
|
-
|
|
1889
|
+
send({ error: { message } });
|
|
1663
1890
|
}
|
|
1664
1891
|
controller.enqueue(encoder.encode('data: [DONE]\\n\\n'));
|
|
1665
1892
|
controller.close();
|
|
@@ -1676,12 +1903,24 @@ async function chatHandler(request: Request): Promise<Response> {
|
|
|
1676
1903
|
},
|
|
1677
1904
|
});
|
|
1678
1905
|
}`,
|
|
1679
|
-
streamMapping: "The Vercel AI SDK's toUIMessageStreamResponse() and toTextStreamResponse() don't emit OpenAI-format SSE.
|
|
1680
|
-
runNote: "Set AI_GATEWAY_API_KEY for the AI Gateway (string model id form: creator/model-name). For direct provider access, import its provider package (e.g. @ai-sdk/openai) and set the corresponding key (e.g. OPENAI_API_KEY).",
|
|
1906
|
+
streamMapping: "The Vercel AI SDK's toUIMessageStreamResponse() and toTextStreamResponse() don't emit OpenAI-format SSE, so the route re-frames the stream itself and readOpenAIStream from @kitn.ai/ui/wire parses it exactly as it does every other integration. Iterate result.fullStream, NOT result.textStream: textStream carries text deltas only, so a route built on it emits a plain answer however the model replied and drops every tool call and every reasoning block silently. fullStream yields typed parts: text-delta.text -> delta.content, reasoning-delta.text -> delta.reasoning, tool-input-start plus its tool-input-delta fragments -> delta.tool_calls, finish -> finish_reason plus a usage frame, error -> an in-band {error:{message}}. Two traps. (1) OpenAI correlates tool-call fragments by their POSITION in the tool_calls array and the SDK only ever gives an id, so the route keeps an id -> index map; passing anything else through as the index splices one call's arguments into another. (2) fullStream sends the complete input AGAIN on the tool-call part after streaming it in fragments, so emitting both doubles the arguments — the route tracks how much each call streamed and emits the tool-call part only for a provider that streamed none. Parts with no OpenAI spelling (text-start/end, tool-input-end, step boundaries, raw) are dropped; source parts have one — delta.annotations[].url_citation — and are left unmapped because the SDK's Source union carries document sources a url_citation cannot express. On the REQUEST side the trap that only a live run finds: ai v7 REFUSES a system message inside `messages` (InvalidPromptError, 'Use the instructions option instead') even though SystemModelMessage is still in the ModelMessage union and therefore typechecks — and the kit's encoder puts the system prompt at messages[0], so that is every turn. Hoist system turns into `instructions` and pass the rest as `messages`. That failure arrives from ITERATING fullStream, not from the streamText() call — streamText returns synchronously and validates as the stream is read — so the in-band catch around the loop is what reports it, and it reaches the browser as an error frame rather than as a 500.",
|
|
1907
|
+
runNote: "Set AI_GATEWAY_API_KEY for the AI Gateway (string model id form: creator/model-name). The route pins `const MODEL = 'openai/gpt-oss-120b'` — one line, at the top, and any id the Gateway routes works in it. That id is pinned because it answers on a FREE Gateway account: most ids (deepseek/*, meta/*, anthropic/*) return `Free tier users do not have access to this model` or a free-tier rate limit until the account has paid credits, which looks like a broken scaffold rather than a billing setting. For direct provider access, import its provider package (e.g. @ai-sdk/openai) and set the corresponding key (e.g. OPENAI_API_KEY). The tools the front end posts become `dynamicTool`s with NO `execute`, which is what keeps the tool loop in the app: the SDK emits the call and stops, the app runs it, renders it in <kai-tool> and posts the thread back. Give a tool an `execute` and the SDK runs the whole loop server-side, so nothing reaches the browser but the final sentence.",
|
|
1681
1908
|
docsSlug: "integrations/vercel-ai-sdk",
|
|
1682
|
-
//
|
|
1683
|
-
//
|
|
1684
|
-
|
|
1909
|
+
// `tools` only. `model` is deliberately NOT forwarded — the route pins it in
|
|
1910
|
+
// one named const, and see that const's comment for why the Gateway is the one
|
|
1911
|
+
// host where a client-supplied id has no correct default. The catalog check
|
|
1912
|
+
// agrees from the other direction: `every integration that forwards a model
|
|
1913
|
+
// emits one valid for the host it POSTs to` reads the host off the route's own
|
|
1914
|
+
// fetch(), and this route makes no fetch call at all — the SDK owns the
|
|
1915
|
+
// transport — so a forwarded model here could not be validated against
|
|
1916
|
+
// anything.
|
|
1917
|
+
forwardsFromClient: ["tools"],
|
|
1918
|
+
// 'openai': the ROUTE's request contract, not the SDK's own. `toToolSet` reads
|
|
1919
|
+
// `raw.function.name` / `.function.parameters` off each entry — the OpenAI
|
|
1920
|
+
// function-calling envelope — and rebuilds it as a `dynamicTool` with a
|
|
1921
|
+
// `jsonSchema()` input. Sending the SDK's own tool shape from the client would
|
|
1922
|
+
// leave `.function` undefined and every tool would arrive nameless.
|
|
1923
|
+
clientToolFormat: "openai",
|
|
1685
1924
|
// `ai` only. A direct provider (e.g. @ai-sdk/openai) is the alternative path
|
|
1686
1925
|
// described in runNote, not what this route imports, so it is not listed: the
|
|
1687
1926
|
// rule is what the emitted code actually imports.
|
|
@@ -2594,6 +2833,114 @@ function listIntegrations() {
|
|
|
2594
2833
|
function listArchetypes() {
|
|
2595
2834
|
return archetypes;
|
|
2596
2835
|
}
|
|
2836
|
+
const CLIENT_MODEL_IDS = {
|
|
2837
|
+
// Vendor-prefixed `vendor/model`: OpenRouter's own id space, and the ONLY one
|
|
2838
|
+
// of the three where the prefix belongs.
|
|
2839
|
+
openrouter: "openai/gpt-4o-mini",
|
|
2840
|
+
// No vendor prefix. This is what the route already pinned, so moving the knob
|
|
2841
|
+
// to the client changes the wire not at all.
|
|
2842
|
+
openai: "gpt-4o-mini",
|
|
2843
|
+
// Anthropic's id space. Matches what the route pinned; 'claude-sonnet-5' and
|
|
2844
|
+
// 'claude-haiku-4-5' are the cheaper swaps (see this integration's runNote).
|
|
2845
|
+
anthropic: "claude-opus-5"
|
|
2846
|
+
};
|
|
2847
|
+
function defaultModelFor(integration) {
|
|
2848
|
+
if (!integration.forwardsFromClient.includes("model")) return void 0;
|
|
2849
|
+
const id = CLIENT_MODEL_IDS[integration.id];
|
|
2850
|
+
if (id === void 0) {
|
|
2851
|
+
throw new Error(
|
|
2852
|
+
`Integration '${integration.id}' forwards the client's 'model' but has no CLIENT_MODEL_IDS entry, so the scaffold would emit a model id that is not valid for the host its route POSTs to. Add one in agent-tooling/route-emit.ts.`
|
|
2853
|
+
);
|
|
2854
|
+
}
|
|
2855
|
+
return id;
|
|
2856
|
+
}
|
|
2857
|
+
const CHAT_REQUEST_BODY_IMPORT = `import type { OpenAIWireMessage } from '@kitn.ai/ui/wire';`;
|
|
2858
|
+
const CHAT_REQUEST_BODY_DECL = [
|
|
2859
|
+
`/**`,
|
|
2860
|
+
` * What the front end POSTs. \`request.json()\` is \`unknown\` (it is whatever the`,
|
|
2861
|
+
` * client sent), so the body is narrowed once here instead of at every use —`,
|
|
2862
|
+
` * without it this route does not compile under a server tsconfig. Widen it as`,
|
|
2863
|
+
` * you add fields of your own.`,
|
|
2864
|
+
` */`,
|
|
2865
|
+
`type ChatRequestBody = {`,
|
|
2866
|
+
` messages: OpenAIWireMessage[];`,
|
|
2867
|
+
` model?: string;`,
|
|
2868
|
+
` tools?: unknown[];`,
|
|
2869
|
+
`};`,
|
|
2870
|
+
``,
|
|
2871
|
+
`/** Narrow the JSON body once, at the edge. */`,
|
|
2872
|
+
`async function readChatRequest(request: Request): Promise<ChatRequestBody> {`,
|
|
2873
|
+
` return (await request.json()) as ChatRequestBody;`,
|
|
2874
|
+
`}`
|
|
2875
|
+
];
|
|
2876
|
+
const CONTENT_PARTS_DECL = [
|
|
2877
|
+
`/** Where an attachment's bytes are: inline base64, or an address the PROVIDER`,
|
|
2878
|
+
` * fetches. Never both. */`,
|
|
2879
|
+
`type WireFileSource = { type: 'data'; data: string } | { type: 'url'; url: string };`,
|
|
2880
|
+
``,
|
|
2881
|
+
`/** One piece of a turn, with the string and array content forms flattened into`,
|
|
2882
|
+
` * a single shape. */`,
|
|
2883
|
+
`type WirePart =`,
|
|
2884
|
+
` | { kind: 'text'; text: string }`,
|
|
2885
|
+
` | { kind: 'file'; mediaType: string; filename?: string; source: WireFileSource };`,
|
|
2886
|
+
``,
|
|
2887
|
+
`const DATA_URI = /^data:([^;,]+);base64,([\\s\\S]*)$/;`,
|
|
2888
|
+
``,
|
|
2889
|
+
`/**`,
|
|
2890
|
+
` * Flatten a wire message's content into parts.`,
|
|
2891
|
+
` *`,
|
|
2892
|
+
` * An image sent by URL has no media type here — \`image_url\` carries only the`,
|
|
2893
|
+
` * address — so it reports the top-level segment \`'image'\`, which is all a URL`,
|
|
2894
|
+
` * source needs. Only images can reach that branch: the kit refuses to encode a`,
|
|
2895
|
+
` * remote PDF rather than guess at one.`,
|
|
2896
|
+
` */`,
|
|
2897
|
+
`function wireParts(content: OpenAIWireMessage['content']): WirePart[] {`,
|
|
2898
|
+
` if (content == null) return [];`,
|
|
2899
|
+
` if (typeof content === 'string') return content === '' ? [] : [{ kind: 'text', text: content }];`,
|
|
2900
|
+
` return content.map((part): WirePart => {`,
|
|
2901
|
+
` if (part.type === 'text') return { kind: 'text', text: part.text };`,
|
|
2902
|
+
` if (part.type === 'image_url') {`,
|
|
2903
|
+
` const asData = DATA_URI.exec(part.image_url.url);`,
|
|
2904
|
+
` return asData`,
|
|
2905
|
+
` ? { kind: 'file', mediaType: asData[1], source: { type: 'data', data: asData[2] } }`,
|
|
2906
|
+
` : { kind: 'file', mediaType: 'image', source: { type: 'url', url: part.image_url.url } };`,
|
|
2907
|
+
` }`,
|
|
2908
|
+
` const asData = DATA_URI.exec(part.file.file_data);`,
|
|
2909
|
+
` if (!asData) {`,
|
|
2910
|
+
` // LOUD on purpose. \`file_data\` is a data URI on this wire; anything else`,
|
|
2911
|
+
` // cannot be turned into bytes without fetching it, and forwarding a turn`,
|
|
2912
|
+
` // with the attachment quietly missing is the bug this whole path exists`,
|
|
2913
|
+
` // to prevent.`,
|
|
2914
|
+
` throw new Error(`,
|
|
2915
|
+
` 'Unsupported file content part: file_data must be a data: URI of the form data:<media type>;base64,<data>.',`,
|
|
2916
|
+
` );`,
|
|
2917
|
+
` }`,
|
|
2918
|
+
` return {`,
|
|
2919
|
+
` kind: 'file',`,
|
|
2920
|
+
` mediaType: asData[1],`,
|
|
2921
|
+
` filename: part.file.filename,`,
|
|
2922
|
+
` source: { type: 'data', data: asData[2] },`,
|
|
2923
|
+
` };`,
|
|
2924
|
+
` });`,
|
|
2925
|
+
`}`,
|
|
2926
|
+
``,
|
|
2927
|
+
`/** Just the text of a turn. System, assistant and tool messages are text-only`,
|
|
2928
|
+
` * on this wire, so this collapses the array form for them. */`,
|
|
2929
|
+
`function wireText(content: OpenAIWireMessage['content']): string {`,
|
|
2930
|
+
` return wireParts(content)`,
|
|
2931
|
+
` .map((p) => (p.kind === 'text' ? p.text : ''))`,
|
|
2932
|
+
` .join('');`,
|
|
2933
|
+
`}`
|
|
2934
|
+
];
|
|
2935
|
+
const PREAMBLE_DECLARATION = /^(?:export\s+)?(?:async\s+)?(?:function|type|interface|const|class)\s+([A-Za-z_$][\w$]*)/;
|
|
2936
|
+
function chatRoutePreamble(fragment) {
|
|
2937
|
+
const decl = /\bwire(?:Parts|Text)\s*\(/.test(fragment) ? [...CHAT_REQUEST_BODY_DECL, ``, ...CONTENT_PARTS_DECL] : [...CHAT_REQUEST_BODY_DECL];
|
|
2938
|
+
return {
|
|
2939
|
+
imports: [CHAT_REQUEST_BODY_IMPORT],
|
|
2940
|
+
decl,
|
|
2941
|
+
symbols: decl.flatMap((line) => PREAMBLE_DECLARATION.exec(line)?.[1] ?? [])
|
|
2942
|
+
};
|
|
2943
|
+
}
|
|
2597
2944
|
const ENCODABLE = [
|
|
2598
2945
|
{ pattern: "image/jpeg", kind: "image" },
|
|
2599
2946
|
{ pattern: "image/png", kind: "image" },
|
|
@@ -2976,27 +3323,6 @@ function realBodyPayload(opts) {
|
|
|
2976
3323
|
return `{ ${fields.join(", ")} }`;
|
|
2977
3324
|
};
|
|
2978
3325
|
}
|
|
2979
|
-
const CLIENT_MODEL_IDS = {
|
|
2980
|
-
// Vendor-prefixed `vendor/model`: OpenRouter's own id space, and the ONLY one
|
|
2981
|
-
// of the three where the prefix belongs.
|
|
2982
|
-
openrouter: "openai/gpt-4o-mini",
|
|
2983
|
-
// No vendor prefix. This is what the route already pinned, so moving the knob
|
|
2984
|
-
// to the client changes the wire not at all.
|
|
2985
|
-
openai: "gpt-4o-mini",
|
|
2986
|
-
// Anthropic's id space. Matches what the route pinned; 'claude-sonnet-5' and
|
|
2987
|
-
// 'claude-haiku-4-5' are the cheaper swaps (see this integration's runNote).
|
|
2988
|
-
anthropic: "claude-opus-5"
|
|
2989
|
-
};
|
|
2990
|
-
function defaultModelFor(integration) {
|
|
2991
|
-
if (!integration.forwardsFromClient.includes("model")) return void 0;
|
|
2992
|
-
const id = CLIENT_MODEL_IDS[integration.id];
|
|
2993
|
-
if (id === void 0) {
|
|
2994
|
-
throw new Error(
|
|
2995
|
-
`Integration '${integration.id}' forwards the client's 'model' but has no CLIENT_MODEL_IDS entry, so the scaffold would emit a model id that is not valid for the host its route POSTs to. Add one in mcp/tools/scaffold.ts.`
|
|
2996
|
-
);
|
|
2997
|
-
}
|
|
2998
|
-
return id;
|
|
2999
|
-
}
|
|
3000
3326
|
function emitsToolSchemas(components, integration) {
|
|
3001
3327
|
const needsToolsArray = hasToolPanel(components) || bearsCards(components);
|
|
3002
3328
|
return needsToolsArray && integration.forwardsFromClient.includes("tools");
|
|
@@ -5419,86 +5745,7 @@ const WEB_ROUTE_ADAPTERS = {
|
|
|
5419
5745
|
]
|
|
5420
5746
|
}
|
|
5421
5747
|
};
|
|
5422
|
-
|
|
5423
|
-
const CHAT_REQUEST_BODY_DECL = [
|
|
5424
|
-
`/**`,
|
|
5425
|
-
` * What the front end POSTs. \`request.json()\` is \`unknown\` (it is whatever the`,
|
|
5426
|
-
` * client sent), so the body is narrowed once here instead of at every use —`,
|
|
5427
|
-
` * without it this route does not compile under a server tsconfig. Widen it as`,
|
|
5428
|
-
` * you add fields of your own.`,
|
|
5429
|
-
` */`,
|
|
5430
|
-
`type ChatRequestBody = {`,
|
|
5431
|
-
` messages: OpenAIWireMessage[];`,
|
|
5432
|
-
` model?: string;`,
|
|
5433
|
-
` tools?: unknown[];`,
|
|
5434
|
-
`};`,
|
|
5435
|
-
``,
|
|
5436
|
-
`/** Narrow the JSON body once, at the edge. */`,
|
|
5437
|
-
`async function readChatRequest(request: Request): Promise<ChatRequestBody> {`,
|
|
5438
|
-
` return (await request.json()) as ChatRequestBody;`,
|
|
5439
|
-
`}`
|
|
5440
|
-
];
|
|
5441
|
-
const CONTENT_PARTS_DECL = [
|
|
5442
|
-
`/** Where an attachment's bytes are: inline base64, or an address the PROVIDER`,
|
|
5443
|
-
` * fetches. Never both. */`,
|
|
5444
|
-
`type WireFileSource = { type: 'data'; data: string } | { type: 'url'; url: string };`,
|
|
5445
|
-
``,
|
|
5446
|
-
`/** One piece of a turn, with the string and array content forms flattened into`,
|
|
5447
|
-
` * a single shape. */`,
|
|
5448
|
-
`type WirePart =`,
|
|
5449
|
-
` | { kind: 'text'; text: string }`,
|
|
5450
|
-
` | { kind: 'file'; mediaType: string; filename?: string; source: WireFileSource };`,
|
|
5451
|
-
``,
|
|
5452
|
-
`const DATA_URI = /^data:([^;,]+);base64,([\\s\\S]*)$/;`,
|
|
5453
|
-
``,
|
|
5454
|
-
`/**`,
|
|
5455
|
-
` * Flatten a wire message's content into parts.`,
|
|
5456
|
-
` *`,
|
|
5457
|
-
` * An image sent by URL has no media type here — \`image_url\` carries only the`,
|
|
5458
|
-
` * address — so it reports the top-level segment \`'image'\`, which is all a URL`,
|
|
5459
|
-
` * source needs. Only images can reach that branch: the kit refuses to encode a`,
|
|
5460
|
-
` * remote PDF rather than guess at one.`,
|
|
5461
|
-
` */`,
|
|
5462
|
-
`function wireParts(content: OpenAIWireMessage['content']): WirePart[] {`,
|
|
5463
|
-
` if (content == null) return [];`,
|
|
5464
|
-
` if (typeof content === 'string') return content === '' ? [] : [{ kind: 'text', text: content }];`,
|
|
5465
|
-
` return content.map((part): WirePart => {`,
|
|
5466
|
-
` if (part.type === 'text') return { kind: 'text', text: part.text };`,
|
|
5467
|
-
` if (part.type === 'image_url') {`,
|
|
5468
|
-
` const asData = DATA_URI.exec(part.image_url.url);`,
|
|
5469
|
-
` return asData`,
|
|
5470
|
-
` ? { kind: 'file', mediaType: asData[1], source: { type: 'data', data: asData[2] } }`,
|
|
5471
|
-
` : { kind: 'file', mediaType: 'image', source: { type: 'url', url: part.image_url.url } };`,
|
|
5472
|
-
` }`,
|
|
5473
|
-
` const asData = DATA_URI.exec(part.file.file_data);`,
|
|
5474
|
-
` if (!asData) {`,
|
|
5475
|
-
` // LOUD on purpose. \`file_data\` is a data URI on this wire; anything else`,
|
|
5476
|
-
` // cannot be turned into bytes without fetching it, and forwarding a turn`,
|
|
5477
|
-
` // with the attachment quietly missing is the bug this whole path exists`,
|
|
5478
|
-
` // to prevent.`,
|
|
5479
|
-
` throw new Error(`,
|
|
5480
|
-
` 'Unsupported file content part: file_data must be a data: URI of the form data:<media type>;base64,<data>.',`,
|
|
5481
|
-
` );`,
|
|
5482
|
-
` }`,
|
|
5483
|
-
` return {`,
|
|
5484
|
-
` kind: 'file',`,
|
|
5485
|
-
` mediaType: asData[1],`,
|
|
5486
|
-
` filename: part.file.filename,`,
|
|
5487
|
-
` source: { type: 'data', data: asData[2] },`,
|
|
5488
|
-
` };`,
|
|
5489
|
-
` });`,
|
|
5490
|
-
`}`,
|
|
5491
|
-
``,
|
|
5492
|
-
`/** Just the text of a turn. System, assistant and tool messages are text-only`,
|
|
5493
|
-
` * on this wire, so this collapses the array form for them. */`,
|
|
5494
|
-
`function wireText(content: OpenAIWireMessage['content']): string {`,
|
|
5495
|
-
` return wireParts(content)`,
|
|
5496
|
-
` .map((p) => (p.kind === 'text' ? p.text : ''))`,
|
|
5497
|
-
` .join('');`,
|
|
5498
|
-
`}`
|
|
5499
|
-
];
|
|
5500
|
-
function withChatRequestBody(fragment) {
|
|
5501
|
-
const decl = /\bwire(?:Parts|Text)\s*\(/.test(fragment) ? [...CHAT_REQUEST_BODY_DECL, ``, ...CONTENT_PARTS_DECL] : CHAT_REQUEST_BODY_DECL;
|
|
5748
|
+
function withChatRequestBody(fragment, decl) {
|
|
5502
5749
|
const lines = fragment.split("\n");
|
|
5503
5750
|
let at = lines.findIndex((l) => /^(?:export\s+)?async function chatHandler\b/.test(l));
|
|
5504
5751
|
if (at < 0) return [...decl, ``, ...lines].join("\n");
|
|
@@ -5510,17 +5757,18 @@ function webRouteFor(integration, framework) {
|
|
|
5510
5757
|
const adapter = WEB_ROUTE_ADAPTERS[framework];
|
|
5511
5758
|
if (!fragment || !adapter) return void 0;
|
|
5512
5759
|
const adapted = adapter.adaptFragment?.(fragment) ?? { fragment, imports: [] };
|
|
5760
|
+
const preamble = chatRoutePreamble(adapted.fragment);
|
|
5513
5761
|
return {
|
|
5514
5762
|
framework,
|
|
5515
5763
|
runtime: adapter.runtime,
|
|
5516
5764
|
exact: true,
|
|
5517
5765
|
template: [
|
|
5518
5766
|
`// ${adapter.file}`,
|
|
5519
|
-
|
|
5767
|
+
...preamble.imports,
|
|
5520
5768
|
...adapter.before ?? [],
|
|
5521
5769
|
...adapted.imports,
|
|
5522
5770
|
``,
|
|
5523
|
-
withChatRequestBody(adapted.fragment),
|
|
5771
|
+
withChatRequestBody(adapted.fragment, preamble.decl),
|
|
5524
5772
|
...adapter.after
|
|
5525
5773
|
].join("\n")
|
|
5526
5774
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitn.ai/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"nx": {
|
|
5
5
|
"name": "ui",
|
|
6
6
|
"targets": {
|
|
@@ -155,7 +155,7 @@
|
|
|
155
155
|
"scripts": {
|
|
156
156
|
"prepublishOnly": "npm run build",
|
|
157
157
|
"prebuild": "npm run build:css && npm run build:card-validation",
|
|
158
|
-
"build": "vite build --config vite.config.ts && vite build --config vite.config.provider.ts && vite build --config vite.config.react.ts && vite build --config vite.config.barrel.ts && vite build --config vite.config.barrel.server.ts && vite build --config vite.config.solid.ts && vite build --config vite.config.solid.server.ts && vite build --config vite.config.state.ts && vite build --config vite.config.wire.ts && vite build --config vite.config.schemas.ts && vite build --config vite.config.mcp.ts && npm run build:elements &&
|
|
158
|
+
"build": "vite build --config vite.config.ts && vite build --config vite.config.provider.ts && vite build --config vite.config.react.ts && vite build --config vite.config.barrel.ts && vite build --config vite.config.barrel.server.ts && vite build --config vite.config.solid.ts && vite build --config vite.config.solid.server.ts && vite build --config vite.config.state.ts && vite build --config vite.config.wire.ts && vite build --config vite.config.schemas.ts && vite build --config vite.config.mcp.ts && npm run build:elements && npm run verify:elements-bundle && npm run verify:react-wrappers && npm run verify:shader-lazy",
|
|
159
159
|
"build:elements": "node scripts/gen-elements-manifest.mjs && vite build --config vite.config.elements.ts && node scripts/gen-element-dts.mjs",
|
|
160
160
|
"postbuild": "npm run build:theme && npm run build:api && npm run build:schemas && npm run build:subpath-dts && npm run verify:dts",
|
|
161
161
|
"build:theme": "node scripts/build-theme-tokens.mjs",
|
|
@@ -166,11 +166,14 @@
|
|
|
166
166
|
"verify:card-validation": "node scripts/gen-card-validation-schemas.mjs --check",
|
|
167
167
|
"verify:consumer": "node scripts/verify-consumer-sideeffects.mjs",
|
|
168
168
|
"verify:dts": "node scripts/verify-dts-boundaries.mjs",
|
|
169
|
+
"verify:elements-bundle": "node scripts/verify-elements-bundle.mjs --self-test && node scripts/verify-elements-bundle.mjs",
|
|
169
170
|
"verify:dts:consumer": "node scripts/verify-dts-consumer.mjs",
|
|
170
171
|
"verify:generated": "node scripts/verify-generated-sync.mjs",
|
|
171
172
|
"verify:quarantine": "node scripts/verify-quarantine.mjs",
|
|
173
|
+
"verify:react-wrappers": "node scripts/verify-react-wrappers.mjs --self-test && node scripts/verify-react-wrappers.mjs",
|
|
172
174
|
"verify:scaffold": "node scripts/verify-scaffold-compiles.mjs",
|
|
173
175
|
"verify:schemas": "node scripts/verify-schemas-exported.mjs",
|
|
176
|
+
"verify:shader-lazy": "node scripts/verify-shader-lazy.mjs --self-test && node scripts/verify-shader-lazy.mjs",
|
|
174
177
|
"verify:solid-coverage": "node scripts/verify-solid-coverage.mjs",
|
|
175
178
|
"verify:tool-schemas": "node scripts/verify-tool-schemas.mjs",
|
|
176
179
|
"verify:ssr": "node scripts/verify-ssr-imports.mjs && node scripts/verify-ssr-render.mjs",
|