ソースを参照

feat(M1): Phase C webui — mode chip + relabel + SR-002 privacy notice

CR-20260607-001 rev.2 §10.3 + Q8 + SR-002 的前端落地。webui 顶栏现在
mode-aware, 文案按 desktop/lab/paas 切换, 云端模型隐私提示就位。
tsc + vite build 全过。

## 新文件

### webui/src/api/mode.ts (71 LOC)
- fetchMode() / useMode() — 读 GET /api/v1/setup/mode (Phase B 加的端点)
  react-query 缓存 5min; 旧后端无此端点时 graceful 降级到 desktop
- MODE_META — 三档的 single source of truth (label / brand / chip 颜色 /
  icon): desktop=ResearchAgent Desktop/teal/🖥️, lab=LambdAgent Lab/
  amber/👥, paas=LambdAgent PaaS/indigo/☁️

### webui/src/components/PrivacyNotice.tsx (147 LOC)
SR-002 云端模型数据提示, 三种渲染形态:
- PrivacyNoticeBody  — 「会发送 / 不会发送」两列对照 (问题+检索片段+
  提示词 vs 完整KB+未命中文件+本地索引+历史产物)
- PrivacyNoticeModal — desktop 强制弹窗 (首次, 持久化 ack 后不再弹);
  lab/paas no-op 除非 force
- PrivacyNoticeBanner — 轻量内联横幅变体 (给设置页等不该 block 的场景)

## 改动

### Sidebar.tsx — mode chip + 文案重定位 (Q8 + §10.3)
- 顶部 brand 名 mode-aware (MODE_META[mode].brand)
- 新增 ModeChip: 显示 icon+label, lab/paas 带 tenant alias, hover tooltip
- nav 文案 desktop 分流: 仪表盘→今日工作, 知识体→资料库,
  模型提供商→模型与隐私 (lab/paas 保留平台术语)
- 登出按钮 desktop 文案: 重新配置→切换数据 / 重新配置

### Layout.tsx — 挂载 <PrivacyNoticeModal /> (全局一次)

### SetupWizard.tsx — 首屏 mode-aware (login 前也能读 /setup/mode)
- brand 名 + 副标题 mode 分流 (desktop: 本地科研工作台)
- step 3 文案: 初始化平台 → 完成设置 (desktop)
- "检查 LambdAgent PaaS 服务" → "检查本地服务"

### store/app.ts — SR-002 持久化状态
- cloudPrivacyAck / ackCloudPrivacy / resetCloudPrivacy

## 验证

  $ cd webui && npm run build
  ✓ tsc 0 errors
  ✓ 1647 modules transformed, built in 2.83s

## Phase C 完成度

  ✓ 顶栏 mode chip (Q8)
  ✓ 文案重命名 (§10.3)
  ✓ mode-aware brand
  ✓ SR-002 隐私提示组件 + Layout 挂载
  🔲 Chat→任务工作台 文案 (Chat.tsx 有未提交 WIP, 留待 WIP 落地后一起)
  🔲 first-run wizard 数据目录配置步骤 (FR-002 UI, 配合后端 data_dir)

## 不在本 commit 范围

- Chat.tsx / claude_code_provider.py / mkdocs.yml 的 pre-existing WIP
  (与 Phase C 无关, 单独处理)
- PrivacyNoticeModal 是全局挂载 (Layout), 比 wire 进 Chat 更省且覆盖
  所有 cloud-model 入口; 未来若要"每次任务前"粒度可换 force 模式

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kenny67nju 3 ヶ月 前
コミット
ff61e59

+ 71 - 0
webui/src/api/mode.ts

