上級ガイド:本番AIコンパニオン - ストリーミング、音声、マネタイズ
上級段階へようこそ。ユーザーがお金を払うような本番レベルのAIコンパニオンを構築します。つまり、ストリーミングレスポンス、音声I/O、分析、オフラインサポート、適切なマネタイズインフラが必要です。
スケーリング向けアーキテクチャ
本番アプリには以下が含まれます:
- バックエンドサービス — 安全なAPIキー処理、リクエスト検証、レート制限
- ストリーミングエンジン — AIが応答する際のリアルタイムテキスト表示
- 音声レイヤー — 音声テキスト変換とテキスト音声変換
- 分析 — ユーザー行動、リテンション、サブスクリプション指標
- マネタイズ — RevenueCatによるサブスクリプション
- オフラインモード — SQLiteベースのオフラインレスポンス
- セキュリティ — 暗号化ストレージ、プライバシーコンプライアンス
Cloudflare Workersでのバックエンド
本番環境ではAPIキーを露出させません。サーバーレスバックエンドを使用:
// wrangler.toml
name = "companion-api"
type = "javascript"
account_id = "your-account"
workers_dev = true
routes = [
{ pattern = "api.rorklab.com/*", zone_id = "your-zone-id" }
]
[env.production]
vars = { ENVIRONMENT = "production" }
[[env.production.kv_namespaces]]
binding = "RATE_LIMIT"
id = "your-kv-id"// src/index.ts (Cloudflare Worker)
export interface Env {
CLAUDE_API_KEY: string;
RATE_LIMIT: KVNamespace;
}
export default {
async fetch(
request: Request,
env: Env,
context: ExecutionContext
): Promise<Response> {
if (request.method === 'POST' && request.url.includes('/api/chat')) {
return handleChatRequest(request, env);
}
return new Response('Not found', { status: 404 });
},
};
async function handleChatRequest(request: Request, env: Env) {
const { messages, conversationId } = await request.json();
const userIP = request.headers.get('CF-Connecting-IP');
// レート制限
const rateLimitKey = `ratelimit:${userIP}`;
const count = await env.RATE_LIMIT.get(rateLimitKey);
const currentCount = count ? parseInt(count) + 1 : 1;
if (currentCount > 100) {
return new Response('レート制限に達しました', { status: 429 });
}
await env.RATE_LIMIT.put(rateLimitKey, String(currentCount), {
expirationTtl: 3600,
});
// Claudeからのストリーム応答
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': env.CLAUDE_API_KEY,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1500,
stream: true,
messages: messages,
}),
});
return new Response(response.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
});
}React Nativeでのストリーミングレスポンス
AIが応答する際にリアルタイムでテキストを表示:
// services/streamingApi.ts
export async function streamChatResponse(
messages: Array<{ role: string; content: string }>,
onChunk: (chunk: string) => void,
onComplete: () => void
): Promise<void> {
const response = await fetch(
'https://api.rorklab.com/api/chat',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
}
);
if (!response.ok) {
throw new Error('ストリーム失敗');
}
const reader = response.body?.getReader();
if (!reader) throw new Error('リーダーなし');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i];
if (line.startsWith('data: ')) {
try {
const json = JSON.parse(line.slice(6));
if (
json.type === 'content_block_delta' &&
json.delta.type === 'text_delta'
) {
onChunk(json.delta.text);
}
} catch (e) {
// パースエラー、スキップ
}
}
}
buffer = lines[lines.length - 1];
}
onComplete();
}// components/StreamingChat.tsx
import React, { useState } from 'react';
import { View, Text, ScrollView, StyleSheet } from 'react-native';
import { streamChatResponse } from '../services/streamingApi';
interface Props {
messages: Array<{ role: string; content: string }>;
}
export default function StreamingChat({ messages }: Props) {
const [streamingText, setStreamingText] = useState('');
const handleStream = async () => {
setStreamingText('');
try {
await streamChatResponse(
messages,
(chunk) => {
setStreamingText((prev) => prev + chunk);
},
() => {
console.log('ストリーム完了');
}
);
} catch (error) {
console.error('ストリームエラー:', error);
}
};
return (
<ScrollView style={styles.container}>
{messages.map((msg, idx) => (
<View key={idx} style={styles.messageBubble}>
<Text style={styles.text}>{msg.content}</Text>
</View>
))}
{streamingText && (
<View style={styles.streamingBubble}>
<Text style={styles.text}>{streamingText}</Text>
</View>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 12 },
messageBubble: {
backgroundColor: '#f0f0f0',
padding: 12,
borderRadius: 12,
marginVertical: 8,
},
streamingBubble: {
backgroundColor: '#e3f2fd',
padding: 12,
borderRadius: 12,
marginVertical: 8,
borderLeftWidth: 3,
borderLeftColor: '#2196F3',
},
text: { fontSize: 15, color: '#333' },
});音声入出力
音声テキスト変換とテキスト音声変換を実装:
npx expo install expo-speech expo-av expo-permissions// services/voice.ts
import * as Speech from 'expo-speech';
import * as Audio from 'expo-av';
export async function startListening(
onTranscript: (text: string) => void
): Promise<void> {
const permission = await Audio.requestPermissionsAsync();
if (!permission.granted) {
throw new Error('音声許可が拒否されました');
}
const recording = new Audio.Recording();
await recording.prepareToRecordAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY
);
await recording.startAsync();
setTimeout(async () => {
await recording.stopAndUnloadAsync();
const uri = recording.getURI();
// バックエンドに送信して転記するか、デバイス側の音声認識を使用
}, 10000);
}
export async function speakText(text: string): Promise<void> {
await Speech.speak(text, {
language: 'ja',
rate: 1,
pitch: 1,
});
}
export function stopSpeaking() {
Speech.stop();
}// components/VoiceChat.tsx
import React, { useState } from 'react';
import { View, TouchableOpacity, Text, StyleSheet } from 'react-native';
import { startListening, speakText, stopSpeaking } from '../services/voice';
export default function VoiceChat() {
const [isListening, setIsListening] = useState(false);
const [transcript, setTranscript] = useState('');
const handleMicPress = async () => {
if (isListening) {
setIsListening(false);
return;
}
try {
setIsListening(true);
await startListening((text) => {
setTranscript(text);
});
} catch (error) {
console.error('リスニング失敗:', error);
setIsListening(false);
}
};
return (
<View style={styles.container}>
<TouchableOpacity
style={[styles.micButton, isListening && styles.micActive]}
onPress={handleMicPress}
>
<Text style={styles.micIcon}>
{isListening ? '🎤' : '🎙️'}
</Text>
</TouchableOpacity>
{transcript && <Text style={styles.transcript}>{transcript}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: { alignItems: 'center', paddingVertical: 20 },
micButton: {
width: 70,
height: 70,
borderRadius: 35,
backgroundColor: '#007AFF',
justifyContent: 'center',
alignItems: 'center',
},
micActive: {
backgroundColor: '#FF3B30',
},
micIcon: { fontSize: 32 },
transcript: {
marginTop: 12,
fontSize: 16,
color: '#333',
maxWidth: '90%',
textAlign: 'center',
},
});ムード追跡と分析
ムードジャーナル機能を追加:
// services/analytics.ts
export interface MoodEntry {
id: string;
mood: 'happy' | 'sad' | 'anxious' | 'calm' | 'neutral';
note: string;
timestamp: number;
}
export async function logMoodEntry(entry: MoodEntry): Promise<void> {
// SQLiteに保存
db.transaction((tx) => {
tx.executeSql(
`INSERT INTO mood_entries (id, mood, note, timestamp)
VALUES (?, ?, ?, ?);`,
[entry.id, entry.mood, entry.note, entry.timestamp]
);
});
}
export async function getMoodTrend(
days: number
): Promise<Map<string, number>> {
const oneWeekAgo = Date.now() - days * 24 * 60 * 60 * 1000;
return new Promise((resolve, reject) => {
db.transaction((tx) => {
tx.executeSql(
`SELECT mood, COUNT(*) as count FROM mood_entries
WHERE timestamp > ? GROUP BY mood;`,
[oneWeekAgo],
(_, result) => {
const trend = new Map();
result.rows._array.forEach((row: any) => {
trend.set(row.mood, row.count);
});
resolve(trend);
},
(_, error) => reject(error)
);
});
});
}オフラインサポートとバックグラウンド同期
ユーザーがオフラインでチャットして、戻ってきたときに同期できるようにする:
// services/offlineSync.ts
export async function queueMessageForSync(
message: Message
): Promise<void> {
const queue = await AsyncStorage.getItem('syncQueue');
const current = queue ? JSON.parse(queue) : [];
current.push(message);
await AsyncStorage.setItem('syncQueue', JSON.stringify(current));
}
export async function syncQueuedMessages(): Promise<void> {
const queue = await AsyncStorage.getItem('syncQueue');
if (!queue) return;
const messages: Message[] = JSON.parse(queue);
try {
for (const msg of messages) {
await sendMessage(msg);
await db.transaction((tx) => {
tx.executeSql(`UPDATE messages SET synced = 1 WHERE id = ?;`, [
msg.id,
]);
});
}
await AsyncStorage.removeItem('syncQueue');
} catch (error) {
console.error('同期失敗:', error);
// 次のオンラインイベント時に再試行
}
}RevenueCatでのサブスクリプション
アプリ内課金を実装:
npm install react-native-purchases
npx expo install react-native-purchases// services/purchases.ts
import Purchases, {
PurchasesPackage,
} from 'react-native-purchases';
export const ENTITLEMENTS = {
PREMIUM: 'premium',
VOICE: 'voice_premium',
};
export async function initializePurchases() {
await Purchases.configure({
apiKey: 'appl_YOUR_PUBLIC_SDK_KEY',
});
}
export async function getOfferings(): Promise<
PurchasesPackage[] | null
> {
try {
const offerings = await Purchases.getOfferings();
return offerings.current?.availablePackages ?? null;
} catch (error) {
console.error('オファー取得エラー:', error);
return null;
}
}
export async function purchasePackage(
pkg: PurchasesPackage
): Promise<boolean> {
try {
await Purchases.purchasePackage(pkg);
return true;
} catch (error) {
console.error('購入失敗:', error);
return false;
}
}
export async function hasEntitlement(name: string): Promise<boolean> {
try {
const info = await Purchases.getCustomerInfo();
return (
info.activeSubscriptions.includes(name) ||
Object.keys(info.entitlements.all).includes(name)
);
} catch (error) {
console.error('エンタイトルメント確認エラー:', error);
return false;
}
}App Storeへの申請ガイド - AIコンパニオン
App Storeに申請する前にAppleのポリシーを確認:
主要な要件:
- これがAI(実在する人物ではない)であることを明確に開示
- データ使用方法を説明するプライバシーポリシー
- 18歳未満のユーザー向けペアレンタルコントロール
- 誤解を招く健康/メンタルヘルス請求なし
- 利用規約と制限事項の説明
- ユーザーデータの適切な処理(転送中/保存中の暗号化)
- 通知への明確なオプトイン
- ローカルへの支払い情報保存なし
プライバシー開示の例:
// screens/PrivacyScreen.tsx
const PRIVACY_TEXT = `
このアプリはClaudeで動作するAIコンパニオンです。
実在する人物ではなく、専門的なメンタルヘルスケアは提供できません。
データ処理:
- 会話はお使いのデバイスに保存されます
- データは暗号化され、第三者と共有されません
- いつでも設定ですべてのデータを削除できます
サポート:support@rorklab.com
`;セキュリティのベストプラクティス
- 保存時の暗号化:
import * as SecureStore from 'expo-secure-store';
export async function storeSecure(key: string, value: string) {
await SecureStore.setItemAsync(key, value);
}
export async function retrieveSecure(key: string) {
return await SecureStore.getItemAsync(key);
}- APIキーローテーション:
- サーバー側で月ごとにトークンを回転
- 猶予期間後に古いトークンを無効化
- 異常な使用パターンを監視
- データプライバシー:
- GDPR:削除権、データエクスポート
- CCPA:オプトアウトメカニズム
- App Storeの要件に準拠
監視と分析
重要な指標を追跡:
// services/metrics.ts
export async function logUserSession(duration: number) {
await Analytics.logEvent('session_completed', {
duration_seconds: Math.round(duration),
});
}
export async function trackMessageCount(count: number) {
await Analytics.logEvent('daily_messages', {
count,
});
}
export async function trackConversion() {
await Analytics.logEvent('subscription_purchased');
}全体を振り返って:本番リストチェック
本番にデプロイする前に:
- ✅ レート制限付きバックエンドAPI
- ✅ ストリーミングレスポンス
- ✅ 音声入出力
- ✅ ムード追跡
- ✅ オフラインサポート
- ✅ RevenueCat統合
- ✅ App Storeコンプライアンス
- ✅ データ暗号化
- ✅ プライバシーポリシー&利用規約
- ✅ 分析セットアップ
- ✅ エラー追跡(Sentry)
- ✅ 負荷テスト
本番AIコンパニオンはスケーリングできる状態になっています。頑張ってシップしてください!🚀
12年の個人開発で見えてきたこと
リリース直後にチェックしているもの
- クラッシュレポートの上位エラーを24時間ごとに確認しているか
- AdMob/StoreKit の収益ダッシュボードに異常検知が出ていないか
- ユーザーレビューに技術的な指摘が含まれていないか