- Switch conversation agent to xiaomi/mimo-v2.5 (multimodal: text + image) - Render native reasoning parts as live 'Thinking trace' (streaming open, collapsed after completion); inline <think> extraction for streaming models - Image attachments: picker (up to 4, 10MB each), base64 to Flue AgentPromptImage, authenticated blob-URL replay for historical images - Mobile keyboard viewport fix: visual-viewport hook, fixed shell, interactive-widget=resizes-content, header pinned, composer follows keyboard - Conversation to Signal to proposed Work: Convex persistence, Effect validation in @code/work-os, Work cards with exact source provenance - Streamdown markdown + Mermaid chart rendering in chat messages - Flue tool turns hidden, reasoning-containing turns remain visible - Frontend regression tests: keyboard viewport, responsive shell, attachment overflow, authenticated images, reasoning traces, transforms - .env.example updated to xiaomi/mimo-v2.5 config
121 lines
3.8 KiB
TypeScript
121 lines
3.8 KiB
TypeScript
interface FlueFetchOptions {
|
|
readonly baseUrl: URL;
|
|
readonly fetchImpl?: typeof fetch;
|
|
readonly generateRequestId?: () => string;
|
|
}
|
|
|
|
export const generateBrowserRequestId = (): string => {
|
|
const bytes = new Uint8Array(16);
|
|
if (globalThis.crypto?.getRandomValues) {
|
|
globalThis.crypto.getRandomValues(bytes);
|
|
} else {
|
|
for (let index = 0; index < bytes.length; index += 1) {
|
|
bytes[index] = Math.floor(Math.random() * 256);
|
|
}
|
|
}
|
|
bytes[6] = ((bytes[6] ?? 0) % 16) + 64;
|
|
bytes[8] = ((bytes[8] ?? 0) % 64) + 128;
|
|
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0"));
|
|
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
|
|
};
|
|
|
|
const addRequestContext = async (
|
|
response: Response,
|
|
method: string,
|
|
requestUrl: URL
|
|
): Promise<Response> => {
|
|
if (response.ok) {
|
|
return response;
|
|
}
|
|
const responseClone = response.clone();
|
|
const responseText = await responseClone.text();
|
|
const detail = responseText.trim() || "request failed";
|
|
return Response.json(
|
|
{
|
|
error: {
|
|
message: `${method} ${requestUrl.pathname}${requestUrl.search}: ${detail}`,
|
|
},
|
|
},
|
|
{
|
|
headers: response.headers,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
}
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Build the Flue transport used by the browser client.
|
|
*
|
|
* The installed Flue SDK does not expose per-call headers on `agents.send`.
|
|
* Its custom `fetch` seam does receive the resolved URL and method, so request
|
|
* IDs are attached here to each agent admission POST. History and Durable
|
|
* Streams observation requests are GETs and intentionally remain untagged.
|
|
*/
|
|
export const createFlueFetch = ({
|
|
baseUrl,
|
|
fetchImpl = fetch,
|
|
generateRequestId = generateBrowserRequestId,
|
|
}: FlueFetchOptions): typeof fetch => {
|
|
const basePath = baseUrl.pathname.replace(/\/+$/u, "");
|
|
|
|
return async (input, init) => {
|
|
const inputUrl =
|
|
typeof input === "string" || input instanceof URL ? input : input.url;
|
|
const requestUrl = new URL(inputUrl, baseUrl);
|
|
const method = (
|
|
init?.method ?? (input instanceof Request ? input.method : "GET")
|
|
).toUpperCase();
|
|
const relativePath = requestUrl.pathname.startsWith(`${basePath}/`)
|
|
? requestUrl.pathname.slice(basePath.length)
|
|
: requestUrl.pathname;
|
|
const pathSegments = relativePath.split("/").filter(Boolean);
|
|
const isAgentAdmission =
|
|
method === "POST" &&
|
|
pathSegments.length === 3 &&
|
|
pathSegments[0] === "agents";
|
|
|
|
let requestInit = init;
|
|
if (isAgentAdmission) {
|
|
const headers = new Headers(
|
|
input instanceof Request ? input.headers : undefined
|
|
);
|
|
for (const [key, value] of new Headers(init?.headers).entries()) {
|
|
headers.set(key, value);
|
|
}
|
|
headers.set("x-zopu-request-id", generateRequestId());
|
|
requestInit = { ...init, headers };
|
|
}
|
|
|
|
const rawResponse = await fetchImpl.call(globalThis, input, requestInit);
|
|
const response = await addRequestContext(rawResponse, method, requestUrl);
|
|
if (!response.headers.get("content-type")?.includes("application/json")) {
|
|
return response;
|
|
}
|
|
|
|
const body = (await response.clone().json()) as unknown;
|
|
if (
|
|
typeof body !== "object" ||
|
|
body === null ||
|
|
!("streamUrl" in body) ||
|
|
typeof body.streamUrl !== "string"
|
|
) {
|
|
return response;
|
|
}
|
|
|
|
const streamUrl = new URL(body.streamUrl);
|
|
streamUrl.protocol = baseUrl.protocol;
|
|
streamUrl.host = baseUrl.host;
|
|
const headers = new Headers(response.headers);
|
|
headers.set("location", streamUrl.toString());
|
|
return Response.json(
|
|
{ ...body, streamUrl: streamUrl.toString() },
|
|
{
|
|
headers,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
}
|
|
);
|
|
};
|
|
};
|