From a3c556cd0a7a635b8fa084e97895d80e5141d4b6 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 15:19:46 +0000 Subject: [PATCH] Surface cross-session controls in the UI with a project settings panel Why: the layered permission model (project gate + per-conversation outbound/inbound flags) shipped in Backend PR #18 needs visible controls; project scope also had no settings UI yet though the API existed. Evidence: npm run build (tsc -b + vite build) and npm test (15/15) pass. Impact: new schema-driven ProjectSettingsModal (first project-scope settings surface, writes putProjectSettings, reuses SettingsModal group/input helpers); TaskSidebar gains a private shield on rows and in the row menu, per-conversation allowCrossRead in the row menu, and a project-settings gear on project group heads; Conversation header gains a cross-read toggle; contracts/api/store carry the new flags. Refs: proposals/2026-09-30-cross-session-reading.md; Backend PR #18; Dev task feature/cross-session-reading. --- src/App.tsx | 2 + src/api.ts | 2 +- src/components/Conversation.tsx | 7 ++ src/components/ProjectSettingsModal.tsx | 86 +++++++++++++++++++++++++ src/components/SettingsModal.tsx | 17 ++--- src/components/TaskSidebar.tsx | 9 +++ src/contracts.ts | 1 + src/store.ts | 10 +++ tsconfig.tsbuildinfo | 2 +- 9 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 src/components/ProjectSettingsModal.tsx diff --git a/src/App.tsx b/src/App.tsx index 0e28c55..5f8b686 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { Workbench } from './components/Workbench' import { Workspaces, workspaceShortcuts } from './components/Workspaces' import { AgentTracker } from './components/AgentTracker' import { SettingsModal } from './components/SettingsModal' +import { ProjectSettingsModal } from './components/ProjectSettingsModal' import { DocViewer } from './components/DocViewer' // 三栏布局:左(项目会话)/ 中(对话)/ 右(工作台);分隔条拖拽与超调收起(4.4)。 @@ -146,6 +147,7 @@ export function App() { {trackerRunId && } {ui.settingsOpen && } + {ui.projectSettingsId && } {ui.adminUsersOpen && store.setUI({ adminUsersOpen: false })} />} {ui.changePwOpen && store.setUI({ changePwOpen: false })} />} {viewingArtifact && } diff --git a/src/api.ts b/src/api.ts index 07b9988..fce8567 100644 --- a/src/api.ts +++ b/src/api.ts @@ -26,7 +26,7 @@ export const api = { listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json(r)), // 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。 listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json(r)), - setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }), + setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }), me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }>(r)), // ---- 认证与会话(管理员发放制,R2)---- login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => { diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 41c8103..2e03c19 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -272,6 +272,13 @@ export function Conversation() { }}> {profiles.map(p => )} + {active && } diff --git a/src/components/ProjectSettingsModal.tsx b/src/components/ProjectSettingsModal.tsx new file mode 100644 index 0000000..1b9a4e1 --- /dev/null +++ b/src/components/ProjectSettingsModal.tsx @@ -0,0 +1,86 @@ +// 项目设置面板(提案 2026-09-30-cross-session-reading):首个项目级设置 UI。 +// schema 驱动渲染 project scope(4.9 覆盖链:system → user → project), +// 写 putProjectSettings;后续项目级设置项经 settings schema 自动出现,零开发。 +import { useEffect, useMemo, useState } from 'react' +import { api } from '../api' +import { store, useStore } from '../store' +import type { SettingDesc } from '../contracts' +import { SettingsShell, Btn, EmptyState } from '@ui' +import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal' + +export function ProjectSettingsModal() { + const projectId = useStore(s => s.ui.projectSettingsId) + const projects = useStore(s => s.projects) + const project = projects.find(p => p.id === projectId) + const [schema, setSchema] = useState([]) + const [draft, setDraft] = useState>({}) + const [loaded, setLoaded] = useState(false) + const [saved, setSaved] = useState(false) + + useEffect(() => { + if (!projectId) return + setLoaded(false) + api.getSettings(projectId).then(s => { + setSchema(s.schema) + setDraft(flatten(s.project)) + setLoaded(true) + }).catch(() => undefined) + }, [projectId]) + + const groups = useMemo(() => { + const g: Record = {} + for (const d of schema) (g[d.group] ??= []).push(d) + return g + }, [schema]) + + if (!projectId) return null + + const save = async () => { + if (!projectId) return + try { + await api.putProjectSettings(projectId, unflatten(draft)) + setSaved(true) + setTimeout(() => setSaved(false), 1500) + } catch (e) { + store.toast(`项目设置保存失败:${e}`) + } + } + + return ( + undefined} + onClose={() => store.openProjectSettings(null)} + sections={[{ key: 'project', label: '本项目', icon: 'folder' }]} + footer={saved ? ✓ 已保存 : '项目设置覆盖用户设置(new-run 类对新任务生效)'} + > +
+ {!loaded ? ( + 加载中… + ) : ( + <> +
+ 此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。 +
+ {Object.entries(groups).map(([g, descs]) => ( +
+

{groupNames[g] ?? g}

+ {descs.map(d => ( +
+ + setDraft(x => ({ ...x, [d.key]: v }))} /> + + {d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'} + +
+ ))} +
+ ))} + 保存项目设置 + + )} +
+
+ ) +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index e150a39..45edeb7 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -7,6 +7,12 @@ import { SettingsShell, Btn, EmptyState } from '@ui' type Section = 'settings' | 'profiles' | 'plugins' +// 分组显示名(项目设置面板共用)。 +export const groupNames: Record = { + models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索', + ui: '界面', plugins: '插件目录', crossSession: '会话协作', tools: '工具', +} + export function SettingsModal() { const profiles = useStore(s => s.profiles) const [section, setSection] = useState
('settings') @@ -29,11 +35,6 @@ export function SettingsModal() { return g }, [schema]) - const groupNames: Record = { - models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索', - ui: '界面', plugins: '插件目录', - } - const save = async () => { const values = unflatten(draft) await api.putUserSettings(values) @@ -83,7 +84,7 @@ export function SettingsModal() { ) } -function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) { +export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) { if (desc.type === 'boolean') { return onChange(e.target.checked)} /> } @@ -106,12 +107,12 @@ function flat(m: Record, prefix: string, out: Record) else out[key] = v } } -function flatten(m: Record): Record { +export function flatten(m: Record): Record { const out: Record = {} flat(m, '', out) return out } -function unflatten(flatMap: Record): Record { +export function unflatten(flatMap: Record): Record { const out: Record = {} for (const [k, v] of Object.entries(flatMap)) { if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改) diff --git a/src/components/TaskSidebar.tsx b/src/components/TaskSidebar.tsx index 398c53f..60b71fa 100644 --- a/src/components/TaskSidebar.tsx +++ b/src/components/TaskSidebar.tsx @@ -93,6 +93,10 @@ export function TaskSidebar() { + ) @@ -101,6 +105,7 @@ export function TaskSidebar() { onClick={() => store.openTask(t.id)} onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}> {t.title || '未命名任务'} + {t.crossPrivate && } {badge && {badge}} {relativeTime(t.updatedAt)} {rowActions(t)} @@ -176,6 +181,7 @@ export function TaskSidebar() { {g.label} {g.tasks.length} + ) : ( @@ -224,6 +230,9 @@ export function TaskSidebar() { { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'} { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}
+ { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'} + { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'} +
{ store.setUI({ taskOrg: 'timeline' }); setMenu(null) }}>按时间线查看 } diff --git a/src/contracts.ts b/src/contracts.ts index ec2b011..2cdbb20 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -15,6 +15,7 @@ export interface Project { export interface Conversation { id: ID; projectId: ID; owner?: ID; title: string; profileId?: ID; pinned?: boolean; archived?: boolean; + allowCrossRead?: boolean; crossPrivate?: boolean; createdAt: string; updatedAt: string; } diff --git a/src/store.ts b/src/store.ts index 0006b8f..f87436e 100644 --- a/src/store.ts +++ b/src/store.ts @@ -40,6 +40,7 @@ export interface UIState { lastProjectId: string | null adminUsersOpen: boolean changePwOpen: boolean + projectSettingsId: string | null } export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean } @@ -86,6 +87,7 @@ const DEFAULT_UI: UIState = { lastProjectId: null, adminUsersOpen: false, changePwOpen: false, + projectSettingsId: null, } function loadUI(): UIState { @@ -209,6 +211,13 @@ export const store = { store.refreshTasks() }, + // 跨会话标记(提案 2026-09-30-cross-session-reading):出站 allowCrossRead / 入站 crossPrivate。 + async setCrossFlags(convId: string, flags: { allowCrossRead?: boolean; crossPrivate?: boolean }) { + const tasks = state.tasks.map(t => t.id === convId ? { ...t, ...flags } : t) + set({ tasks }) + try { await api.setConversationFlags(convId, flags) } catch (e) { store.toast(`跨会话设置失败:${e}`); store.refreshTasks() } + }, + async openConversation(convId: string) { if (sse) { sse.close(); sse = null } set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [] }) @@ -494,6 +503,7 @@ export const store = { openTracker(runId: string) { setUI({ trackerRunId: runId }) }, viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) }, openSettings(open: boolean) { setUI({ settingsOpen: open }) }, + openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) }, toast(msg: string) { set({ toast: msg }) setTimeout(() => { if (state.toast === msg) set({ toast: null }) }, 2600) diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index e4fba48..501abbe 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"} \ No newline at end of file