
ããã«ã¡ã¯ãã«ããã·ã§ãã«ããã· è¨åä¿å ¨ããµã¼ãã¹ã®éçºãè¡ã£ã¦ããæ¾¤æ¨ã§ãã
ä»åã¯React Router v7ã§ReadableStreamãå©ç¨ãããã¼ã¿ã®é次表示ãå®è£ ããæ¹æ³ã«ã¤ãã¦ç´¹ä»ãã¾ãã
ç¾å¨ç§ãã¡ã®ãã¼ã ã§ã¯LLMã使ã£ãæ©è½éçºãè¡ã£ã¦ããã®ã§ãããLLMã¯çæå¦çã«æéããããããå ¨ã¦ã®ãã¼ã¿çæãå¾ ã£ã¦ãã表示ããã¨ã¦ã¼ã¶ã¼ä½é¨ãæªåãã¦ãã¾ãã¾ãã ãã®ããã¹ããªã¼ãã³ã°ã§é次ãã¼ã¿ãåãåããçæãããé¨åããå°ããã¤è¡¨ç¤ºãã¦ããã¨ããå®è£ ãä¸è¬çãã¨æãã¾ãã
React Router v7ã«ãã¹ããªã¼ãã³ã°æ©è½ã¯æä¾ããã¦ããã®ã§ãããããã¯ããã¾ã§é å»¶èªã¿è¾¼ã¿ãå®ç¾ããããã®ãã®ã§ãããã¼ã¿ã®ä¸é¨åãèªã¿åããªãã表示ãã¦ããã¦ã¼ã¹ã±ã¼ã¹ã«ã¯å¯¾å¿ãã¦ãã¾ããã
ããã§ä»åã¯React Router v7ã®ã¹ããªã¼ãã³ã°æ©è½ã¯ä½¿ãããReadableStream APIãç´æ¥ä½¿ç¨ãã¦ã¹ããªã¼ãã³ã°å¦çãå®è£ ãã¾ããã
React Router v7ã®ã¹ããªã¼ãã³ã°æ©è½ã«ã¤ãã¦
ReadableStreamã使ã£ãã¹ããªã¼ãã³ã°ã®å®è£ ã«å ¥ãåã«ãReact Router v7ã®ã¹ããªã¼ãã³ã°æ©è½ã«ã¤ãã¦ç°¡åã«èª¬æãã¾ãã
React Router v7ã§ã¯loader颿°ããæªè§£æ±ºã®Promiseãè¿ããã¨ã§ãPromiseã®è§£æ±ºãå¾ ããã«ã³ã³ãã¼ãã³ããã¬ã³ããªã³ã°ãããã¨ãã§ãã¾ããã³ã³ãã¼ãã³ãå´ã§ã¯Suspenseã§Promiseã®è§£æ±ºãå¾ ã¤ãã¨ã§ãèªã¿è¾¼ã¿ã«æéãããããã¼ã¿ãé å»¶èªã¿è¾¼ã¿ãããã¨ãå¯è½ã«ãªãã¾ãã
ããããã®æ©è½ã¯ããã¾ã§Promiseåä½ã§ã®é å»¶èªã¿è¾¼ã¿ã§ããããã£ã³ã¯ã§ãã¼ã¿ãå°ããã¤åãåãã¨ããã¦ã¼ã¹ã±ã¼ã¹ã«ã¯å¯¾å¿ãã¦ããªãããå¥ã®æ¹æ³ã§å®è£ ãè¡ãå¿ è¦ãããã¾ãã
å®è£ æ¹é
å®è£ å½åã¯React Router v7ã®æ¨æºçãªæ¹æ³ã«åãpageã«actionãå®è£ ãã¦Formããsubmitããå½¢ã§å®è£ ãããã¨ããã®ã§ãããuseActionDataã§åå¾ã§ãããã¼ã¿ã¯React Routerå´ã§JSONã¨ãã¦ãã¼ã¹ããã¦ãã¾ãããReadableStreamããã®ã¾ã¾æ±ããã¨ãã§ãã¾ããã§ãã
export const action = async ({ request }: LoaderFunctionArgs) => { // ReadableStreamãè¿ã return new Response(new ReadableStream({ ... })); }; export default function Page() { const actionData = useActionData(); // JSONã¨ãã¦ãã¼ã¹ããã¦ãã¾ã // ReadableStreamãæ±ããªãããã¹ããªã¼ãã³ã°è¡¨ç¤ºãã§ããªã }
ãã®ã¾ã¾ã§ã¯ReadableStreamãç´æ¥æ±ããã¨ãã§ããªããããpageã®actionã¨ãã¦å®è£ ããã®ã§ã¯ãªãResource Routesã§actionãå®è£ ããã¯ã©ã¤ã¢ã³ãå´ã§ã¯fetch APIã§Resource Routesãç´æ¥å¼ã³åºãå½¢ã§å®è£ ãè¡ãã¾ããã
Resource Routesã¯UIã³ã³ãã¼ãã³ããæããªãã«ã¼ãã¨ãã¦å®è£ ã§ãããããã¹ããªã¼ãã³ã°ã¬ã¹ãã³ã¹ãè¿ãAPIã¨ã³ããã¤ã³ãã¨ãã¦å©ç¨ãããã¨ãã§ãã¾ãã
actionã®å®è£
ä»åã¯ä¾ã¨ãã¦ãBedrockã¢ãã«ã®APIãå¼ã³åºãã¦ReadableStreamã¨ãã¦ã¬ã¹ãã³ã¹ãè¿ãaction颿°ãå®è£ ãã¾ãã
Bedrockã¢ãã«ãã¯ããã¨ããLLM APIã§ã¯ãã¹ããªã¼ãã³ã°å½¢å¼ã®ã¬ã¹ãã³ã¹ãAsyncIterableã¨ãã¦è¿ããããã¨ãå¤ãããããããReadableStreamã«å¤æãã¦è¿ãã¾ããBedrockã¢ãã«ã®InvokeModelWithResponseStreamCommandã§ã¯ãã£ã³ã¯æ¯ã«JSONå½¢å¼ã®ãã¼ã¿ãè¿ããããããã³ã³ãã³ãå
ã®ããã¹ãé¨åã ããæ½åºãã¦ã¯ã©ã¤ã¢ã³ãå´ã«è¿å´ããããã«ãã¦ãã¾ãã
ãã®actionã¯UIã³ã³ãã¼ãã³ããæããªãResource Routesã¨ãã¦/invoke-modelã«å®è£
ããfetch APIã§fetch("/invoke-model", { method: "POST" })ã®ããã«å¼ã³åºããããã«ãã¦ãã¾ãã
import { BedrockRuntimeClient, InvokeModelWithResponseStreamCommand, } from "@aws-sdk/client-bedrock-runtime"; import type { LoaderFunctionArgs } from "react-router"; export const action = async ({ request }: LoaderFunctionArgs) => { // ãªã¯ã¨ã¹ãã®åå¾ const formData = await request.formData(); const prompt = formData.get("prompt"); // bedrockã¢ãã«ã®invoke const client = new BedrockRuntimeClient({ region: "ap-northeast-1", }); const payload = { anthropic_version: "bedrock-2023-05-31", max_tokens: 1000, messages: [ { role: "user", content: [{ type: "text", text: prompt }], }, ], }; const command = new InvokeModelWithResponseStreamCommand({ contentType: "application/json", body: JSON.stringify(payload), modelId: "apac.anthropic.claude-sonnet-4-20250514-v1:0", }); const apiResponse = await client.send(command); // ReadableStreamã¨ãã¦ã¬ã¹ãã³ã¹ãè¿ã return new Response( new ReadableStream({ async start(controller) { // AsyncIterableãReadableStreamã«å¤æ for await (const item of apiResponse?.body ?? []) { const chunk = JSON.parse(new TextDecoder().decode(item.chunk?.bytes)); // çæå 容ã®ããã¹ãã®ã¿åãåºã if (chunk.type === "content_block_delta") { const text = chunk.delta.text; controller.enqueue(text); } } controller.close(); }, }), ); };
ã¯ã©ã¤ã¢ã³ãå´ã®å®è£
ã¯ã©ã¤ã¢ã³ãå´ã§ã¯fetch APIã使ã£ã¦actionãå¼ã³åºããReadableStreamãåãåã£ã¦ãã£ã³ã¯æ¯ã«ãã¼ã¿ãå¦çãã¾ããå
ã»ã©å®è£
ããactionã¯Resource Routesï¼/invoke-modelï¼ã¨ãã¦å®è£
ãã¦ãããããfetch APIã§ç´æ¥å¼ã³åºããã¨ãã§ãã¾ãã
èªã¿åºãå´ã§ã¯ReadableStreamããreaderãåå¾ãreader.read()ã§ã¹ããªã¼ã ãçµäºããã¾ã§ãã£ã³ã¯ã鿬¡èªã¿åããsetMessageã§ã¡ãã»ã¼ã¸ã«ãã£ã³ã¯ã®ããã¹ããé æ¬¡è¿½å ãã¦ããã¾ããmessageã®stateã«ã¯ããã¹ãã鿬¡è¿½å ããã¦ãããããUIä¸ã§ã¯çæãããé¨åããå°ããã¤è¡¨ç¤ºããã¦ããããã«ãªãã¾ãã
import { useCallback, useRef, useState } from "react"; export const useReadableStream = () => { const [message, setMessage] = useState(""); const submit = useCallback((formData: FormData) => { fetch("/invoke-model", { method: "POST", body: formData, }) .then(async (response) => { if (!response.ok || !response.body) { return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { // ãã£ã³ã¯ã®èªã¿åã const { done, value } = await reader.read(); if (done) { break; } // ãã£ã³ã¯ã®ãã³ã¼ãã¨stateã®æ´æ° const chunk = decoder.decode(value, { stream: true }); setMessage((prev) => prev + chunk); } }) .catch((error) => { console.error("Fetch error:", error); }); }, []); return { submit, message, }; }; function Sample() { const { submit, message } = useReadableStream(); return ( <div> <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); submit(formData); }} > <textarea name="prompt" /> <button type="submit">Submit</button> </form> <p>{message}</p> </div> ); }
ã¯ã©ã¤ã¢ã³ãå´ã§JSONå½¢å¼ã®ãã¼ã¿ãæ±ãå ´å
Bedrockã¢ãã«ã®ããã«ãã£ã³ã¯æ¯ã«JSONå½¢å¼ã§ãã¼ã¿ãè¿ãããå ´åï¼ã¾ãã¯ã¯ã©ã¤ã¢ã³ãå´ã«æ§é åããããã¼ã¿ãè¿ãããå ´åï¼ãããã¹ãã¨ãã¦ã§ã¯ãªãJSONã¨ãã¦ãã¼ã¹ãã¦æ±ãããã±ã¼ã¹ããããã¨æãã¾ãããã®ãããªå ´åã¯ãJSON Linesã®ããã«æ¹è¡åºåãã§JSONãè¿ããã¯ã©ã¤ã¢ã³ãå´ã§ã¯ãã£ã³ã¯ã®ããã¹ãããããã¡ã§ä¿æããªããæ¹è¡ã§åå²ãã¦ãã¼ã¹ããå®è£ ãå¿ è¦ã«ãªãã¾ãã
ä»åã®Bedrockã¢ãã«ã®ä¾ã§ã¯ãBedrockå´ããè¿ããããã£ã³ã¯ã1ã¤ã®JSONã¨ãªã£ã¦ãããããåãã£ã³ã¯æ¯ã«æ¹è¡æåã追å ãã¦ã¯ã©ã¤ã¢ã³ãã¸è¿å´ããã¯ã©ã¤ã¢ã³ãå´ã§ã¯JSONå½¢å¼ã§æ±ããããã«å®è£ ãè¡ãã¾ãã
import { BedrockRuntimeClient, InvokeModelWithResponseStreamCommand, } from "@aws-sdk/client-bedrock-runtime"; import { useCallback, useRef, useState } from "react"; import type { LoaderFunctionArgs } from "react-router"; export const action = async ({ request }: LoaderFunctionArgs) => { // ãªã¯ã¨ã¹ãã®åå¾ const formData = await request.formData(); const prompt = formData.get("prompt"); // bedrockã¢ãã«ã®invoke const client = new BedrockRuntimeClient({ region: "ap-northeast-1", }); const payload = { anthropic_version: "bedrock-2023-05-31", max_tokens: 1000, messages: [ { role: "user", content: [{ type: "text", text: prompt }], }, ], }; const command = new InvokeModelWithResponseStreamCommand({ contentType: "application/json", body: JSON.stringify(payload), modelId: "apac.anthropic.claude-sonnet-4-20250514-v1:0", }); const apiResponse = await client.send(command); // ã¨ã³ã³ã¼ãã¼ã¨ãã³ã¼ãã¼ã®åæå const encoder = new TextEncoder(); const decoder = new TextDecoder(); // ReadableStreamã¨ãã¦ã¬ã¹ãã³ã¹ãè¿ã return new Response( new ReadableStream({ async start(controller) { for await (const item of apiResponse?.body ?? []) { // ãã£ã³ã¯ãããã¹ãã¨ãã¦ãã³ã¼ã const json = decoder.decode(item.chunk?.bytes); // åãã£ã³ã¯ãã¨ã«æ¹è¡ã追å ãã¦è¿å´ controller.enqueue(encoder.encode(`${json}\n`)); } controller.close(); }, }), ); }; export const useReadableStream = () => { // JSONãã¼ã¿ãé åã§ä¿æ const [data, setData] = useState<any[]>([]); // ãã£ã³ã¯ã®ãããã¡ const buffer = useRef(""); const submit = useCallback((formData: FormData) => { fetch("/invoke-model", { method: "POST", body: formData, }) .then(async (response) => { if (!response.ok || !response.body) { return; } buffer.current = ""; const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) { // ã¹ããªã¼ã çµäºæã«æ®ã£ããããã¡ãå¦ç if (buffer.current.trim() !== "") { try { const json = JSON.parse(buffer.current); setData((prev) => [...prev, json]); } catch (e) { console.log("JSON parse error:", e); } } break; } // ãã£ã³ã¯ããããã¡ã«ä¿æ const chunk = decoder.decode(value, { stream: true }); buffer.current += chunk; // æ¹è¡ã§åå²ãã¦æçµè¡ã®ã¿ããããã¡ã«æ®ã const lines = buffer.current.split("\n"); buffer.current = lines.pop() || ""; // åè¡ãJSONã¨ãã¦ãã¼ã¹ for (const line of lines) { if (line.trim() === "") continue; try { const json = JSON.parse(line); setData((prev) => [...prev, json]); } catch (e) { console.error("JSON parse error:", e); } } } }) .catch((error) => { console.error("Fetch error:", error); }); }, []); return { submit, data, }; };
ãããã«
React Router v7ã§ReadableStreamã使ã£ãã¹ããªã¼ãã³ã°è¡¨ç¤ºã®å®è£ æ¹æ³ã«ã¤ãã¦ç´¹ä»ãã¾ããã
useActionDataã使ããªããªã©React Router v7ã®æ¨æºçãªä½¿ãæ¹ããã¯å°ãé¸è±ããå®è£ ã«ãªã£ã¦ãã¾ãããResource Routesãæ´»ç¨ãããã¨ã§é常ã®React Router v7ã®ã¢ã¼ããã¯ãã£ãã大ããå¤ãããã¨ãªãã¹ããªã¼ãã³ã°å¦çãå®è£ ã§ãããã¨æãã¾ãã
ä»åã¯LLMã®å¼ã³åºããä¾ã«æãã¾ããããã¹ããªã¼ãã³ã°å¦çã®å®è£ ãã¿ã¼ã³ã¯å¤§ããªãã¡ã¤ã«ã®ãã¦ã³ãã¼ãé²æè¡¨ç¤ºããªã¢ã«ã¿ã¤ã ãã¼ã¿ã®è¡¨ç¤ºãªã©ãæ§ã ãªã¦ã¼ã¹ã±ã¼ã¹ã«å¿ç¨ã§ãããã¨æãã¾ãã