対象読者と動作環境
チーム用ワークスペース「Kurari」の AI 機能を、コードを引用しながら実装レベルで解説します。対象読者は次のような人です。
- Kotlin か TypeScript のどちらかは普段書いていて、もう一方も読める人
- サーバー側で LLM の API キーを持たずに AI 機能を提供する構成に興味がある人
- ジョブキュー・ポーリング・WebSocket 通知を組み合わせた実装例を見たい人
Kurari はホワイトボード・ドキュメント・チャット・AI 出力を1つの画面に統合するチーム用ワークスペースです。この記事では「なぜこの構成にしたか」には触れません。設計思想は Zenn 側の記事に譲り、ここでは「どう実装したか」だけを追います。
動作環境(2026-08-02 時点、github.com/tanishi-z/Kurari)は次のとおりです。
| コンポーネント | スタック |
|---|---|
| backend | Spring Boot 3.5.16 + Kotlin 2.1.21(JVM 21) |
| agent | Node.js v24 + tsx 4.19(ランタイム依存パッケージはゼロ。package.json の dependencies は空で、tsx/typescript は devDependencies) |
| frontend | Vite + React + TypeScript |
規模はおおよそ frontend 11,161 行 / backend 2,883 行 / agent 428 行(TS・TSX・Kotlin 合計、2026-08-02 時点)です。agent が突出して薄いのは、この記事のテーマそのものである「エンジンの中身を知らない」実装になっているためです。
全体像
AI ジョブがどう流れるかを先に図で示します。
ポイントは Agent から backend への矢印がすべて外向きだということです。backend は Agent の IP もポート番号も知りません。Agent がポーリングで取りに来るだけなので、Agent はユーザーのローカル PC にいても、NAT の内側にいても構いません。
backend が instruction を持つ
Kurari の AI ジョブは13種類あります。種別ごとのシステムプロンプト(instruction)は Agent 側ではなく backend の AiJobType enum に集約されています。
/** ジョブの出力形式。json のときは instruction でJSONのみの出力を強制する */
enum class ResponseFormat { text, json }
enum class AiJobType(val instruction: String, val responseFormat: ResponseFormat) {
summarize_board(
"""あなたはチームのコンテキスト管理アプリ「Kurari」のAIアシスタントです。
以下に渡すのは、あるボード上の付箋とコメントの一覧です。
内容を簡潔に日本語で要約してください。論点・決定事項・未解決の疑問があれば分けて示してください。
コードの編集やコマンド実行は不要です。テキストで回答だけを返してください。""",
ResponseFormat.text,
),
brainstorm(
"""あなたはチームのコンテキスト管理アプリ「Kurari」のAIアシスタントです。
以下に渡すボードの内容を踏まえて、ユーザーの指示に沿ったアイデアを付箋の文面として提案してください。
出力はJSON配列のみ。各要素はアイデア1件の文字列(50字以内)。
コードフェンス・前置き・後書きは一切禁止。例: ["アイデア1","アイデア2","アイデア3"]
指定された件数ちょうどを返してください。""",
ResponseFormat.json,
),
// ... organize_board / summarize_document / draft_document / summarize_transcript /
// call_minutes / call_live_summary / project_brief / detect_conflicts /
// extract_decisions / chat_reply / summarize_selection と続く(全13種)
}
brainstorm や organize_board のように構造化データが欲しいジョブは ResponseFormat.json にして、instruction 本文に「出力はJSON配列のみ」「コードフェンス禁止」まで書き込みます。JSON の形をどこで担保するかを Agent 側に持ち込まず、instruction の文面だけで縛る設計です。
ジョブ作成時、AiJobService.create() がこの instruction を payload に同梱します。
val payload = mutableMapOf<String, Any?>(
"targetId" to req.targetId?.toString(),
"nodeIds" to req.nodeIds?.map { it.toString() },
"chatRoomId" to req.chatRoomId?.toString(),
"prompt" to req.prompt,
"instruction" to type.instruction,
"responseFormat" to type.responseFormat.name,
"runner" to req.runner,
)
context(要約対象のボードやドキュメントの中身)は ContextBuilder が Markdown 風のテキストに直列化してから、これも payload とは別カラムの context に持たせます(AiJobService.kt:97)。つまり Agent がジョブを受け取った時点で、instruction・context・ユーザーの追加プロンプトの3点セットがすべて揃っています。Agent 自身はデータベースにも Kurari のノードツリーにも一切アクセスしません。
Agent側の起動時エンジン検出
Agent は起動時に、使える実行エンジンを総当たりでチェックします。
async function main() {
log(`server: ${server}`)
// 全エンジンを起動時にチェックし、使えるものをすべて公開する。
// どのエンジンで実行するかはジョブごとに Web ページ側が指定する(payload.runner)。
const candidates: CliRunner[] = [
new CopilotCliRunner('copilot', timeoutMs),
new AppleAiRunner(timeoutMs),
new OllamaRunner(ollamaUrl, ollamaModel, Math.max(timeoutMs, 120_000)),
]
const runners: CliRunner[] = []
for (const r of candidates) {
const availability = await r.checkAvailability()
if (availability.ok) {
log(`✔ ${r.label()} — ${availability.detail}`)
runners.push(r)
} else {
log(`✘ ${r.id} は利用不可 — ${availability.detail}`)
}
}
if (runners.length === 0) {
log('エラー: 利用可能なAIエンジンがありません(copilot / Apple Intelligence / Ollama のいずれかを用意してください)')
process.exit(1)
}
...
CopilotCliRunner は copilot --version が通るか、AppleAiRunner は macOS かつビルド済みバイナリが動くか、OllamaRunner は http://localhost:11434/api/tags が応答してモデルが1つ以上入っているか、をそれぞれ確認します。ここで見つかったエンジンは1つに絞らず、使えるものを全部 runners 配列に積んで公開します。
const api = new ApiClient(server, token, runners.map((r) => ({ id: r.id, label: r.label() })))
...
await api.heartbeat()
ApiClient.heartbeat() はこの runners 一覧を POST /api/agent/heartbeat のボディに載せて送ります。
async heartbeat(): Promise<void> {
const res = await fetch(`${this.server}/api/agent/heartbeat`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ runners: this.runners }),
})
if (!res.ok) throw new Error(`heartbeat failed: ${res.status}`)
}
backend 側は AiJobService.heartbeat() で受け取った一覧をメモリに保持し(AiJobService.kt:66-69)、AiStatusDto.runners としてフロントに返します。フロントはこれをセレクタとして表示するだけで、Agent がどんなエンジンを持っているかを事前に決め打ちしません。
claim ループとプロンプト組み立て
Agent 本体は runClaimLoop の無限ループです。2秒ごとに POST /api/agent/jobs/claim を叩き、ジョブが取れたら実行、なければ待って次のループへ進みます。
let serverWasDown = false
for (;;) {
try {
const job = await api.claimJob()
if (serverWasDown) {
log('サーバーへ再接続しました')
serverWasDown = false
}
if (job) {
const runner = pickRunner(runners, job)
log(`job ${job.id} (${job.type}) を ${runner.id} で実行します…`)
try {
const started = Date.now()
const result = await runner.run(buildPrompt(job))
await api.completeJob(job.id, { result })
log(`job ${job.id} 完了 (${((Date.now() - started) / 1000).toFixed(1)}s)`)
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
log(`job ${job.id} 失敗: ${message}`)
await api.completeJob(job.id, { error: message }).catch(() => {})
}
continue // ジョブがあった直後は待たずに次を見る
}
} catch (e) {
if (!serverWasDown) {
log(`サーバーに接続できません(再試行し続けます): ${e instanceof Error ? e.message : e}`)
serverWasDown = true
}
}
await new Promise((r) => setTimeout(r, pollIntervalMs))
}
buildPrompt は instruction・context・追加プロンプトを結合するだけの関数です。
function buildPrompt(job: AiJob): string {
const instruction =
typeof job.payload.instruction === 'string' && job.payload.instruction
? job.payload.instruction
: LEGACY_SYSTEM_PROMPT
const parts = [instruction, '', '---', job.context ?? '(コンテキストなし)', '---']
const extra = job.payload.prompt
if (extra) {
parts.push('', `ユーザーの指示・質問: ${extra}`)
}
return parts.join('\n')
}
Agent 側のコードに summarize_board や brainstorm といったジョブ種別名は一切出てきません。文字列を3つ繋げているだけです。この「Agent はジョブの中身を知らない」という制約が、後述する種別追加コストの低さに直結します。
失敗時の扱いにも触れておきます。runner.run() が例外を投げたら catch 節で api.completeJob(job.id, { error: message }) を呼び、ジョブを failed として書き戻します。completeJob 自体が失敗しても .catch(() => {}) で握りつぶし、ループは止めません。サーバーが落ちている間は serverWasDown フラグでログの連投を防ぎつつ、pollIntervalMs(デフォルト2秒)待ってから再試行します。
なお claim にタイムアウトが挟まる場合に備えて、backend 側にも claimed のまま jobTimeoutSeconds を超えたジョブを pending に戻す requeueStaleJobs(30秒間隔の @Scheduled)があります(AiJobService.kt:297-312)。Agent がクラッシュしてジョブを持ち逃げしても、一定時間後に別の claim で拾い直されます。
ジョブごとのエンジン切替
payload.runner を見てどのエンジンで実行するかを決めるのが pickRunner です。
/** ジョブが指定するエンジン(payload.runner)を選ぶ。無指定・不在なら先頭 */
function pickRunner(runners: CliRunner[], job: AiJob): CliRunner {
const want = job.payload.runner
return runners.find((r) => r.id === want) ?? runners[0]
}
want は Web ページ側のセレクタでユーザーが選んだエンジン ID(copilot-cli / apple-ai / ollama)です。AiJobService.create() はこれをそのまま payload["runner"] に書き込むだけで、backend は runner の値そのものを解釈しません(AiJobService.kt:105)。一致するランナーが runners 配列内に見つからない場合は先頭にフォールバックします。
このため、1台の Agent プロセスを再起動せずに「このジョブは Ollama で」「あのジョブは Copilot CLI で」と切り替えられます。エンジン追加時に Agent の起動オプションを変える必要もありません。candidates 配列(agent/src/index.ts:25-29)に新しい CliRunner 実装を足すだけで、起動時チェックとページ側セレクタの両方に自動的に載ります。
Apple Intelligenceの長文中間省略
3つの CliRunner 実装(CopilotCliRunner / AppleAiRunner / OllamaRunner)はいずれも agent/src/cli-runner.ts にあります。共通インターフェースは次のとおりです。
export interface CliRunner {
readonly id: string
label(): string
checkAvailability(): Promise<{ ok: boolean; detail: string }>
run(prompt: string): Promise<string>
}
CopilotCliRunner は copilot -p "<prompt>" -s --no-color を execFile で叩くだけのシンプルな実装です。OllamaRunner も POST /api/generate を stream: false で叩くだけです。
一方 AppleAiRunner には他の2つにはない前処理があります。オンデバイスモデルはコンテキスト長が小さい(コメントによれば約4Kトークン相当)ため、長いプロンプトをそのまま渡すと失敗します。そこで run() の直前にプロンプトの中間を間引きます。
/** コンテキスト超過を避けるため、長いプロンプトの中間を省略する */
private static shrink(prompt: string): string {
if (prompt.length <= AppleAiRunner.MAX_PROMPT_CHARS) return prompt
const half = Math.floor(AppleAiRunner.MAX_PROMPT_CHARS / 2)
return (
prompt.slice(0, half) +
'\n…(コンテキストが長いため中略)…\n' +
prompt.slice(prompt.length - half)
)
}
MAX_PROMPT_CHARS は 5000(cli-runner.ts:77)。これを超えるプロンプトは先頭 2500 文字と末尾 2500 文字だけを残し、間を …(コンテキストが長いため中略)… という1行に置き換えます。先頭と末尾を残すのは、instruction(先頭)と直近のユーザー指示(末尾)を優先的に生かすためです。run() はこの shrink() を通した文字列を子プロセスの stdin に渡します(cli-runner.ts:122-143)。
Apple Intelligence 自体はビルドも遅延させています。checkAvailability() はソース(agent/apple/kurari-apple-ai.swift)がバイナリより新しいときだけ swiftc -parse-as-library -O でビルドし直し、以降はキャッシュされたバイナリをそのまま使います(cli-runner.ts:93-107)。
フロントのジョブ追跡とJSONパースのフォールバック
フロント側でジョブの完了を待つフックが useAiJob です。WebSocket の ai_job.updated イベントをストアに反映しつつ、念のため1.5秒間隔のポーリングも並行して動かします。
export function useAiJob() {
const [jobId, setJobId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const upsert = useAiJobStore((s) => s.upsert)
const job: AiJob | null = useAiJobStore((s) => (jobId ? (s.jobs[jobId] ?? null) : null))
const pollTimer = useRef<number | undefined>(undefined)
const running = job !== null && (job.status === 'pending' || job.status === 'claimed')
useEffect(() => {
if (!running || !jobId) return
pollTimer.current = window.setInterval(async () => {
try {
upsert(await api.getAiJob(jobId))
} catch {
// keep polling
}
}, 1500)
return () => window.clearInterval(pollTimer.current)
}, [running, jobId, upsert])
このフックを呼ぶ側で気をつけるべき点が1つあります。「AI下書き」ボタンのようにジョブ完了後にエディタへ挿入する UI では、job.status === 'done' だけを見ていると前回の結果を再挿入してしまうバグを踏みます。DocAiToolbar はこれをジョブ ID で防いでいます。
// pending はジョブIDと紐づける。where だけだと、前回の done ジョブが
// ストアに残ったまま次を実行した瞬間に古い結果を再挿入してしまう。
const draftJob = useAiJob()
const [pendingInsert, setPendingInsert] = useState<{ jobId: string; where: InsertWhere } | null>(null)
...
useEffect(() => {
if (!pendingInsert || draftJob.job?.id !== pendingInsert.jobId) return
if (draftJob.job.status === 'done' && draftJob.job.result) {
insertMarkdown(draftJob.job.result, pendingInsert.where)
setPendingInsert(null)
} else if (draftJob.job.status === 'failed') {
setPendingInsert(null)
}
}, [draftJob.job, pendingInsert, insertMarkdown])
useAiJob 自体は「今追跡している最新のジョブ」しか持たないため、draftJob.job?.id !== pendingInsert.jobId の比較が無いと、ストアが upsert で新しいジョブに差し替わった瞬間に古い done 状態を拾ってしまいます。挿入系 UI ではこの ID 突き合わせが必須です。
構造化出力(brainstorm / organize_board / detect_conflicts / extract_decisions)は instruction で JSON のみを指示していますが、LLM がコードフェンスや前置きを付けて返すことがあります。パースは parseAiJson が寛容に行います。
export function parseAiJson<T>(raw: string): T | null {
let text = raw.trim()
// ```json ... ``` フェンス除去
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/)
if (fence) text = fence[1].trim()
// 最初の [ または { から、対応する閉じ括弧までを抽出
const start = text.search(/[[{]/)
if (start < 0) return null
const open = text[start]
const close = open === '[' ? ']' : '}'
let depth = 0
let inString = false
let escaped = false
for (let i = start; i < text.length; i++) {
const c = text[i]
if (inString) {
if (escaped) escaped = false
else if (c === '\\') escaped = true
else if (c === '"') inString = false
continue
}
if (c === '"') inString = true
else if (c === open) {
depth++
} else if (c === close) {
depth--
if (depth === 0) {
try {
return JSON.parse(text.slice(start, i + 1)) as T
} catch {
return null
}
}
}
}
try {
return JSON.parse(text.slice(start)) as T
} catch {
return null
}
}
コードフェンスを剥がしてから、最初の [ か { を見つけ、文字列リテラル内の括弧を除外しながら深さを数えて対応する閉じ括弧までを切り出す、という素朴な実装です。正規表現1発で JSON を抜き出そうとせず、括弧の対応を自前でカウントしているのは、ネストした配列やオブジェクトを含む出力(organize_board の groups など)でも壊れないようにするためです。
それでもパースに失敗した場合の受け皿が fallbackLines です。
/** JSONパース失敗時のフォールバック: 箇条書き・番号付き・行分割で文字列配列化 */
export function fallbackLines(raw: string): string[] {
return raw
.split('\n')
.map((l) => l.replace(/^\s*(?:[-・*]|\d+[.)])\s*/, '').trim())
.filter((l) => l.length > 0 && !/^```/.test(l))
}
JSON として崩れていても、箇条書きや番号付きリストの体裁で返ってくることは多いので、行頭の記号を削って文字列配列として扱えるようにしています。brainstorm の呼び出し側は parseAiJson が null を返したときにこの fallbackLines へフォールバックし、UI としては「アイデアの一覧」が出せる状態を保ちます。
mock で E2E 全経路が通る仕組み
Agent を起動していない開発時でも、AI 機能を含めた E2E を通す必要があります。AiJobService.create() は Agent がオフラインかつ kurari.ai.mock: true(開発時のデフォルト設定)のとき、ジョブを即座に done にしてダミー応答を書き込みます。
// Agent 不在かつ mock モードのときは即ダミー完了させる(開発・検証用)
if (mockMode && !agentOnline()) {
job.status = AiJobStatus.done
job.result = mockResult(type, context, req.prompt, organized?.legend)
job.completedAt = Instant.now()
}
ここで重要なのは mockResult が JSON 系種別に対して構文的に妥当な JSON を返す点です。
/**
* Agent未接続時のダミー応答。JSON系種別は必ず妥当なJSONを返す
* (フロントのパース経路・E2Eをmockモードで検証できるようにするため)。
*/
private fun mockResult(
type: AiJobType,
context: String,
prompt: String?,
legend: List<String>? = null,
): String = when (type) {
AiJobType.brainstorm ->
"""["(Mock) アイデア案1","(Mock) アイデア案2","(Mock) アイデア案3"]"""
AiJobType.organize_board -> {
val size = legend.orEmpty().size
val split = (size + 1) / 2
val first = (1..split).joinToString(",")
if (size == 1) {
"""{"groups":[{"theme":"(Mock) テーマA","items":[$first]}]}"""
} else {
val second = (split + 1..size).joinToString(",")
"""{"groups":[{"theme":"(Mock) テーマA","items":[$first]},{"theme":"(Mock) テーマB","items":[$second]}]}"""
}
}
AiJobType.detect_conflicts ->
"""[{"topic":"(Mock) 論点の例","a":"ボード上の記述A","b":"ドキュメント上の記述B","hint":"Agent接続時に実際の矛盾を検出します"}]"""
AiJobType.extract_decisions ->
"""{"decisions":["(Mock) 決定事項の例"],"openQuestions":["(Mock) 未解決事項の例"]}"""
AiJobType.call_live_summary ->
"""{"points":["(Mock) 通話の要点1","(Mock) 通話の要点2","(Mock) 通話の要点3"]}"""
else -> {
// text 系は箇条書きテキストのダミー応答
...
}
}
organize_board のモックは legend(未分類要素の番号対応表、ContextBuilder.buildOrganizeBoardContext が払い出す)の件数に応じてグループを2分割し、実データでもグルーピング結果を UI に反映する経路を通せるようにしています。単に固定文字列を返すのではなく、テストデータの形に応じて妥当な構造を作っている点がポイントです。
この仕組みにより、frontend/e2e/smoke.mjs の 70 項目の E2E スモークは Agent プロセスを一切起動せずに全経路を通せます。フロントの parseAiJson → UI 反映という経路も、mock が返す JSON でそのまま検証されます。Agent を接続した状態でしか通らないテストが1つもない、というのがこの設計の狙いです。
種別追加が3点で済む理由
ここまでの実装を踏まえると、新しい AI ジョブ種別を追加するときにどこを触ればいいかが見えてきます。
-
AiJobType.ktに enum を1件追加(instruction とResponseFormatを書く) -
AiJobService.buildContext()のwhen式に分岐を1件追加(ContextBuilderの既存メソッドを組み合わせるだけのことが多い) -
AiJobService.mockResult()に mock 応答を1件追加
Agent 側のコード(claim-loop.ts / cli-runner.ts / index.ts)は一切変更しません。Agent はジョブの type を見てすらいません。buildPrompt が組み立てる文字列は instruction・context・prompt の3パーツの結合結果でしかなく、それらは全部 backend が用意し終えた状態で payload に乗ってくるからです。
これは冒頭の Mermaid 図にも表れています。Agent が関与するのは「claim → CLI 実行 → complete」の3ステップだけで、ジョブの意味づけ(何を要約するか、JSON で返すべきか)は全部そこに到達する前に backend 側で完結しています。フロント側の型判定 (ResponseFormat) と instruction の内容が一致していれば、Agent の改修なしに種別を増やせる、というのがこの設計の実装上の帰結です。
Kurari の記事一覧
- Zenn: Kurariを1ヶ月作って分かったこと
- Zenn: ボード・ドキュメント・チャットを1本のツリーで持つ設計
- Qiita: LAN内WebRTC P2P通話を実装してハマったところ(近日公開)
- Qiita: LAN共有をオーナー承認制にする実装(Kotlin/Spring Boot + React)(近日公開)
- Qiita: React Flow (@xyflow/react v12) を controlled で使うときの注意点