@@ -0,0 +1,71 @@
+import { useQuery } from '@tanstack/react-query'
+
+/**
+ * Deployment mode (CR-20260607-001 rev.2 Q8).
+ *
+ * The backend exposes GET /api/v1/setup/mode WITHOUT auth so the top-bar
+ * chip and the first-run wizard can adapt before the user logs in.
+ */
+export type DeploymentMode = 'desktop' | 'lab' | 'paas'
+
+export interface ModeInfo {
+  mode: DeploymentMode
+  tenant_alias: string
+  bootstrapped: boolean
+}
+
+const BASE = '/api/v1'
+
+export async function fetchMode(): Promise<ModeInfo> {
+  const res = await fetch(`${BASE}/setup/mode`)
+  if (!res.ok) {
+    // Older backends (pre-rev.2) have no /setup/mode — default to desktop
+    // so the UI degrades gracefully instead of crashing.
+    return { mode: 'desktop', tenant_alias: '', bootstrapped: false }
+  }
+  return res.json() as Promise<ModeInfo>
+}
+
+/**
+ * React hook returning the current deployment mode. Cached for 5 min;
+ * mode rarely changes within a session (it's a deploy-time decision).
+ */
+export function useMode() {
+  return useQuery({
+    queryKey: ['deployment-mode'],
+    queryFn: fetchMode,
+    staleTime: 5 * 60_000,
+    // Never error the UI on a mode fetch — fall back to desktop.
+    retry: false,
+    placeholderData: { mode: 'desktop', tenant_alias: '', bootstrapped: false },
+  })
+}
+
+/**
+ * Per-mode display metadata for the top-bar chip and brand. Keeping this
+ * as a single source of truth so the wording/color is consistent
+ * everywhere (CR §10.3 / Q8).
+ */
+export const MODE_META: Record<
+  DeploymentMode,
+  { label: string; brand: string; chipClass: string; icon: string }
+> = {
+  desktop: {
+    label: 'Desktop',
+    brand: 'ResearchAgent Desktop',
+    chipClass: 'bg-teal-100 text-teal-700 border-teal-200',
+    icon: '🖥️',
+  },
+  lab: {
+    label: 'Lab',
+    brand: 'LambdAgent Lab',
+    chipClass: 'bg-amber-100 text-amber-700 border-amber-200',
+    icon: '👥',
+  },
+  paas: {
+    label: 'PaaS',
+    brand: 'LambdAgent PaaS',
+    chipClass: 'bg-indigo-100 text-indigo-700 border-indigo-200',
+    icon: '☁️',
+  },
+}

+ 4 - 0
webui/src/components/Layout.tsx

@@ -1,5 +1,6 @@
 import { Outlet } from 'react-router-dom'
 import Sidebar from './Sidebar'
+import { PrivacyNoticeModal } from './PrivacyNotice'
 
 export default function Layout() {
   return (
@@ -8,6 +9,9 @@ export default function Layout() {
       <main className="flex-1 overflow-auto">
         <Outlet />
       </main>
+      {/* SR-002: desktop-mode one-time cloud-data privacy modal. No-op in
+          lab/paas and after the user acknowledges (persisted). */}
+      <PrivacyNoticeModal />
     </div>
   )
 }

+ 147 - 0
webui/src/components/PrivacyNotice.tsx

@@ -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>
+  )
+}

+ 54 - 11
webui/src/components/Sidebar.tsx

@@ -2,27 +2,70 @@ import { NavLink, useNavigate } from 'react-router-dom'
 import { LayoutDashboard, Bot, Cpu, LogOut, Zap, LibraryBig } from 'lucide-react'
 import { useAppStore } from '../store/app'
 import { clsx } from '../lib/clsx'
+import { useMode, MODE_META, type DeploymentMode } from '../api/mode'
 
