Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3c556cd0a | ||
|
|
b2fbcf72bc |
@@ -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() {
|
||||
<Workspaces />
|
||||
{trackerRunId && <AgentTracker />}
|
||||
{ui.settingsOpen && <SettingsModal />}
|
||||
{ui.projectSettingsId && <ProjectSettingsModal />}
|
||||
{ui.adminUsersOpen && <AdminUsersModal onClose={() => store.setUI({ adminUsersOpen: false })} />}
|
||||
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
|
||||
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export const api = {
|
||||
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
|
||||
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
|
||||
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(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 => {
|
||||
|
||||
@@ -272,6 +272,13 @@ export function Conversation() {
|
||||
}}>
|
||||
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<button className="btn ghost" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
title={conversation?.allowCrossRead === false
|
||||
? '本会话不读取其他会话记录(点击开启)'
|
||||
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
|
||||
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
|
||||
<Icon name="users" size={13} />跨读{conversation?.allowCrossRead === false ? '·关' : '·开'}
|
||||
</button>
|
||||
<span title={connected ? '事件流已连接' : '事件流重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
||||
{active && <button className="btn ghost" onClick={() => store.cancelActive()}>停止</button>}
|
||||
</div>
|
||||
|
||||
@@ -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<SettingDesc[]>([])
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
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<string, SettingDesc[]> = {}
|
||||
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 (
|
||||
<SettingsShell
|
||||
title={`项目设置 · ${project?.name ?? projectId}`}
|
||||
active="project"
|
||||
onSelect={() => undefined}
|
||||
onClose={() => store.openProjectSettings(null)}
|
||||
sections={[{ key: 'project', label: '本项目', icon: 'folder' }]}
|
||||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
|
||||
>
|
||||
<div>
|
||||
{!loaded ? (
|
||||
<EmptyState icon="settings">加载中…</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div className="empty-note" style={{ marginBottom: 10 }}>
|
||||
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
|
||||
</div>
|
||||
{Object.entries(groups).map(([g, descs]) => (
|
||||
<div className="settings-group" key={g}>
|
||||
<h4>{groupNames[g] ?? g}</h4>
|
||||
{descs.map(d => (
|
||||
<div className="setting-row" key={d.key}>
|
||||
<label title={d.key}>{d.label}</label>
|
||||
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
|
||||
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
|
||||
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsShell>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,12 @@ import { SettingsShell, Btn, EmptyState } from '@ui'
|
||||
|
||||
type Section = 'settings' | 'profiles' | 'plugins'
|
||||
|
||||
// 分组显示名(项目设置面板共用)。
|
||||
export const groupNames: Record<string, string> = {
|
||||
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
|
||||
ui: '界面', plugins: '插件目录', crossSession: '会话协作', tools: '工具',
|
||||
}
|
||||
|
||||
export function SettingsModal() {
|
||||
const profiles = useStore(s => s.profiles)
|
||||
const [section, setSection] = useState<Section>('settings')
|
||||
@@ -29,11 +35,6 @@ export function SettingsModal() {
|
||||
return g
|
||||
}, [schema])
|
||||
|
||||
const groupNames: Record<string, string> = {
|
||||
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 <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
|
||||
}
|
||||
@@ -106,12 +107,12 @@ function flat(m: Record<string, any>, prefix: string, out: Record<string, any>)
|
||||
else out[key] = v
|
||||
}
|
||||
}
|
||||
function flatten(m: Record<string, any>): Record<string, any> {
|
||||
export function flatten(m: Record<string, any>): Record<string, any> {
|
||||
const out: Record<string, any> = {}
|
||||
flat(m, '', out)
|
||||
return out
|
||||
}
|
||||
function unflatten(flatMap: Record<string, any>): Record<string, any> {
|
||||
export function unflatten(flatMap: Record<string, any>): Record<string, any> {
|
||||
const out: Record<string, any> = {}
|
||||
for (const [k, v] of Object.entries(flatMap)) {
|
||||
if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改)
|
||||
|
||||
@@ -93,6 +93,10 @@ export function TaskSidebar() {
|
||||
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
|
||||
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
|
||||
</button>
|
||||
<button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
|
||||
onClick={e => { e.stopPropagation(); store.setCrossFlags(t.id, { crossPrivate: !t.crossPrivate }) }}>
|
||||
<Icon name="shield" size={13} />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -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 }) }}>
|
||||
<span className="tsb-task-t" title={t.title}>{t.title || '未命名任务'}</span>
|
||||
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
|
||||
{badge && <span className="tsb-pbadge">{badge}</span>}
|
||||
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
|
||||
{rowActions(t)}
|
||||
@@ -176,6 +181,7 @@ export function TaskSidebar() {
|
||||
<span className="tsb-group-name" title={g.label}>{g.label}</span>
|
||||
<span className="tsb-group-cnt">{g.tasks.length}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="tsb-group-add" title="项目设置(含跨会话读取)" onClick={e => { e.stopPropagation(); if (g.projectId) store.openProjectSettings(g.projectId) }}><Icon name="settings" size={13} /></button>
|
||||
<button className="tsb-group-add" title="在此项目新建任务" onClick={e => { e.stopPropagation(); store.createTask(g.projectId) }}><Icon name="plus" size={13} /></button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -224,6 +230,9 @@ export function TaskSidebar() {
|
||||
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
|
||||
<MenuItem icon={rowTask.archived ? 'undo' : 'archive'} onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="shield" onClick={() => { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'}</MenuItem>
|
||||
<MenuItem icon="users" onClick={() => { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}</MenuItem>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="clock" onClick={() => { store.setUI({ taskOrg: 'timeline' }); setMenu(null) }}>按时间线查看</MenuItem>
|
||||
</>}
|
||||
</Menu>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user