Skip to content

Commit abade67

Browse files
authored
Merge pull request #673 from xiaomakuaiz/260610-ai-consent
260610 ai consent
2 parents 2d71c41 + 37749b6 commit abade67

5 files changed

Lines changed: 91 additions & 8 deletions

File tree

mobile/app/(tabs)/_layout.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Tabs, useRouter } from 'expo-router';
2-
import React from 'react';
2+
import React, { useState } from 'react';
33
import { Pressable, Text, View } from 'react-native';
44
import { useSafeAreaInsets } from 'react-native-safe-area-context';
5+
import { AiConsentModal, useAiConsent } from '@/components/AiConsent';
56
import { Glass } from '@/components/glass';
67
import { Icons } from '@/components/Icons';
78
import { useTheme } from '@/theme';
@@ -51,11 +52,22 @@ function GlassDock({ state, navigation }: { state: any; navigation: any }) {
5152
}
5253

5354
export default function TabsLayout() {
55+
// 登录后进入首页(tab 区)首次提示 AI 数据处理同意(App Store 2.1)。已同意则不再弹;
56+
// 「暂不使用」仅本次关闭(下次启动再问),真正的硬拦截在任务会话页/新建任务页。
57+
const aiConsent = useAiConsent();
58+
const [consentDismissed, setConsentDismissed] = useState(false);
5459
return (
55-
<Tabs tabBar={(props) => <GlassDock {...(props as any)} />} screenOptions={{ headerShown: false }}>
56-
<Tabs.Screen name="tasks" />
57-
<Tabs.Screen name="projects" />
58-
<Tabs.Screen name="profile" />
59-
</Tabs>
60+
<>
61+
<Tabs tabBar={(props) => <GlassDock {...(props as any)} />} screenOptions={{ headerShown: false }}>
62+
<Tabs.Screen name="tasks" />
63+
<Tabs.Screen name="projects" />
64+
<Tabs.Screen name="profile" />
65+
</Tabs>
66+
<AiConsentModal
67+
visible={aiConsent.status === 'needed' && !consentDismissed}
68+
onAgree={aiConsent.grant}
69+
onDecline={() => setConsentDismissed(true)}
70+
/>
71+
</>
6072
);
6173
}