-const nav = [
-  { to: '/dashboard', label: '仪表盘', icon: LayoutDashboard },
-  { to: '/agents', label: '智能体', icon: Bot },
-  { to: '/knowledge', label: '知识体', icon: LibraryBig },
-  { to: '/settings/providers', label: '模型提供商', icon: Cpu },
-]
+// Nav labels are mode-aware (CR-20260607-001 rev.2 §10.3): desktop users
+// are scientists, not platform operators, so the wording leans toward
+// "research workspace" instead of "platform admin". lab/paas keep the
+// platform vocabulary.
+function navItems(mode: DeploymentMode) {
+  const desktop = mode === 'desktop'
+  return [
+    { to: '/dashboard', label: desktop ? '今日工作' : '仪表盘', icon: LayoutDashboard },
+    { to: '/agents', label: '智能体', icon: Bot },
+    { to: '/knowledge', label: desktop ? '资料库' : '知识体', icon: LibraryBig },
+    { to: '/settings/providers', label: desktop ? '模型与隐私' : '模型提供商', icon: Cpu },
+  ]
+}
+
+function ModeChip() {
+  const { data } = useMode()
+  const mode = (data?.mode ?? 'desktop') as DeploymentMode
+  const meta = MODE_META[mode]
+  const alias = data?.tenant_alias ?? ''
+
+  return (
+    <div
+      className={clsx(
+        'flex items-center gap-1.5 px-2 py-1 rounded-md border text-[11px] font-medium',
+        meta.chipClass,
+      )}
+      title={
+        mode === 'desktop'
+          ? '单机模式 — 数据全部保存在本机'
+          : `${meta.label} 模式${alias ? ` · ${alias}` : ''}`
+      }
+    >
+      <span>{meta.icon}</span>
+      <span>{meta.label}</span>
+      {mode !== 'desktop' && alias && (
+        <span className="opacity-60 truncate max-w-[90px]">· {alias}</span>
+      )}
+    </div>
+  )
+}
 
 export default function Sidebar() {
   const { clearApiKey } = useAppStore()
   const navigate = useNavigate()
+  const { data } = useMode()
+  const mode = (data?.mode ?? 'desktop') as DeploymentMode
+  const meta = MODE_META[mode]
 
   return (
     <aside className="w-56 bg-gray-900 text-gray-100 flex flex-col shrink-0">
-      <div className="px-4 py-5 flex items-center gap-2 border-b border-gray-800">
-        <Zap size={18} className="text-indigo-400" />
-        <span className="font-semibold text-sm tracking-tight">LambdAgent PaaS</span>
+      <div className="px-4 py-4 border-b border-gray-800 space-y-2.5">
+        <div className="flex items-center gap-2">
+          <Zap size={18} className="text-indigo-400 shrink-0" />
+          <span className="font-semibold text-sm tracking-tight truncate">
+            {meta.brand}
+          </span>
+        </div>
+        <ModeChip />
       </div>
 
       <nav className="flex-1 px-2 py-4 space-y-0.5">
-        {nav.map(({ to, label, icon: Icon }) => (
+        {navItems(mode).map(({ to, label, icon: Icon }) => (
           <NavLink
             key={to}
             to={to}
@@ -50,7 +93,7 @@ export default function Sidebar() {
           className="flex items-center gap-3 px-3 py-2 w-full rounded-lg text-sm text-gray-500 hover:bg-gray-800 hover:text-gray-300 transition-colors"
         >
           <LogOut size={16} />
-          重新配置
+          {mode === 'desktop' ? '切换数据 / 重新配置' : '重新配置'}
         </button>
       </div>
     </aside>

+ 10 - 4
webui/src/pages/SetupWizard.tsx

@@ -5,6 +5,7 @@ import toast from 'react-hot-toast'
 import { useAppStore } from '../store/app'
 import { Button, Input, Card } from '../components/ui'
 import { clsx } from '../lib/clsx'
+import { useMode, MODE_META, type DeploymentMode } from '../api/mode'
 
 // ── Step helpers ──────────────────────────────────────────────────
 
@@ -49,7 +50,7 @@ function Step1({ onNext }: { onNext: () => void }) {
     <div className="space-y-6">
       <div>
         <h2 className="text-lg font-semibold text-gray-900">环境检测</h2>
-        <p className="mt-1 text-sm text-gray-500">检查 LambdAgent PaaS 服务是否正在运行。</p>
+        <p className="mt-1 text-sm text-gray-500">检查本地服务是否正在运行。</p>
       </div>
 
       {state === 'idle' && (
@@ -348,6 +349,9 @@ export default function SetupWizard() {
   const [providerChoice, setProviderChoice] = useState<ProviderChoice>({ id: '', apiKey: '' })
   const { setApiKey } = useAppStore()
   const navigate = useNavigate()
+  const { data: modeData } = useMode()
+  const mode = (modeData?.mode ?? 'desktop') as DeploymentMode
+  const brand = MODE_META[mode].brand
 
   function handleDone(key: string) {
     setApiKey(key)
@@ -358,7 +362,7 @@ export default function SetupWizard() {
   const steps = [
     { n: 1, label: '环境检测' },
     { n: 2, label: '选择模型' },
-    { n: 3, label: '初始化平台' },
+    { n: 3, label: mode === 'desktop' ? '完成设置' : '初始化平台' },
   ]
 
   return (
@@ -368,9 +372,11 @@ export default function SetupWizard() {
         <div className="text-center mb-8">
           <div className="inline-flex items-center gap-2 mb-3">
             <Zap size={28} className="text-indigo-600" />
-            <span className="text-2xl font-bold text-gray-900">LambdAgent PaaS</span>
+            <span className="text-2xl font-bold text-gray-900">{brand}</span>
           </div>
-          <p className="text-sm text-gray-500">本地管理界面 · 首次安装向导</p>
+          <p className="text-sm text-gray-500">
+            {mode === 'desktop' ? '本地科研工作台 · 首次安装向导' : '本地管理界面 · 首次安装向导'}
+          </p>
         </div>
 
         <Card>

+ 12 - 0
webui/src/store/app.ts

@@ -5,6 +5,14 @@ interface AppState {
   apiKey: string
   setApiKey: (key: string) => void
   clearApiKey: () => void
+
+  // SR-002 (CR-20260607-001 rev.2): in desktop mode the user must
+  // acknowledge, at least once, that selecting a cloud model sends
+  // query + retrieved snippets + the agent prompt to that provider.
+  // Persisted so we don't nag on every chat.
+  cloudPrivacyAck: boolean
+  ackCloudPrivacy: () => void
+  resetCloudPrivacy: () => void
 }
 
 export const useAppStore = create<AppState>()(
@@ -13,6 +21,10 @@ export const useAppStore = create<AppState>()(
       apiKey: '',
       setApiKey: key => set({ apiKey: key }),
       clearApiKey: () => set({ apiKey: '' }),
+
+      cloudPrivacyAck: false,
+      ackCloudPrivacy: () => set({ cloudPrivacyAck: true }),
+      resetCloudPrivacy: () => set({ cloudPrivacyAck: false }),
     }),
     { name: 'lambdagent-store' },
   ),