Rork でモバイル化する必然性
OpenClaw はサーバー側のフレームワークですが、本当の AIパートナー体験は スマートフォンで常に一緒にいる ことで完成します。Rork / Rork Max を活用すれば、プロトタイプから本番アプリまで高速で実現できます。
Rork を選ぶ理由
| 選択肢 | 開発速度 | アプリ品質 | OpenClaw 連携 | おすすめ度 |
|---|---|---|---|---|
| React Native | 中速 | 高 | 可能だが手作業 | ⭐⭐ |
| Rork / Rork Max | 高速 | 高 | ネイティブ統合 | ⭐⭐⭐⭐⭐ |
| Swift / Kotlin | 遅い | 最高 | 複雑 | ⭐ (既存開発向け) |
| Flutter | 中速 | 中~高 | 可能 | ⭐⭐⭐ |
Rork は React Native ベースですが、OpenClaw とのシームレス連携と開発生産性で明確に有利です。
OpenClaw をモバイルアプリに統合するアーキテクチャ
まず、基本アーキテクチャを理解しましょう。
3階層アーキテクチャ
┌─────────────────────────────────────┐
│ モバイル UI 層 (Rork/React Native) │
│ - チャットUI
│ - ボイスアクション
│ - 永続メモリビュー
└──────────────┬──────────────────────┘
│ WebSocket/HTTP API
┌──────────────▼──────────────────────┐
│ OpenClaw サーバー層 (Node.js) │
│ - メッセージ処理
│ - スキル実行
│ - メモリ管理
└──────────────┬──────────────────────┘
│ API呼び出し
┌──────────────▼──────────────────────┐
│ LLM バックエンド層 (Claude等) │
│ - レスポンス生成
│ - 推論
└──────────────────────────────────────┘
ネットワーク通信パターン
// モバイル → OpenClaw → LLM の通信フロー
// 1. ユーザーがメッセージ送信
mobile.sendMessage("今日は疲れた");
// 2. OpenClaw で処理
openclaw.handleMessage({
userId: "user123",
content: "今日は疲れた",
timestamp: Date.now(),
platform: "mobile"
});
// 3. LLM でレスポンス生成
const response = await claude.generateResponse({
message: "今日は疲れた",
context: userMemory, // 過去の会話を含む
personality: "caring_supportive"
});
// 4. モバイルで受信・表示
mobile.displayMessage(response);Rork でチャット UI を素早く構築
Rork の最大の利点は、シンプルなプロンプトで完全なチャット UI が生成される ことです。
Step 1:Rork プロジェクト初期化
# Rork プロジェクト作成
rork create ai-partner-app
# プロジェクト構成
ai-partner-app/
├── src/
│ ├── screens/
│ │ ├── ChatScreen.tsx
│ │ ├── ProfileScreen.tsx
│ │ └── MemoryScreen.tsx
│ ├── components/
│ │ ├── ChatBubble.tsx
│ │ ├── MessageInput.tsx
│ │ └── VoiceButton.tsx
│ ├── services/
│ │ ├── openclaw-client.ts
│ │ ├── storage.ts
│ │ └── voice-handler.ts
│ ├── hooks/
│ │ ├── useMessages.ts
│ │ ├── useVoice.ts
│ │ └── useMemory.ts
│ └── App.tsx
└── package.jsonStep 2:ChatScreen を Rork で素早く生成
Rork のプロンプトテンプレート:
# rork-prompt.yaml
task: "OpenClaw と連携するAIパートナーアプリの ChatScreen を実装"
requirements:
- メッセージ履歴表示(スクロール可能)
- メッセージ入力フィールド
- 音声送信ボタン
- 読み込み状態表示
- エラーハンドリング
- オフライン検知
design:
theme: "modern_gradient"
colorScheme: "AI partner (blue/purple)"
animations: "smooth_transitions"
integration:
backend: "OpenClaw"
authMethod: "JWT"
dataSync: "real_time"
output:
- ChatScreen.tsx
- ChatBubble.tsx
- MessageInput.tsx
- types.ts
- useMessages.tsRork が生成するコード(抜粋):
// src/screens/ChatScreen.tsx
import React, { useState, useEffect, useRef } from 'react';
import {
View,
FlatList,
StyleSheet,
SafeAreaView,
ActivityIndicator,
} from 'react-native';
import { ChatBubble } from '../components/ChatBubble';
import { MessageInput } from '../components/MessageInput';
import { useMessages } from '../hooks/useMessages';
export const ChatScreen: React.FC = () => {
const flatListRef = useRef(null);
const { messages, loading, error, sendMessage } = useMessages();
const handleSendMessage = async (text: string) => {
await sendMessage(text);
// スクロール到底
flatListRef.current?.scrollToEnd({ animated: true });
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Sakura - Your AI Partner</Text>
<Text style={styles.subtitle}>Always here for you</Text>
</View>
<FlatList
ref={flatListRef}
data={messages}
renderItem={({ item }) => (
<ChatBubble
message={item.content}
isUser={item.isUser}
timestamp={item.timestamp}
/>
)}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.messageList}
onEndReachedThreshold={0.1}
/>
{loading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#8B5CF6" />
</View>
)}
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<MessageInput
onSendMessage={handleSendMessage}
disabled={loading}
/>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F8FAFC',
},
header: {
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#E2E8F0',
},
title: {
fontSize: 20,
fontWeight: '700',
color: '#1E293B',
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: '#64748B',
},
messageList: {
paddingHorizontal: 12,
paddingVertical: 16,
},
loadingContainer: {
paddingVertical: 16,
alignItems: 'center',
},
errorContainer: {
backgroundColor: '#FEE2E2',
marginHorizontal: 12,
marginVertical: 8,
padding: 12,
borderRadius: 8,
},
errorText: {
color: '#DC2626',
fontSize: 14,
},
});OpenClaw API / WebSocket 連携の実装
モバイルアプリから OpenClaw サーバーに接続します。
WebSocket クライアント実装
// src/services/openclaw-client.ts
import io from 'socket.io-client';
class OpenClawClient {
private socket: SocketIOClient.Socket | null = null;
private userId: string;
private apiKey: string;
constructor(userId: string, apiKey: string) {
this.userId = userId;
this.apiKey = apiKey;
}
async connect(serverUrl: string): Promise<void> {
return new Promise((resolve, reject) => {
this.socket = io(serverUrl, {
auth: {
userId: this.userId,
token: this.apiKey,
},
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: Infinity,
});
this.socket.on('connect', () => {
console.log('Connected to OpenClaw');
resolve();
});
this.socket.on('connect_error', (error) => {
console.error('Connection error:', error);
reject(error);
});
// エラー処理
this.socket.on('error', (error) => {
console.error('Socket error:', error);
});
// 再接続処理
this.socket.on('reconnect_attempt', () => {
console.log('Attempting to reconnect...');
});
});
}
async sendMessage(
content: string,
options?: { voiceInput?: boolean }
): Promise<string> {
return new Promise((resolve, reject) => {
const messageId = `msg_${Date.now()}`;
// レスポンス受信
this.socket?.once(`response_${messageId}`, (response) => {
resolve(response.content);
});
// タイムアウト設定(30秒)
const timeout = setTimeout(() => {
reject(new Error('Message timeout'));
}, 30000);
// メッセージ送信
this.socket?.emit('message', {
id: messageId,
content,
userId: this.userId,
timestamp: Date.now(),
metadata: {
platform: 'mobile',
...options,
},
});
// レスポンス受信後タイムアウトクリア
this.socket?.once(`response_${messageId}`, () => {
clearTimeout(timeout);
});
});
}
onStreamingResponse(
callback: (chunk: string) => void
): () => void {
// ストリーミングレスポンス
this.socket?.on('streaming_response', (data) => {
callback(data.chunk);
});
// リスナー解除関数
return () => {
this.socket?.removeListener('streaming_response');
};
}
disconnect(): void {
this.socket?.disconnect();
}
}
export const openclawClient = new OpenClawClient(
'user_id',
'api_key'
);useMessages フック
// src/hooks/useMessages.ts
import { useState, useCallback, useEffect } from 'react';
import { openclawClient } from '../services/openclaw-client';
interface Message {
id: string;
content: string;
isUser: boolean;
timestamp: number;
}
export const useMessages = () => {
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// 接続初期化
openclawClient.connect('https://openclaw-server.example.com').catch((err) => {
setError('サーバー接続エラー');
console.error(err);
});
// ストリーミングレスポンスリスナー
const unsubscribe = openclawClient.onStreamingResponse((chunk) => {
setMessages((prev) => {
const lastMessage = prev[prev.length - 1];
if (lastMessage && !lastMessage.isUser) {
return [
...prev.slice(0, -1),
{
...lastMessage,
content: lastMessage.content + chunk,
},
];
}
return prev;
});
});
return () => {
unsubscribe();
openclawClient.disconnect();
};
}, []);
const sendMessage = useCallback(async (text: string) => {
if (!text.trim()) return;
// ユーザーメッセージを追加
const userMessage: Message = {
id: `msg_user_${Date.now()}`,
content: text,
isUser: true,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, userMessage]);
setLoading(true);
setError(null);
// AI メッセージを追加(空)
const aiMessage: Message = {
id: `msg_ai_${Date.now()}`,
content: '',
isUser: false,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, aiMessage]);
try {
const response = await openclawClient.sendMessage(text);
// AI メッセージを更新
setMessages((prev) => [
...prev.slice(0, -1),
{
...prev[prev.length - 1],
content: response,
},
]);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
// エラーメッセージ表示
setMessages((prev) => [
...prev.slice(0, -1),
{
...prev[prev.length - 1],
content: 'エラーが発生しました。もう一度試してください。',
},
]);
} finally {
setLoading(false);
}
}, []);
return { messages, loading, error, sendMessage };
};音声通話機能(Rork Max での実装)
音声入力・出力
// src/services/voice-handler.ts
import * as Speech from 'expo-speech';
import * as VoiceSDK from 'react-native-voice';
class VoiceHandler {
private isListening: boolean = false;
private transcribedText: string = '';
async startListening(): Promise<string> {
return new Promise((resolve, reject) => {
this.transcribedText = '';
VoiceSDK.onSpeechResults = (e: any) => {
this.transcribedText = e.value[0];
resolve(this.transcribedText);
};
VoiceSDK.onSpeechError = (e: any) => {
reject(new Error(`Speech recognition error: ${e.error}`));
};
VoiceSDK.start('ja-JP').catch(reject);
this.isListening = true;
});
}
stopListening(): void {
if (this.isListening) {
VoiceSDK.stop();
this.isListening = false;
}
}
async speak(text: string, options?: { rate?: number }): Promise<void> {
return Speech.speak(text, {
language: 'ja-JP',
pitch: 1.0,
rate: options?.rate || 1.0,
onDone: () => console.log('Speaking finished'),
onError: (error) => console.error('Speech error:', error),
});
}
async stop(): Promise<void> {
await Speech.stop();
}
}
export const voiceHandler = new VoiceHandler();VoiceButton コンポーネント
// src/components/VoiceButton.tsx
import React, { useState } from 'react';
import { Pressable, StyleSheet, View, Animated } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { voiceHandler } from '../services/voice-handler';
import { openclawClient } from '../services/openclaw-client';
interface VoiceButtonProps {
onVoiceMessage: (text: string) => void;
isLoading?: boolean;
}
export const VoiceButton: React.FC<VoiceButtonProps> = ({
onVoiceMessage,
isLoading = false,
}) => {
const [isRecording, setIsRecording] = useState(false);
const [scaleAnimation] = React.useState(new Animated.Value(1));
const handlePressIn = async () => {
setIsRecording(true);
// アニメーション開始
Animated.sequence([
Animated.timing(scaleAnimation, {
toValue: 1.2,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(scaleAnimation, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
try {
const text = await voiceHandler.startListening();
setIsRecording(false);
// 音声テキストをメッセージとして送信
onVoiceMessage(text);
// Rork Max で音声返信を有効化
const response = await openclawClient.sendMessage(text, {
voiceInput: true,
});
// レスポンスを音声で再生
await voiceHandler.speak(response, { rate: 1.0 });
} catch (error) {
console.error('Voice message error:', error);
setIsRecording(false);
}
};
return (
<Animated.View
style={[
styles.container,
{ transform: [{ scale: scaleAnimation }] },
]}
>
<Pressable
onPressIn={handlePressIn}
disabled={isLoading || isRecording}
style={[
styles.button,
isRecording && styles.buttonRecording,
]}
>
<MaterialCommunityIcons
name={isRecording ? 'microphone' : 'microphone-outline'}
size={28}
color={isRecording ? '#EF4444' : '#8B5CF6'}
/>
</Pressable>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
padding: 8,
},
button: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#F3E8FF',
justifyContent: 'center',
alignItems: 'center',
},
buttonRecording: {
backgroundColor: '#FEE2E2',
},
});永続メモリ(AsyncStorage / SQLite)
ローカルデータベース設定
// src/services/storage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import SQLite from 'react-native-sqlite-storage';
export class LocalStorage {
private db: SQLite.SQLiteDatabase | null = null;
async initialize(): Promise<void> {
this.db = await SQLite.openDatabase({
name: 'ai-partner.db',
location: 'default',
});
await this.db?.executeSql(`
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
message TEXT NOT NULL,
response TEXT NOT NULL,
timestamp INTEGER NOT NULL,
metadata TEXT
)
`);
await this.db?.executeSql(`
CREATE TABLE IF NOT EXISTS user_profile (
id TEXT PRIMARY KEY,
userId TEXT UNIQUE NOT NULL,
name TEXT,
interests TEXT,
preferences TEXT,
lastUpdated INTEGER
)
`);
}
async saveConversation(
userId: string,
message: string,
response: string
): Promise<void> {
const timestamp = Date.now();
const id = `conv_${timestamp}`;
await this.db?.executeSql(
`INSERT INTO conversations (id, userId, message, response, timestamp)
VALUES (?, ?, ?, ?, ?)`,
[id, userId, message, response, timestamp]
);
// 非同期で OpenClaw サーバーにも送信(オフライン対応)
await AsyncStorage.setItem(
`pending_${id}`,
JSON.stringify({ userId, message, response, timestamp })
);
}
async getConversationHistory(
userId: string,
limit: number = 50
): Promise<any[]> {
const result = await this.db?.executeSql(
`SELECT * FROM conversations
WHERE userId = ?
ORDER BY timestamp DESC
LIMIT ?`,
[userId, limit]
);
return result?.[0].rows._array || [];
}
async syncWithServer(): Promise<void> {
// ペンディング中のメッセージを送信
const keys = await AsyncStorage.getAllKeys();
const pendingKeys = keys.filter((k) => k.startsWith('pending_'));
for (const key of pendingKeys) {
try {
const data = await AsyncStorage.getItem(key);
if (data) {
// OpenClaw に送信
await openclawClient.sendMessage(JSON.parse(data).message);
await AsyncStorage.removeItem(key);
}
} catch (error) {
console.error(`Failed to sync ${key}:`, error);
}
}
}
}
export const storage = new LocalStorage();メモリビュー画面
// src/screens/MemoryScreen.tsx
import React, { useEffect, useState } from 'react';
import {
View,
FlatList,
StyleSheet,
SafeAreaView,
Text,
TouchableOpacity,
} from 'react-native';
import { storage } from '../services/storage';
interface MemoryItem {
id: string;
category: string;
content: string;
date: string;
importance: 'high' | 'medium' | 'low';
}
export const MemoryScreen: React.FC = () => {
const [memories, setMemories] = useState<MemoryItem[]>([]);
const [filter, setFilter] = useState<'all' | 'important' | 'recent'>('recent');
useEffect(() => {
loadMemories();
}, [filter]);
const loadMemories = async () => {
const conversations = await storage.getConversationHistory('user123');
// AI が学習した情報を表示
const analyzedMemories = conversations.map((conv, index) => ({
id: conv.id,
category: categorizeMemory(conv.response),
content: extractKeyInfo(conv.message, conv.response),
date: new Date(conv.timestamp).toLocaleDateString('ja-JP'),
importance: calculateImportance(conv.response),
}));
const filtered = analyzedMemories.filter((m) => {
if (filter === 'important') return m.importance === 'high';
if (filter === 'recent') {
const daysDiff = (Date.now() - new Date(m.date).getTime()) / (1000 * 60 * 60 * 24);
return daysDiff < 30;
}
return true;
});
setMemories(filtered);
};
const categorizeMemory = (response: string): string => {
if (response.includes('仕事') || response.includes('プロジェクト')) return '仕事';
if (response.includes('健康') || response.includes('運動')) return '健康';
if (response.includes('趣味') || response.includes('好き')) return '趣味';
return 'その他';
};
const extractKeyInfo = (message: string, response: string): string => {
// AI が学習した重要な情報を抽出
return `${message.substring(0, 30)}...`;
};
const calculateImportance = (response: string): 'high' | 'medium' | 'low' => {
const length = response.length;
if (length > 200) return 'high';
if (length > 100) return 'medium';
return 'low';
};
const renderMemory = ({ item }: { item: MemoryItem }) => (
<View style={[styles.memoryCard, getImportanceColor(item.importance)]}>
<View style={styles.memoryHeader}>
<Text style={styles.category}>{item.category}</Text>
<Text style={styles.date}>{item.date}</Text>
</View>
<Text style={styles.content}>{item.content}</Text>
</View>
);
const getImportanceColor = (importance: string) => {
switch (importance) {
case 'high':
return { borderLeftColor: '#EF4444', borderLeftWidth: 4 };
case 'medium':
return { borderLeftColor: '#F59E0B', borderLeftWidth: 4 };
default:
return { borderLeftColor: '#94A3B8', borderLeftWidth: 4 };
}
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.filterContainer}>
<TouchableOpacity
style={[styles.filterButton, filter === 'recent' && styles.filterActive]}
onPress={() => setFilter('recent')}
>
<Text style={styles.filterText}>最近の思い出</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.filterButton, filter === 'important' && styles.filterActive]}
onPress={() => setFilter('important')}
>
<Text style={styles.filterText}>重要な情報</Text>
</TouchableOpacity>
</View>
<FlatList
data={memories}
renderItem={renderMemory}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
/>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F8FAFC',
},
filterContainer: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#E2E8F0',
},
filterButton: {
marginRight: 8,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
backgroundColor: '#E2E8F0',
},
filterActive: {
backgroundColor: '#8B5CF6',
},
filterText: {
fontSize: 14,
fontWeight: '600',
color: '#1E293B',
},
list: {
padding: 16,
},
memoryCard: {
backgroundColor: '#FFFFFF',
borderRadius: 8,
padding: 12,
marginBottom: 12,
},
memoryHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
},
category: {
fontSize: 12,
fontWeight: '600',
color: '#8B5CF6',
},
date: {
fontSize: 12,
color: '#64748B',
},
content: {
fontSize: 14,
color: '#334155',
lineHeight: 20,
},
});プッシュ通知でAIパートナーからの返信を受け取る
AIパートナーが主動的にメッセージを送ることで、より親密な関係が構築されます。
// src/services/push-notification.ts
import * as Notifications from 'expo-notifications';
import { openclawClient } from './openclaw-client';
export class PushNotificationService {
async requestPermissions(): Promise<boolean> {
const { status } = await Notifications.requestPermissionsAsync();
return status === 'granted';
}
async setupChannels(): Promise<void> {
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('ai-partner', {
name: 'AIパートナー',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#8B5CF6',
});
}
}
async registerForProactiveMessages(): Promise<void> {
// サーバーが定期的にメッセージを送信
openclawClient.onProactiveMessage((message) => {
this.sendNotification({
title: 'Sakura からのメッセージ',
body: message.content,
data: {
conversationId: message.conversationId,
},
});
});
}
async sendNotification(params: {
title: string;
body: string;
data?: Record<string, any>;
}): Promise<void> {
await Notifications.scheduleNotificationAsync({
content: {
title: params.title,
body: params.body,
data: params.data,
sound: 'default',
badge: 1,
},
trigger: null, // 即座に送信
});
}
setupNotificationHandlers(): void {
// ユーザーがプッシュ通知をタップした時の処理
Notifications.addNotificationResponseReceivedListener((response) => {
const conversationId = response.notification.request.content.data.conversationId;
// チャット画面に遷移
navigation.navigate('Chat', { conversationId });
});
}
}
export const pushNotificationService = new PushNotificationService();App Store / Google Play 申請のポイント
アプリメタデータ準備
# app-store-metadata.yaml
ios:
bundleId: "com.example.ai-partner"
appName: "AIパートナー Sakura"
description: "24時間あなたのそばにいるAIパートナー"
keywords: ["AI", "チャット", "パートナー"]
supportUrl: "https://support.example.com"
privacyPolicyUrl: "https://example.com/privacy"
screenshots:
- "screenshots/chat-screen.png"
- "screenshots/memory-screen.png"
- "screenshots/voice-call.png"
minimumOSVersion: "14.0"
android:
packageName: "com.example.aipartner"
appName: "AIパートナー Sakura"
targetSdkVersion: 34
minSdkVersion: 24
screenshots: [...]
privacyPolicy: "https://example.com/privacy"
common:
category: "Lifestyle"
contentRating: "4+" # AI彼女の場合は要確認
supportEmail: "support@example.com"
developer:
name: "Your Company"
url: "https://example.com"審査で気をつけるポイント
## App Store / Google Play 審査チェックリスト
- [ ] プライバシーポリシーが明確(AI学習、データ保存について)
- [ ] 音声記録の事前同意(iOS 要件)
- [ ] オフライン機能の説明
- [ ] 年齢制限設定(AI彼女などの場合)
- [ ] API レート制限表示(OpenClaw 無料版の場合)
- [ ] バッテリー消費に関する警告
- [ ] ネットワーク接続要件の明記
## 特に Clawra(AI彼女)の場合
- 明確なコンテンツ警告
- 18+ レーティング設定
- 利用規約で既存の人間関係への影響を否定
- 精神衛生上の警告12年の個人開発で見えてきたこと
リリース直後にチェックしているもの
- クラッシュレポートの上位エラーを24時間ごとに確認しているか
- AdMob/StoreKit の収益ダッシュボードに異常検知が出ていないか
- ユーザーレビューに技術的な指摘が含まれていないか
12年の個人開発から見る Rork の使いどころ
リリース直後にチェックしているもの
- クラッシュレポートの上位エラーを24時間ごとに確認しているか
- AdMob/StoreKit の収益ダッシュボードに異常検知が出ていないか
- ユーザーレビューに技術的な指摘が含まれていないか