はじめに
源内(GenAI)は、デジタル庁が開発・運用する生成 AI 利活用基盤です。行政職員が業務特化の生成 AI アプリケーションを、迅速かつ安全かつ簡単に利用できる環境を提供します。
digital-go-jp/genai-web は、その「源内 Web」のフロントエンドです。
AWS の Generative AI Use Cases をベースにしつつ、チーム管理、AI アプリ管理、外部 AI アプリ実行、デジタル庁デザインシステムなどが追加されています。
ただ、元の構成は AWS 前提です。
- Cognito / Amplify による認証
- Lambda response stream による推論
- DynamoDB によるチャット履歴保存
- S3 署名 URL によるファイルアップロード
- Bedrock / SageMaker のモデル一覧
- CDK / CloudFormation によるデプロイ
今回は、この genai-web を AWS なしでローカル実行できるようにしました。
改修後のリポジトリはこちらです。
注意:この記事では「CDK や AWS 関連コードをすべて物理削除する」のではなく、ローカル実行パスから Cloud 依存を外すことを目的にしています。既存コードとの差分を小さくするため、Cloud 用のコードは lazy import / route 制御で残しています。
まず結論
ローカル化でやったことは、主にこの 5 つです。
-
VITE_APP_LOCAL_MODE=trueを追加し、Cloud mode と Local mode を分ける - Amplify / Cognito 認証をローカルではスキップする
- Lambda streaming をローカル HTTP API の
/predict/streamに差し替える - DynamoDB 相当の保存先を SQLite に置き換える
- Bedrock / SageMaker ではなく OpenAI-compatible endpoint を使う
構成は以下のようになります。
Browser
|
| Vite / React
v
genai-web local mode
|
| http://127.0.0.1:8787
v
Local API
| \
| \-- SQLite: chat history / system context
|
| OpenAI-compatible /v1/chat/completions
v
Ollama / LM Studio / vLLM / other local model server
1. 起動方法
前提条件
- Node.js 22 系
- OpenAI 互換の
/v1/chat/completionsAPI を持つローカルモデルサーバー - 例:Ollama
Ollama を使う場合は、例えば以下のようにモデルを用意します。
ollama pull gemma4:e4b
ollama serve
次に、別ターミナルで genai-web-local を起動します。
git clone https://github.com/engchina/genai-web-local.git
cd genai-web-local
npm install
npm run local:dev
デフォルトでは以下で起動します。
| 項目 | デフォルト |
|---|---|
| Web | http://localhost:5173/ |
| Local API | http://127.0.0.1:8787 |
| Model API | http://127.0.0.1:11434/v1 |
| Model | gemma4:e4b |
| Data directory | .local-data |
モデルやポートを変える場合は、環境変数で指定できます。
LOCAL_API_PORT=8787 \
LOCAL_OPENAI_BASE_URL=http://127.0.0.1:11434/v1 \
LOCAL_OPENAI_API_KEY=ollama \
LOCAL_CHAT_MODEL=gemma4:e4b \
LOCAL_DATA_DIR=.local-data \
npm run local:dev
2. local:dev で Cloud 用環境変数をローカル値に差し替える
まず、ルートの package.json にローカル起動用の script を追加しました。
{
"scripts": {
"local:dev": "node scripts/local-dev.mjs",
"local:api:test": "npm -w packages/local-api run test",
"local:api:typecheck": "npm -w packages/local-api run typecheck"
}
}
scripts/local-dev.mjs では、Web と Local API を同時に起動します。
ここで重要なのは、AWS 用の環境変数を空またはローカル値に置き換えている点です。
const env = {
...process.env,
LOCAL_API_PORT: port,
LOCAL_OPENAI_BASE_URL: process.env.LOCAL_OPENAI_BASE_URL ?? 'http://127.0.0.1:11434/v1',
LOCAL_OPENAI_API_KEY: process.env.LOCAL_OPENAI_API_KEY ?? 'ollama',
LOCAL_CHAT_MODEL: firstLocalModelId,
LOCAL_DATA_DIR: process.env.LOCAL_DATA_DIR ?? defaultDataDir,
LOCAL_USER_ID: process.env.LOCAL_USER_ID ?? 'local-user',
VITE_APP_LOCAL_MODE: 'true',
VITE_APP_API_ENDPOINT: `http://127.0.0.1:${port}`,
VITE_APP_TEAM_ACCESS_CONTROL_API_ENDPOINT: `http://127.0.0.1:${port}`,
VITE_APP_REGION: 'local',
VITE_APP_USER_POOL_ID: '',
VITE_APP_USER_POOL_CLIENT_ID: '',
VITE_APP_IDENTITY_POOL_ID: '',
VITE_APP_PREDICT_STREAM_FUNCTION_ARN: '',
VITE_APP_MODEL_REGION: 'local',
VITE_APP_MODEL_IDS: '[]',
VITE_APP_LOCAL_MODEL_IDS: localModelIds,
VITE_APP_IMAGE_MODEL_IDS: '[]',
VITE_APP_ENDPOINT_NAMES: '[]',
VITE_APP_SAMLAUTH_ENABLED: 'false',
VITE_APP_HIDDEN_USE_CASES: JSON.stringify({ image: true }),
};
つまり、既存フロントエンドから見ると「API endpoint は存在する」が、その先は AWS ではなく localhost:8787 になります。
3. Local mode の判定を 1 箇所にまとめる
フロント側には、ローカルモード判定用の小さな utility を追加しました。
// packages/web/src/utils/localMode.ts
export const isLocalMode = import.meta.env.VITE_APP_LOCAL_MODE === 'true';
この 1 行を各所で使い、Cloud 依存が必要な処理だけを回避します。
ポイントは、各コンポーネントで環境変数を直接読まないことです。
4. Amplify / Cognito 認証をローカルではスキップする
元のコードでは、アプリ全体が Amplify UI の Authenticator.Provider に包まれていました。
ローカル版では、Cloud mode の時だけ Amplify を読み込みます。
// packages/web/src/main.tsx
const CloudAuthWrapper = lazy(async () => {
await import('@aws-amplify/ui-react/styles.css');
const [{ Authenticator }, { AuthWithSAML }, { AuthWithUserpool }] = await Promise.all([
import('@aws-amplify/ui-react'),
import('@/components/auth/AuthWithSAML'),
import('@/components/auth/AuthWithUserpool'),
]);
return {
default: ({ children }: { children: ReactNode }) => (
<Authenticator.Provider>
{samlAuthEnabled ? (
<AuthWithSAML>{children}</AuthWithSAML>
) : (
<AuthWithUserpool>{children}</AuthWithUserpool>
)}
</Authenticator.Provider>
),
};
});
const AuthWrapper = ({ children }: { children: ReactNode }) => {
if (isLocalMode) {
return <>{children}</>;
}
return (
<Suspense fallback={null}>
<CloudAuthWrapper>{children}</CloudAuthWrapper>
</Suspense>
);
};
useAuth もローカルでは Cognito へ行かず、最低限の session shape だけ返します。
// packages/web/src/hooks/useAuth.ts
export const useAuth = () => {
return useSWR('user', async () => {
if (isLocalMode) {
return {
tokens: {
accessToken: {
payload: {
'cognito:groups': [],
},
},
},
};
}
const { fetchAuthSession } = await import('aws-amplify/auth');
return fetchAuthSession();
});
};
この改修で、ローカル起動時に Cognito User Pool、Identity Pool、SAML 設定が不要になります。
5. API fetcher から認証ヘッダー依存を外す
API 呼び出し部分も、Local mode では Authorization: Bearer ... を付けないようにしました。
// packages/web/src/lib/fetcher.ts
const getAuthHeaders = async (hasBody: boolean): Promise<Record<string, string>> => {
const headers: Record<string, string> = {};
if (hasBody) {
headers['Content-Type'] = 'application/json';
}
if (isLocalMode) {
return headers;
}
const { fetchAuthSession } = await import('aws-amplify/auth');
const token = (await fetchAuthSession()).tokens?.idToken?.toString();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
};
さらに、streaming 用に postStream を追加しています。
const rawRequest = async (
method: string,
path: string,
body?: unknown,
options?: RequestOptions,
): Promise<Response> => {
const url = buildUrl(baseURL, path, options?.params);
const authHeaders = await getAuthHeaders(body !== undefined);
const res = await fetch(url, {
method,
headers: { ...authHeaders, ...options?.headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const errorData = await parseResponseBody<unknown>(res);
throw new ApiError(res.status, errorData);
}
return res;
};
return {
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>('POST', path, body, options),
postStream: (path: string, body?: unknown, options?: RequestOptions) =>
rawRequest('POST', path, body, options),
};
これで、通常 API と streaming API の両方をローカル API に向けられます。
6. Lambda response stream を Local API に差し替える
元の predictStream は AWS Lambda の response stream を使います。
ローカルでは Lambda を使わず、/predict/stream に POST します。
// packages/web/src/lib/chatApi.ts
export async function* predictStream(req: PredictRequest) {
if (isLocalMode) {
const res = await genUApi.postStream('predict/stream', req);
if (!res.body) {
throw new Error('ローカル API からストリームを取得できませんでした。');
}
const reader = res.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
buffer += decoder.decode();
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line) {
yield `${line}\n`;
}
}
}
if (buffer.trim()) {
yield `${buffer}\n`;
}
return;
}
const { InvokeWithResponseStreamCommand, LambdaClient } = await import(
'@aws-sdk/client-lambda'
);
const { fromCognitoIdentityPool } = await import('@aws-sdk/credential-providers');
const { fetchAuthSession } = await import('aws-amplify/auth');
// Cloud mode の既存処理
}
ここで大事なのは、AWS SDK の import も Cloud mode 側に寄せていることです。
Local mode では、Lambda Client も Cognito credential provider も読み込まれません。
7. Bedrock / SageMaker のモデル一覧を OpenAI-compatible に置き換える
型定義では、新しく openai-compatible を追加しました。
// packages/types/src/message.d.ts
export type Model = {
type: 'bedrock' | 'sagemaker' | 'openai-compatible';
modelId: string;
sessionId?: string;
};
フロントの model registry も、Local mode では VITE_APP_LOCAL_MODEL_IDS を使います。
// packages/web/src/models.ts
const localModelIds = parseStringArray(import.meta.env.VITE_APP_LOCAL_MODEL_IDS, [
'gemma4:e4b',
]);
const localModelMetadata: Record<string, ModelMetadata> = Object.fromEntries(
localModelIds.map((modelId) => [
modelId,
{
flags: { text: true },
displayName: modelId,
},
]),
);
const bedrockModelIds: string[] = isLocalMode
? []
: parseStringArray(import.meta.env.VITE_APP_MODEL_IDS);
const textModels = [
...(isLocalMode
? localModelIds.map((name) => ({ modelId: name, type: 'openai-compatible' }) as Model)
: []),
...bedrockModelIds.map((name) => ({ modelId: name, type: 'bedrock' }) as Model),
...endpointNames.map((name) => ({ modelId: name, type: 'sagemaker' }) as Model),
];
これで、モデル選択 UI は Bedrock ではなくローカルモデルを表示できます。
8. ローカル API を追加する
新しく packages/local-api を追加しました。
依存を増やさないため、Node.js の標準 http と node:sqlite を使っています。
// packages/local-api/src/server.ts
const start = () => {
const config = loadConfig();
const server = createLocalApiServer(config);
server.listen(config.port, () => {
console.log(`Local API: http://127.0.0.1:${config.port}`);
console.log(`Local data: ${config.dataDir}`);
console.log(`Model endpoint: ${config.openAIBaseUrl}`);
console.log(`Default model: ${config.defaultModel}`);
});
};
設定は環境変数から読みます。
// packages/local-api/src/config.ts
export const loadConfig = (
env: NodeJS.ProcessEnv = process.env,
cwd: string = process.cwd(),
): LocalApiConfig => {
const dataDir = path.resolve(cwd, env.LOCAL_DATA_DIR ?? '.local-data');
const defaultModel = env.LOCAL_CHAT_MODEL ?? 'gemma4:e4b';
return {
port: parsePort(env.LOCAL_API_PORT),
dataDir,
dbPath: env.LOCAL_DB_PATH ?? path.join(dataDir, 'local-api.sqlite'),
openAIBaseUrl: trimTrailingSlash(env.LOCAL_OPENAI_BASE_URL ?? 'http://127.0.0.1:11434/v1'),
openAIApiKey: env.LOCAL_OPENAI_API_KEY ?? 'ollama',
defaultModel,
userId: env.LOCAL_USER_ID ?? 'local-user',
};
};
9. OpenAI-compatible endpoint に変換する
Local API は、フロントから来た PredictRequest を OpenAI-compatible の /chat/completions に変換します。
// packages/local-api/src/openai.ts
const chatCompletionsUrl = (config: LocalApiConfig): string =>
`${config.openAIBaseUrl}/chat/completions`;
const toOpenAIMessages = (messages: UnrecordedMessage[]): OpenAIMessage[] =>
messages
.filter((message): message is UnrecordedMessage & { role: OpenAIMessage['role'] } =>
['system', 'user', 'assistant'].includes(message.role),
)
.map((message) => ({
role: message.role,
content: message.content,
}));
export const invokeOpenAICompatible = async (
config: LocalApiConfig,
messages: UnrecordedMessage[],
model?: Model,
): Promise<string> => {
const response = await fetch(chatCompletionsUrl(config), {
method: 'POST',
headers: headers(config),
body: JSON.stringify({
model: model?.modelId || config.defaultModel,
messages: toOpenAIMessages(messages),
stream: false,
}),
});
const body = (await response.json()) as OpenAIChatCompletion;
return body.choices?.[0]?.message?.content ?? '';
};
streaming の場合は、OpenAI 互換 SSE を読み、フロントが期待する NDJSON に変換します。
export async function* parseOpenAICompatibleStream(
stream: ReadableStream<Uint8Array>,
): AsyncGenerator<string> {
const reader = stream.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
buffer += decoder.decode();
if (buffer) {
const content = parseStreamLine(buffer);
if (content) {
yield content;
}
}
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? '';
for (const line of lines) {
const content = parseStreamLine(line);
if (content) {
yield content;
}
}
}
}
10. DynamoDB 互換の保存形を SQLite に置き換える
チャット履歴やシステムプロンプトは、DynamoDB ではなく SQLite に保存します。
ただし、フロント側への返却 shape は既存に寄せています。
// packages/local-api/src/database.ts
this.db.exec(`
CREATE TABLE IF NOT EXISTS chats (
id TEXT NOT NULL,
createdDate TEXT NOT NULL,
chatId TEXT NOT NULL,
usecase TEXT NOT NULL,
title TEXT NOT NULL,
updatedDate TEXT NOT NULL,
expire_at INTEGER NOT NULL,
PRIMARY KEY (id, createdDate)
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT NOT NULL,
createdDate TEXT NOT NULL,
messageId TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
trace TEXT,
extraData TEXT,
userId TEXT NOT NULL,
feedback TEXT NOT NULL,
usecase TEXT NOT NULL,
llmType TEXT,
expire_at INTEGER NOT NULL,
PRIMARY KEY (id, createdDate)
);
CREATE TABLE IF NOT EXISTS system_contexts (
id TEXT NOT NULL,
createdDate TEXT NOT NULL,
systemContextId TEXT NOT NULL,
systemContext TEXT NOT NULL,
systemContextTitle TEXT NOT NULL,
expire_at INTEGER NOT NULL,
PRIMARY KEY (id, createdDate)
);
`);
例えば chat 作成は、このように DynamoDB 風の key 形式を維持しています。
createChat(userId: string, usecase = ''): Chat {
const createdDate = `${Date.now()}`;
const chat: ChatRow = {
id: `user#${userId}`,
createdDate,
chatId: `chat#${randomUUID()}`,
usecase,
title: '',
updatedDate: '',
expire_at: expiresAt(),
};
this.db
.prepare(
`INSERT INTO chats (id, createdDate, chatId, usecase, title, updatedDate, expire_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(
chat.id,
chat.createdDate,
chat.chatId,
chat.usecase,
chat.title,
chat.updatedDate,
chat.expire_at,
);
return chat;
}
この形にしておくと、フロント側の変更範囲をかなり小さくできます。
11. Local API の routing
ローカル API 側では、まず最小限必要な API だけ実装しています。
// packages/local-api/src/http.ts
if (method(req) === 'POST' && url.pathname === '/predict/stream') {
await streamPredict(config, req, res);
return;
}
if (method(req) === 'POST' && url.pathname === '/predict') {
const predictRequest = await readJson<PredictRequest>(req);
const text = await invokeOpenAICompatible(
config,
predictRequest.messages,
predictRequest.model,
);
sendJson(res, 200, text);
return;
}
if (method(req) === 'POST' && url.pathname === '/predict/title') {
const body = await readJson<{ prompt?: string; model?: Model }>(req);
const title = await predictTitle(config, body.prompt ?? '', body.model);
sendJson(res, 200, title);
return;
}
チャット履歴も同じ API パスを使います。
if (segments[0] === 'chats') {
if (segments.length === 1 && method(req) === 'POST') {
const body = await readJson<JsonRecord>(req);
const usecase = typeof body.usecase === 'string' ? body.usecase : '';
sendJson(res, 200, { chat: repository.createChat(userId, usecase) });
return;
}
if (segments.length === 3 && segments[2] === 'messages' && method(req) === 'POST') {
const body = await readJson<{ messages?: ToBeRecordedMessage[] }>(req);
const messages = repository.batchCreateMessages(body.messages ?? [], userId, segments[1]);
sendJson(res, 200, { messages });
return;
}
}
12. Cloud 前提の画面を Local mode では隠す
ローカル版 v1 では、テキストチャットを中心にしました。
そのため、以下は Local mode では表示しません。
- AI アプリ管理
- チーム管理
- 画像生成
- 文字起こし
- ファイルアップロード
route 側では、Local mode の時に Cloud 前提 route を除外しています。
// packages/web/src/routes.tsx
!isLocalMode
? {
path: 'apps',
element: lazyElement(<ExAppsPage />),
}
: null,
!isLocalMode && isUseCaseEnabled('image')
? { path: 'image', element: lazyElement(<GenerateImagePage />) }
: null,
!isLocalMode ? { path: 'transcribe', element: lazyElement(<TranscribePage />) } : null,
!isLocalMode ? { path: 'teams', element: lazyElement(<TeamsPage />) } : null,
Header からも AI アプリへのリンクを隠します。
// packages/web/src/components/ui/Header.tsx
{!isLocalMode && (
<li>
<GlobalMenuLink to='/apps'>AIアプリ</GlobalMenuLink>
</li>
)}
ファイルアップロードも Local mode では無効化しています。
// packages/web/src/features/chat/hooks/useFileUploadable.ts
const accept = useMemo(() => {
if (isLocalMode) {
return [];
}
if (!modelId) {
return [];
}
// Cloud mode の既存処理
}, [modelId]);
ここは無理にローカル実装しない方が安全でした。
S3 署名 URL 前提の file flow を雑に置き換えると、保存、削除、履歴、サイズ制限、MIME 判定までまとめて壊れやすいからです。
13. ローカル版で残した機能、外した機能
今回のローカル版 v1 は、以下の方針にしています。
| 機能 | ローカル版 v1 |
|---|---|
| テキストチャット | 対応 |
| チャット履歴 | SQLite で対応 |
| システムプロンプト保存 | SQLite で対応 |
| タイトル自動生成 | OpenAI-compatible endpoint で対応 |
| ストリーミング応答 | Local API で対応 |
| Cognito 認証 | スキップ |
| DynamoDB | SQLite に置き換え |
| Lambda response stream | Local API に置き換え |
| Bedrock / SageMaker | OpenAI-compatible endpoint に置き換え |
| S3 ファイルアップロード | v1 では無効化 |
| AI アプリ / チーム管理 | v1 では非表示 |
| 画像生成 / 文字起こし | v1 では非表示 |
14. テスト
Local API 側は、以下で確認できます。
npm run local:api:test
今回の確認では、以下が通りました。
Test Files 3 passed (3)
Tests 6 passed (6)
Web 側は、local mode に関係する最小テストを確認しました。
npm -w packages/web run test -- tests/lib/fetcher.test.ts tests/features/chat/hooks/useFileUploadable.test.tsx
結果は以下です。
Test Files 2 passed (2)
Tests 3 passed (3)
node:sqlite を使っているため、Node.js 22 系では以下の warning が出る場合があります。
ExperimentalWarning: SQLite is an experimental feature and might change at any time
これは現時点では想定内です。
15. 重要なポイント
今回の改修で一番大事だったのは、「Cloud 依存を一気に消す」のではなく、「Cloud 依存が必要な経路を local mode から通さない」ことです。
特に効いたのは以下です。
-
VITE_APP_LOCAL_MODEで分岐点を明確にする - Amplify / AWS SDK は static import せず、Cloud mode 側で dynamic import する
- API path はなるべく既存と同じにして、フロントの変更を小さくする
- DynamoDB の item shape に寄せて SQLite に保存する
- S3 / Teams / AI Apps など、v1 で安全に移植できないものは無理に有効化しない
この方針にすると、元の genai-web の構造を大きく壊さずに、ローカル LLM で動く開発用 Web UI にできます。
まとめ
genai-web は元々 AWS 前提の構成ですが、フロントエンドの API 境界をうまく使えば、ローカル LLM 向けにも動かせます。
今回の genai-web-local では、
- 認証は local mode でスキップ
- 推論は Local API 経由で OpenAI-compatible endpoint に接続
- 履歴は SQLite に保存
- Cloud 専用 UI は route と navigation から除外
という形にしました。
個人的には、この手のローカル化では「全部を移植する」よりも、まずテキストチャットを安定して動かす方が大事だと思います。
動く中心線を作ってから、ファイル処理、画像生成、AI アプリ連携などを順番に戻していく方が、あとで壊れにくいです。