|
|
@@ -0,0 +1,147 @@
|
|
|
+import { ShieldCheck, Cloud, HardDrive, X } from 'lucide-react'
|
|
|
+import { useAppStore } from '../store/app'
|
|
|
+import { useMode } from '../api/mode'
|
|
|
+import { Button } from './ui'
|
|
|
+
|
|
|
+/**
|
|
|
+ * SR-002 cloud-model privacy notice (CR-20260607-001 rev.2 §7.1).
|
|
|
+ *
|
|
|
+ * Before a cloud model (Claude Code / Anthropic API / OpenAI-compatible /
|
|
|
+ * DashScope) processes a task, the user — especially a scientist with
|
|
|
+ * unpublished data or a doctor with case material — must understand
|
|
|
+ * exactly what leaves the machine. The backend's local index, full
|
|
|
+ * knowledge base, and un-retrieved files NEVER leave; only the query,
|
|
|
+ * the retrieved snippets, and the agent prompt do.
|
|
|
+ *
|
|
|
+ * Behavior by mode (Q8 / §7.1):
|
|
|
+ * - desktop: shown as a blocking modal the first time, until the user
|
|
|
+ * acknowledges (persisted via cloudPrivacyAck). This is the "强制弹窗"
|
|
|
+ * requirement — desktop is the privacy-sensitive default.
|
|
|
+ * - lab / paas: opt-in; the component renders nothing unless `force`
|
|
|
+ * is set, because those operators have already accepted a shared
|
|
|
+ * deployment's data posture.
|
|
|
+ *
|
|
|
+ * Usage: render <CloudPrivacyGate onProceed={...}> right before kicking
|
|
|
+ * off a cloud-model run, OR mount <PrivacyNoticeModal /> once near the
|
|
|
+ * chat surface. This file exports both the modal and a thin gate helper.
|
|
|
+ */
|
|
|
+
|
|
|
+const SENT = [
|
|
|
+ '你的问题 / 任务输入',
|
|
|
+ '从本地资料库检索到的相关片段',
|
|
|
+ '当前智能体的提示词',
|
|
|
+]
|
|
|
+
|
|
|
+const NOT_SENT = [
|
|
|
+ '完整知识库',
|
|
|
+ '未被检索命中的文件',
|
|
|
+ '本地索引与向量数据',
|
|
|
+ '工作区里的历史产物',
|
|
|
+]
|
|
|
+
|
|
|
+export function PrivacyNoticeBody() {
|
|
|
+ return (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <div className="flex items-start gap-3">
|
|
|
+ <ShieldCheck className="text-teal-500 shrink-0 mt-0.5" size={22} />
|
|
|
+ <div>
|
|
|
+ <h3 className="text-base font-semibold text-gray-900">
|
|
|
+ 云端模型数据提示
|
|
|
+ </h3>
|
|
|
+ <p className="mt-1 text-sm text-gray-500">
|
|
|
+ 你选择了云端模型。本次任务会把下面这些内容发送给模型服务商,
|
|
|
+ 其余资料始终留在本机。
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
|
+ <div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
|
|
|
+ <div className="flex items-center gap-1.5 text-amber-700 text-xs font-semibold mb-2">
|
|
|
+ <Cloud size={14} /> 会发送
|
|
|
+ </div>
|
|
|
+ <ul className="space-y-1">
|
|
|
+ {SENT.map(s => (
|
|
|
+ <li key={s} className="text-xs text-gray-700 flex gap-1.5">
|
|
|
+ <span className="text-amber-400">•</span>
|
|
|
+ {s}
|
|
|
+ </li>
|
|
|
+ ))}
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="rounded-lg border border-green-200 bg-green-50 p-3">
|
|
|
+ <div className="flex items-center gap-1.5 text-green-700 text-xs font-semibold mb-2">
|
|
|
+ <HardDrive size={14} /> 不会发送
|
|
|
+ </div>
|
|
|
+ <ul className="space-y-1">
|
|
|
+ {NOT_SENT.map(s => (
|
|
|
+ <li key={s} className="text-xs text-gray-700 flex gap-1.5">
|
|
|
+ <span className="text-green-400">•</span>
|
|
|
+ {s}
|
|
|
+ </li>
|
|
|
+ ))}
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <p className="text-[11px] text-gray-400 leading-relaxed">
|
|
|
+ 若要完全离线,请在「模型与隐私」中选择本地 Ollama 模型。
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Blocking modal. In desktop mode this shows once until acknowledged.
|
|
|
+ * Returns null when already acknowledged (or not in desktop mode and not
|
|
|
+ * forced).
|
|
|
+ */
|
|
|
+export function PrivacyNoticeModal({ force = false }: { force?: boolean }) {
|
|
|
+ const { data } = useMode()
|
|
|
+ const mode = data?.mode ?? 'desktop'
|
|
|
+ const { cloudPrivacyAck, ackCloudPrivacy } = useAppStore()
|
|
|
+
|
|
|
+ const shouldShow = force || (mode === 'desktop' && !cloudPrivacyAck)
|
|
|
+ if (!shouldShow) return null
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
|
|
+ <div className="bg-white rounded-xl shadow-xl max-w-lg w-full p-6 space-y-5">
|
|
|
+ <PrivacyNoticeBody />
|
|
|
+ <div className="flex justify-end gap-2 pt-2">
|
|
|
+ <Button onClick={ackCloudPrivacy}>我已了解,继续</Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Inline dismissible banner variant — for surfaces that shouldn't block
|
|
|
+ * (e.g. a settings page). Lighter weight than the modal.
|
|
|
+ */
|
|
|
+export function PrivacyNoticeBanner() {
|
|
|
+ const { data } = useMode()
|
|
|
+ const mode = data?.mode ?? 'desktop'
|
|
|
+ const { cloudPrivacyAck, ackCloudPrivacy } = useAppStore()
|
|
|
+
|
|
|
+ if (mode !== 'desktop' || cloudPrivacyAck) return null
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="rounded-lg border border-teal-200 bg-teal-50 p-3 flex items-start gap-3">
|
|
|
+ <ShieldCheck className="text-teal-500 shrink-0 mt-0.5" size={18} />
|
|
|
+ <div className="flex-1 text-xs text-gray-600">
|
|
|
+ <span className="font-medium text-gray-800">本地优先:</span>
|
|
|
+ 选择云端模型时,只有问题、检索片段和提示词会上传,其余资料留在本机。
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ onClick={ackCloudPrivacy}
|
|
|
+ className="text-gray-400 hover:text-gray-600 shrink-0"
|
|
|
+ title="知道了"
|
|
|
+ >
|
|
|
+ <X size={16} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|