mobile/app/(tabs)/profile.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ function About({ t }: { t: Theme }) {
9797
const r = await checkOta();
9898
setOtaBusy(null);
9999
if (r.status === 'disabled') { Alert.alert('检查更新', '开发模式下不可用,正式包才会检查更新。'); return; }
100-
if (r.status === 'error') { Alert.alert('检查失败', r.message || '请检查网络后重试。'); return; }
101-
if (r.status === 'none') { Alert.alert('已是最新', `当前已是最新版本 ${verLine}。`); return; }
100+
// error(OTA 服务未上线/网络异常,拿不到有效数据)视为已是最新,不向用户报错
101+
if (r.status === 'error' || r.status === 'none') { Alert.alert('已是最新', `当前已是最新版本 ${verLine}。`); return; }
102102
Alert.alert('发现新版本', '有新版本可用,是否立即更新?\n(更新后将自动重启)', [
103103
{ text: '取消', style: 'cancel' },
104104
{ text: '立即更新', onPress: applyOtaNow },

mobile/app/new-task.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ActivityIndicator, Platform, Pressable, ScrollView, Text, TextInput, Vi
44
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
55
import { useSafeAreaInsets } from 'react-native-safe-area-context';
66
import { ApiError, createTask, getSubscription, listImages, listModels, listProjects } from '@/api/client';
7+
import { AiConsentModal, useAiConsent } from '@/components/AiConsent';
78
import type { Model, Project } from '@/api/types';
89
import { ConcurrentLimitModal } from '@/components/ConcurrentLimitModal';
910
import { Icons, providerIconForUrl } from '@/components/Icons';
@@ -37,6 +38,7 @@ export default function NewTaskScreen() {
3738
const t = useTheme();
3839
const insets = useSafeAreaInsets();
3940
const router = useRouter();
41+
const aiConsent = useAiConsent(); // 新建任务会把内容发给 AI,需先取得数据处理同意(App Store 2.1)
4042
const params = useLocalSearchParams<{ repo?: string; repoName?: string; projectId?: string }>();
4143

4244
const [models, setModels] = useState<Model[]>([]);
@@ -199,6 +201,9 @@ export default function NewTaskScreen() {
199201
<ModelSheet visible={picking === 'model'} models={models} selectedId={modelId}
200202
onPick={(k) => { setModelId(k); setPicking(null); }} onClose={() => setPicking(null)} />
201203
<ConcurrentLimitModal visible={limitOpen} onClose={() => setLimitOpen(false)} onStopped={() => { setLimitOpen(false); setTimeout(() => submit(), 400); }} />
204+
205+
{/* AI 数据处理同意:新建任务会把内容发给 AI,未同意则退出 */}
206+
<AiConsentModal visible={aiConsent.status === 'needed'} onAgree={aiConsent.grant} onDecline={() => router.back()} />
202207
</KeyboardAvoidingView>
203208
);
204209
}

mobile/app/task/[id].tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ApiError, getTaskDetail, getTaskRounds, listModels } from '@/api/client
88
import { TaskControlClient, type PortForwardInfo, type RepoFileChange } from '@/api/control';
99
import { TaskStreamClient, type StreamState } from '@/api/stream';
1010
import { MAX_ATTACHMENTS, pickImages, saveImageToAlbum, uploadImage } from '@/api/upload';
11+
import { AiConsentModal, useAiConsent } from '@/components/AiConsent';
1112
import type { Model, ProjectTask } from '@/api/types';
1213
import { Glass } from '@/components/glass';
1314
import { Icons, Spinner } from '@/components/Icons';
@@ -85,6 +86,7 @@ export default function TaskDetailScreen() {
8586
const insets = useSafeAreaInsets();
8687
const router = useRouter();
8788
const { id } = useLocalSearchParams<{ id: string }>();
89+
const aiConsent = useAiConsent(); // 进入可交互任务前需先取得 AI 数据处理同意(App Store 2.1)
8890

8991
const [task, setTask] = useState<ProjectTask | null>(null);
9092
const [historyMessages, setHistoryMessages] = useState<ChatMessage[]>([]);
@@ -647,6 +649,9 @@ export default function TaskDetailScreen() {
647649
<PreviewSheet visible={previewOpen} ports={previewPorts} refreshing={portsRefreshing} activeUrl={preview && preview.taskId === id ? preview.url : undefined} onOpen={openInBrowser} onRefresh={refreshPorts} onClose={() => setPreviewOpen(false)} />
648650
<CopySheet visible={copyText != null} text={copyText ?? ''} onClose={() => setCopyText(null)} onCopyAll={onCopyAll} />
649651

652+
{/* AI 数据处理同意:进入可交互(可对话)任务且未同意时弹出,未同意则退出该页 */}
653+
<AiConsentModal visible={interactive && aiConsent.status === 'needed'} onAgree={aiConsent.grant} onDecline={() => router.back()} />
654+
650655
{/* ⋯ 更多操作:低频/破坏性指令,点开后选择,避免误触 */}
651656
<Modal visible={moreOpen} transparent animationType="slide" onRequestClose={() => setMoreOpen(false)} statusBarTranslucent>
652657
<Scrim onPress={() => setMoreOpen(false)} />
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* AI 数据处理同意(满足 App Store 审核 Guideline 2.1:向第三方 AI 服务发送数据前需取得用户明确同意)。
3+
*
4+
* 在「即将把用户内容发给 AI」的入口处(任务会话页、新建任务页)首次弹出,用户明确点「同意并继续」后
5+
* 才放行;持久化到 AsyncStorage,之后不再打扰。点「暂不使用」则退出该页(不进行任何 AI 交互)。
6+
*/
7+
import AsyncStorage from '@react-native-async-storage/async-storage';
8+
import { useCallback, useEffect, useState } from 'react';
9+
import { Linking, Modal, Platform, Pressable, StyleSheet, Text, View } from 'react-native';
10+
import { getBaseUrl } from '@/api/client';
11+
import { useTheme } from '@/theme';
12+
13+
const AI_CONSENT_KEY = 'mc.aiConsent.v2';
14+
15+
export type AiConsentStatus = 'loading' | 'granted' | 'needed';
16+
17+
/** 读取/写入「AI 数据处理同意」状态。loading 期间不弹窗,避免闪现。仅 iOS(App Store 审核要求),Android 直接视为已同意。 */
18+
export function useAiConsent() {
19+
const [status, setStatus] = useState<AiConsentStatus>(Platform.OS === 'ios' ? 'loading' : 'granted');
20+
useEffect(() => {
21+
if (Platform.OS !== 'ios') return;
22+
let alive = true;
23+
AsyncStorage.getItem(AI_CONSENT_KEY)
24+
.then((v) => { if (alive) setStatus(v === '1' ? 'granted' : 'needed'); })
25+
.catch(() => { if (alive) setStatus('needed'); });
26+
return () => { alive = false; };
27+
}, []);
28+
const grant = useCallback(() => {
29+
setStatus('granted');
30+
AsyncStorage.setItem(AI_CONSENT_KEY, '1').catch(() => undefined);
31+
}, []);
32+
return { status, grant };
33+
}
34+
35+
export function AiConsentModal({ visible, onAgree, onDecline }: { visible: boolean; onAgree: () => void; onDecline: () => void }) {
36+
const t = useTheme();
37+
const openPrivacy = () => { Linking.openURL(`${getBaseUrl().replace(/\/+$/, '')}/privacy-policy`).catch(() => undefined); };
38+
return (
39+
<Modal visible={visible} transparent animationType="fade" onRequestClose={onDecline} statusBarTranslucent>
40+
{/* 居中确认对话框需要压暗背景(共享 Scrim 是透明的,底部 sheet 惯例不压暗,这里局部加);拦截背景点击 */}
41+
<Pressable style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(0,0,0,0.45)' }]} />
42+
<View style={{ position: 'absolute', left: 26, right: 26, top: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' }} pointerEvents="box-none">
43+
<View style={{ width: '100%', backgroundColor: t.bg2, borderRadius: 22, borderWidth: 1, borderColor: t.line2, padding: 22, ...t.shLift }}>
44+
<Text style={{ color: t.tx, fontSize: 18, fontWeight: '700', marginBottom: 12 }}>使用 AI 编程助手</Text>
45+
<Text style={{ color: t.tx2, fontSize: 14, lineHeight: 22 }}>
46+
为给你提供 AI 编程协助,你在任务中提交的内容(你的指令、代码、文件、图片等)会被发送至 AI 模型进行处理,其中可能包含第三方 AI 服务商。点击「同意并继续」即表示你已知晓并同意上述数据处理方式。
47+
</Text>
48+
<Pressable onPress={openPrivacy} hitSlop={6} style={{ marginTop: 10 }}>
49+
<Text style={{ color: t.acTx, fontSize: 13, fontWeight: '600' }}>查看《隐私政策》</Text>
50+
</Pressable>
51+
<Pressable onPress={onAgree} style={({ pressed }) => [{ marginTop: 20, backgroundColor: t.ac, borderRadius: 14, paddingVertical: 14, alignItems: 'center' }, pressed && { opacity: 0.85 }]}>
52+
<Text style={{ color: t.acInk, fontSize: 15, fontWeight: '700' }}>同意并继续</Text>
53+
</Pressable>
54+
<Pressable onPress={onDecline} style={{ marginTop: 8, paddingVertical: 12, alignItems: 'center' }}>
55+
<Text style={{ color: t.tx3, fontSize: 14, fontWeight: '600' }}>暂不使用</Text>
56+
</Pressable>
57+
</View>
58+
</View>
59+
</Modal>
60+
);
61+
}

0 commit comments

Comments
 (0)