はじめに
Giselleは、ROUTE06が開発するAIアプリビルダーです。ノードベースのビジュアルインターフェースを通じて、複雑なAIワークフローを直感的に構築できることが特徴です。ソースコードはgithub.com/giselles-ai/giselleでオープンソースとして公開しています。
本記事では、Giselleの技術スタックを詳しく解説します。モノレポ構成、フロントエンド設計、バックエンド実装、そしてマルチLLM対応アーキテクチャについて、実際のコードを交えながら紹介していきます。
これからAIプロダクトを開発する方や、モダンなTypeScriptプロジェクトの設計パターンに興味がある方の参考になれば幸いです。
技術スタック全体像
Giselleで採用している主要な技術を一覧でまとめます。
| カテゴリ | 技術 |
|---|---|
| ビルドシステム | Turborepo + pnpm |
| フレームワーク | Next.js + React |
| 言語 | TypeScript |
| スタイリング | Tailwind CSS |
| 状態管理 | Zustand |
| UIコンポーネント | Radix UI + Lucide React |
| ワークフローUI | XYFlow (React Flow) |
| リッチテキスト | TipTap |
| データベース | PostgreSQL + Drizzle ORM |
| ベクトル検索 | pgVector |
| 認証 | Supabase Auth |
| AI SDK | Vercel AI SDK |
| LLMプロバイダ | OpenAI / Anthropic / Google |
| 可観測性 | Langfuse |
| ホスティング | Vercel |
モノレポ構成
ワークスペース設計
Giselleは、Turborepo + pnpmによるモノレポ構成を採用しています。pnpm-workspace.yamlでは4つのワークスペースを定義しています。
packages:
- apps/* # アプリケーション
- internal-packages/* # 内部UIパッケージ
- packages/* # 公開SDKパッケージ
- tools/* # ビルドツール
packages/配下には26個のSDKパッケージがあり、それぞれが明確な責務を持っています。
packages/
├── protocol/ # コアデータ型定義
├── language-model/ # LLM抽象化レイヤー
├── language-model-registry/ # プロバイダ管理
├── langfuse/ # 可観測性統合
├── rag/ # RAG実装
├── stream/ # ストリーミング
├── text-editor/ # TipTapラッパー
├── github-tool/ # GitHub連携
└── ...
catalogによる依存関係の集中管理
pnpm workspaceのcatalog機能を活用し、すべての依存関係バージョンをpnpm-workspace.yamlで一元管理しています。
catalog:
'@ai-sdk/anthropic': 2.0.49
'@ai-sdk/google': 2.0.49
'@ai-sdk/openai': 2.0.72
'@xyflow/react': 12.9.0
drizzle-orm: 0.44.2
next: 16.0.10
react: 19.2.1
typescript: 5.7.3
zustand: 5.0.8
各パッケージのpackage.jsonではcatalog:プレフィックスで参照します。
{
"devDependencies": {
"@biomejs/biome": "catalog:",
"typescript": "catalog:"
}
}
この方式には以下のメリットがあります。
- バージョンの一貫性: モノレポ全体で同一バージョンを使用
- 更新の容易さ: 1箇所の変更で全パッケージに反映
- 依存関係の可視化: 使用ライブラリが一覧で把握可能
また、minimumReleaseAge: 1440(24時間)を設定し、リリース直後のパッケージの自動採用を防いでいます。セキュリティ修正が必要な場合はminimumReleaseAgeExcludeで個別に例外を設けます。
Turborepoによるビルド最適化
turbo.jsonでタスク間の依存関係を定義し、並列実行とキャッシュを活用しています。
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "out/**"]
},
"dev": {
"dependsOn": ["^build"],
"cache": false,
"persistent": true
},
"check-types": {
"dependsOn": ["^check-types"]
}
}
}
dependsOn: ["^build"]により、依存先パッケージのビルドが先に完了することを保証しつつ、独立したパッケージは並列でビルドされます。
ルートのpackage.jsonには以下のようなスクリプトが定義されています。
{
"scripts": {
"build": "turbo build",
"build-sdk": "turbo build --filter '@giselles-ai/*'",
"check-types": "turbo check-types",
"format": "biome check --write .",
"tidy": "knip --no-config-hints"
}
}
knipによる未使用依存の検出、biomeによるフォーマット・リントを組み合わせ、コード品質を維持しています。
フロントエンド設計
XYFlowによるビジュアルワークフロー
GiselleのワークフローエディタはXYFlow(React Flow)で構築されています。ノードベースのグラフUIを提供し、AIエージェント間の接続を視覚的に表現します。
import {
ReactFlow,
type Connection,
type Edge,
type OnNodesChange,
useReactFlow,
} from "@xyflow/react";
function V2NodeCanvas() {
const { nodes, connections } = useAppDesignerStore((s) => ({
nodes: s.nodes,
connections: s.connections,
}));
const nodeTypes = useMemo(
() => ({
card: CardXyFlowNode,
pill: PillXyFlowNode,
}),
[],
);
const handleConnect = useCallback((connection: Connection) => {
const outputNode = nodes.find((n) => n.id === connection.source);
const inputNode = nodes.find((n) => n.id === connection.target);
const supported = isSupportedConnection(outputNode, inputNode);
if (!supported.canConnect) {
toast.error(supported.message);
return;
}
connectNodes(outputNode.id, inputNode.id);
}, [nodes, connectNodes]);
return (
<ReactFlow
nodes={reactFlowNodes}
edges={edges}
nodeTypes={nodeTypes}
onConnect={handleConnect}
colorMode="dark"
>
<Background />
<Panel position="bottom-center">
<Toolbar />
</Panel>
</ReactFlow>
);
}
XYFlowの採用理由は以下の通りです。
- 成熟したエコシステム: 多くの実績とコミュニティ
- カスタマイズ性: ノードタイプ、エッジスタイルを完全制御可能
- パフォーマンス: 仮想化による大規模グラフのサポート
TipTapによるリッチテキストエディタ
AIエージェントへの指示入力には、TipTapを採用しています。プレーンテキストではなく、リッチテキストとメンション機能を組み合わせることで、他ノードの出力を参照する直感的なUIを実現しています。
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import { EditorProvider, useCurrentEditor } from "@tiptap/react";
export function TextEditor({
value,
onValueChange,
connections,
placeholder,
}) {
const extensions = useMemo(() => {
const mentionExtension = Mention.configure({
suggestion: createSuggestion(connections),
});
return [
...baseExtensions,
SourceExtensionReact.configure({ nodes }),
mentionExtension,
Placeholder.configure({ placeholder }),
];
}, [connections, placeholder]);
return (
<EditorProvider
extensions={extensions}
content={value ? JSON.parse(value) : undefined}
onUpdate={(p) => {
onValueChange?.(JSON.stringify(p.editor.getJSON()));
}}
/>
);
}
TipTapを選んだ理由は以下です。
- 拡張性: Mention、Placeholderなど豊富な拡張機能
- ProseMirror基盤: 堅牢なドキュメントモデル
- JSON出力: 構造化データとして保存・処理が容易
Zustandによる状態管理
グローバル状態の管理にはZustandを採用しています。Reduxと比較してボイラープレートが少なく、TypeScriptとの親和性が高い点が決め手でした。
import { create } from "zustand";
type TaskOverlayState = {
isVisible: boolean;
overlayApp: OverlayAppSummary | null;
overlayInput: GenerationContextInput | null;
showOverlay: (payload: {
app: OverlayAppSummary;
input?: GenerationContextInput | null;
}) => void;
hideOverlay: () => void;
};
export const useTaskOverlayStore = create<TaskOverlayState>((set) => ({
isVisible: false,
overlayApp: null,
overlayInput: null,
showOverlay: ({ app, input = null }) =>
set({
isVisible: true,
overlayApp: app,
overlayInput: input,
}),
hideOverlay: () => set({ isVisible: false, overlayApp: null, overlayInput: null }),
}));
コンポーネントからはuseShallowと組み合わせて必要な部分だけを購読します。
const { nodes, connections } = useAppDesignerStore(
useShallow((s) => ({
nodes: s.nodes,
connections: s.connections,
}))
);
バックエンド設計
Drizzle ORMによる型安全なDB操作
データベースアクセスにはDrizzle ORMを採用しています。TypeScriptファーストの設計で、スキーマ定義からクエリまで一貫した型安全性を提供します。
import {
pgTable,
serial,
text,
timestamp,
integer,
index,
unique,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
export const teams = pgTable("teams", {
id: text("id").$type<TeamId>().notNull().unique(),
dbId: serial("db_id").primaryKey(),
name: text("name").notNull(),
plan: text("plan").$type<TeamPlan>().notNull().default("free"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.notNull()
.$onUpdate(() => new Date()),
});
export const teamRelations = relations(teams, ({ many }) => ({
apps: many(apps),
tasks: many(tasks),
}));
export const workspaces = pgTable("workspaces", {
id: text("id").$type<WorkspaceId>().notNull().unique(),
dbId: serial("db_id").primaryKey(),
name: text("name"),
teamDbId: integer("team_db_id")
.notNull()
.references(() => teams.dbId, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
$type<T>()によるブランド型の適用で、IDの取り違えをコンパイル時に防止できます。TeamIdとWorkspaceIdは別の型として扱われるため、誤った代入はエラーになります。
pgVectorによるベクトル埋め込み検索
RAG(Retrieval Augmented Generation)機能のため、PostgreSQLのpgVector拡張を活用しています。ドキュメントやGitHubリポジトリのコンテンツをベクトル化し、類似検索を実現します。
import { vectorWithoutDimensions } from "./custom-types";
import { sql } from "drizzle-orm";
export const documentEmbeddings = pgTable(
"document_embeddings",
{
dbId: serial("db_id").primaryKey(),
documentVectorStoreDbId: integer("document_vector_store_db_id")
.notNull()
.references(() => documentVectorStores.dbId, { onDelete: "cascade" }),
embeddingProfileId: integer("embedding_profile_id")
.$type<EmbeddingProfileId>()
.notNull(),
embeddingDimensions: integer("embedding_dimensions")
.$type<EmbeddingDimensions>()
.notNull(),
chunkContent: text("chunk_content").notNull(),
embedding: vectorWithoutDimensions("embedding").notNull(),
},
(table) => [
// 1536次元用のHNSWインデックス(OpenAI text-embedding-3-small)
index("doc_embs_embedding_1536_idx")
.using("hnsw", sql`(${table.embedding}::vector(1536)) vector_cosine_ops`)
.where(sql`${table.embeddingDimensions} = 1536`),
// 3072次元用のHNSWインデックス(OpenAI text-embedding-3-large)
index("doc_embs_embedding_3072_idx")
.using("hnsw", sql`(${table.embedding}::halfvec(3072)) halfvec_cosine_ops`)
.where(sql`${table.embeddingDimensions} = 3072`),
],
);
部分インデックスを活用し、埋め込み次元ごとに最適化されたHNSWインデックスを作成しています。3072次元にはhalfvec(半精度浮動小数点)を使用し、ストレージ効率とパフォーマンスのバランスを取っています。
マルチLLM対応アーキテクチャ
Language Model Registry
Giselleの差別化ポイントの一つが、複数のLLMプロバイダを統一的なインターフェースで扱える設計です。@giselles-ai/language-model-registryパッケージで、各プロバイダのモデルを定義・管理しています。現在Private Previewとして提供しており、近日中にPublicリリースを予定しています。
// packages/language-model-registry/src/language-models.ts
import { anthropic } from "./anthropic";
import { google } from "./google";
import { openai } from "./openai";
export const languageModels = [
...Object.values(openai),
...Object.values(anthropic),
...Object.values(google),
];
export function getEntry(languageModelId: LanguageModelId) {
const languageModel = languageModels.find(
(model) => model.id === languageModelId
);
if (!languageModel) {
throw new Error(`Language model with ID ${languageModelId} not found`);
}
return languageModel;
}
各プロバイダのモデル定義は、defineLanguageModel関数で統一的に記述します。
// packages/language-model-registry/src/anthropic.ts
import * as z from "zod/v4";
import { defineLanguageModel, definePricing } from "./language-model";
const anthropicProvider = {
id: "anthropic",
title: "Anthropic",
metadata: {
website: "https://www.anthropic.com/",
documentationUrl: "https://platform.claude.com/docs/en/api/overview",
},
} as const;
export const anthropic = {
"anthropic/claude-sonnet-4.5": defineLanguageModel({
provider: anthropicProvider,
id: "anthropic/claude-sonnet-4.5",
name: "Claude Sonnet 4.5",
description: "...",
contextWindow: 200_000,
maxOutputTokens: 64_000,
pricing: {
input: definePricing(3.0),
output: definePricing(15.0),
},
requiredTier: "pro",
configurationOptions: {
temperature: {
description: "Amount of randomness injected into the response.",
schema: z.number().min(0).max(1),
ui: { min: 0.0, max: 1.0, step: 0.1 },
},
thinking: {
description: "Whether to include reasoning text in the response.",
schema: z.boolean(),
},
},
defaultConfiguration: {
temperature: 1.0,
thinking: false,
},
}),
// 他のモデル...
};
この設計により、新しいモデルやプロバイダの追加が容易になります。Zodスキーマで設定オプションを定義することで、UIの動的生成と入力バリデーションを両立しています。
Langfuseによる可観測性
LLMアプリケーションの運用において、可観測性は重要です。GiselleではLangfuseを統合し、すべてのLLM呼び出しをトレースしています。
// packages/langfuse/src/trace-generation.ts
import { Langfuse } from "langfuse";
import { calculateDisplayCost } from "@giselles-ai/language-model";
export async function traceGeneration(args: {
generation: CompletedGeneration | FailedGeneration;
inputMessages: ModelMessage[];
userId?: string;
}) {
const langfuse = new Langfuse();
const trace = langfuse.trace({
name: "generation",
userId: args.userId,
input: await prepareLangfuseInput(args.inputMessages),
});
const usage = args.generation.usage ?? {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
};
const cost = await calculateDisplayCost(
llm.provider,
llm.id,
{ inputTokens: usage.inputTokens, outputTokens: usage.outputTokens }
);
trace.generation({
name: "generateText",
model: llm.id,
input: langfuseInput,
output: args.generation.outputs,
usage: {
unit: "TOKENS",
input: usage.inputTokens,
output: usage.outputTokens,
inputCost: cost.inputCostForDisplay,
outputCost: cost.outputCostForDisplay,
},
startTime: new Date(args.generation.startedAt),
endTime: new Date(args.generation.completedAt),
});
await langfuse.flushAsync();
}
トレースには以下の情報が記録されます。
- 入出力: プロンプトとレスポンスの全文
- トークン使用量: 入力・出力トークン数
- コスト: モデルごとの料金計算結果
- レイテンシ: 開始・終了時刻
- メタデータ: 使用ツール、エラー情報など
プロバイダごとの機能(Anthropicのthinking、Googleのsearch grounding等)もタグとして記録され、後から分析可能です。
まとめ
Giselleの技術スタックについて解説しました。主なポイントを振り返ります。
モノレポ構成
- Turborepo + pnpmによる効率的なビルド
- catalogによる依存関係の一元管理
- 26個のパッケージによる責務の分離
フロントエンド
- XYFlowによるノードベースUI
- TipTapによるリッチテキスト入力
- Zustandによるシンプルな状態管理
バックエンド
- Drizzle ORMによる型安全なDB操作
- pgVectorによるベクトル検索
AI/LLM
- Language Model Registryによるマルチプロバイダ対応
- Langfuseによる可観測性確保
GiselleはApache License 2.0のオープンソースプロジェクトです。コードはGitHubで公開されています。Issueやプルリクエストによる貢献も歓迎します。
本日、Product Hunt でローンチ 🚀
本日、Product Hunt でローンチしました!
2025年12月29日(月)17時から24時間、ぜひご覧いただき、応援よろしくお願いします!