@steerable/agent-shell-web 0.6.30 → 0.6.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +14 -0
- package/src/assets/logo-steerable.svg +14 -0
- package/src/brand.ts +3 -3
- package/src/components/AgentSidebar.tsx +406 -221
- package/src/components/BrandLockup.tsx +1 -1
- package/src/components/CreateProjectModal.tsx +269 -0
- package/src/components/chat/ApprovalModal.tsx +2 -2
- package/src/components/chat/ChatProjectBadge.tsx +98 -12
- package/src/components/chat/ExecPolicyPicker.tsx +4 -3
- package/src/layouts/AgentLayout.tsx +12 -10
- package/src/lib/exec-policy.ts +2 -2
- package/src/lib/http-bridge.ts +20 -7
- package/src/lib/local-api.ts +9 -3
- package/src/pages/AgentPage.tsx +11 -10
- package/src/assets/logo-generic.png +0 -0
|
@@ -30,7 +30,7 @@ export function BrandLockup({ className, onClick }: BrandLockupProps = {}) {
|
|
|
30
30
|
draggable={false}
|
|
31
31
|
/>
|
|
32
32
|
{title ? (
|
|
33
|
-
<span className="truncate text-
|
|
33
|
+
<span className="truncate text-sm font-semibold tracking-tight text-agent-foreground leading-none">
|
|
34
34
|
{title}
|
|
35
35
|
</span>
|
|
36
36
|
) : null}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 新建 / 编辑项目弹窗(Codex 式):项目是带名字的容器,可附加多个源文件夹。
|
|
3
|
+
* 家目录由后端建在 Documents/<应用名>/<项目名>/;源文件夹只放宽读取。
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
6
|
+
import { createPortal } from 'react-dom';
|
|
7
|
+
import { LuFolder, LuFolderPlus, LuX } from 'react-icons/lu';
|
|
8
|
+
import { BRAND_NAME } from '@/brand';
|
|
9
|
+
import { getElectronBridge } from '@/lib/electron-bridge';
|
|
10
|
+
|
|
11
|
+
export interface ProjectFormValues {
|
|
12
|
+
name: string;
|
|
13
|
+
sourceFolders: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CreateProjectModalProps {
|
|
17
|
+
open: boolean;
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
onCreate: (input: ProjectFormValues) => Promise<void>;
|
|
20
|
+
mode?: 'create' | 'edit';
|
|
21
|
+
initial?: ProjectFormValues;
|
|
22
|
+
onDelete?: () => Promise<void> | void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function CreateProjectModal({
|
|
26
|
+
open,
|
|
27
|
+
onClose,
|
|
28
|
+
onCreate,
|
|
29
|
+
mode = 'create',
|
|
30
|
+
initial,
|
|
31
|
+
onDelete,
|
|
32
|
+
}: CreateProjectModalProps) {
|
|
33
|
+
const isEdit = mode === 'edit';
|
|
34
|
+
const [name, setName] = useState(initial?.name ?? '');
|
|
35
|
+
const [sourceFolders, setSourceFolders] = useState(initial?.sourceFolders ?? []);
|
|
36
|
+
const [folderDraft, setFolderDraft] = useState('');
|
|
37
|
+
const [submitting, setSubmitting] = useState(false);
|
|
38
|
+
const [deleting, setDeleting] = useState(false);
|
|
39
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
40
|
+
const [error, setError] = useState<string | null>(null);
|
|
41
|
+
const initialFoldersKey = (initial?.sourceFolders ?? []).join('\0');
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!open) return;
|
|
45
|
+
setName(initial?.name ?? '');
|
|
46
|
+
setSourceFolders(initial?.sourceFolders ?? []);
|
|
47
|
+
setFolderDraft('');
|
|
48
|
+
setSubmitting(false);
|
|
49
|
+
setDeleting(false);
|
|
50
|
+
setConfirmDelete(false);
|
|
51
|
+
setError(null);
|
|
52
|
+
const onKey = (event: KeyboardEvent) => {
|
|
53
|
+
if (event.key === 'Escape') onClose();
|
|
54
|
+
};
|
|
55
|
+
window.addEventListener('keydown', onKey);
|
|
56
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
57
|
+
}, [open, onClose, initial?.name, initialFoldersKey]);
|
|
58
|
+
|
|
59
|
+
if (!open) return null;
|
|
60
|
+
if (typeof document === 'undefined') return null;
|
|
61
|
+
|
|
62
|
+
const trimmed = name.trim();
|
|
63
|
+
const homePreview = `~/Documents/${BRAND_NAME}/${trimmed || '项目名'}`;
|
|
64
|
+
const titleId = isEdit ? 'edit-project-title' : 'create-project-title';
|
|
65
|
+
|
|
66
|
+
const addSourceFolder = (folder: string) => {
|
|
67
|
+
const next = folder.trim();
|
|
68
|
+
if (!next) return;
|
|
69
|
+
setSourceFolders((prev) => (prev.includes(next) ? prev : [...prev, next]));
|
|
70
|
+
setFolderDraft('');
|
|
71
|
+
setError(null);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleAddFolder = async () => {
|
|
75
|
+
const typed = folderDraft.trim();
|
|
76
|
+
if (typed) {
|
|
77
|
+
addSourceFolder(typed);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const result = await getElectronBridge()?.local?.selectDirectory({
|
|
81
|
+
title: '添加源文件夹',
|
|
82
|
+
});
|
|
83
|
+
if (!result || result.canceled || result.filePaths.length === 0) {
|
|
84
|
+
if (!getElectronBridge()?.local?.selectDirectory) {
|
|
85
|
+
setError('请输入文件夹路径');
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
addSourceFolder(result.filePaths[0]);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const handleSubmit = async () => {
|
|
93
|
+
if (!trimmed || submitting || deleting) return;
|
|
94
|
+
setSubmitting(true);
|
|
95
|
+
setError(null);
|
|
96
|
+
try {
|
|
97
|
+
await onCreate({ name: trimmed, sourceFolders });
|
|
98
|
+
onClose();
|
|
99
|
+
} catch (err) {
|
|
100
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
101
|
+
setSubmitting(false);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleDelete = async () => {
|
|
106
|
+
if (!onDelete || deleting || submitting) return;
|
|
107
|
+
if (!confirmDelete) {
|
|
108
|
+
setConfirmDelete(true);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
setDeleting(true);
|
|
112
|
+
setError(null);
|
|
113
|
+
try {
|
|
114
|
+
await onDelete();
|
|
115
|
+
onClose();
|
|
116
|
+
} catch (err) {
|
|
117
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
118
|
+
setDeleting(false);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return createPortal(
|
|
123
|
+
<div
|
|
124
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
|
125
|
+
onMouseDown={(event) => {
|
|
126
|
+
if (event.target === event.currentTarget) onClose();
|
|
127
|
+
}}
|
|
128
|
+
role="dialog"
|
|
129
|
+
aria-modal="true"
|
|
130
|
+
aria-labelledby={titleId}
|
|
131
|
+
data-testid={isEdit ? 'edit-project-dialog' : 'create-project-dialog'}
|
|
132
|
+
>
|
|
133
|
+
<div className="flex w-[440px] max-w-[92vw] flex-col overflow-hidden rounded-2xl border border-agent-border bg-agent-canvas shadow-2xl">
|
|
134
|
+
<div className="flex items-center justify-between px-5 pb-1 pt-4">
|
|
135
|
+
<h2 id={titleId} className="text-base font-semibold text-agent-foreground">
|
|
136
|
+
{isEdit ? '编辑项目' : '新建项目'}
|
|
137
|
+
</h2>
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
onClick={onClose}
|
|
141
|
+
className="rounded-full p-1 text-agent-muted-foreground transition-colors hover:bg-agent-muted hover:text-agent-foreground"
|
|
142
|
+
aria-label="关闭"
|
|
143
|
+
>
|
|
144
|
+
<LuX className="h-4 w-4" />
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div className="space-y-4 px-5 py-3">
|
|
149
|
+
<label className="flex h-10 items-center gap-2 rounded-full border border-agent-border bg-agent-canvas px-3 focus-within:border-agent-foreground/40">
|
|
150
|
+
<LuFolder className="h-4 w-4 shrink-0 text-agent-muted-foreground" />
|
|
151
|
+
<input
|
|
152
|
+
type="text"
|
|
153
|
+
value={name}
|
|
154
|
+
onChange={(event) => setName(event.target.value)}
|
|
155
|
+
onKeyDown={(event) => {
|
|
156
|
+
if (event.key === 'Enter') void handleSubmit();
|
|
157
|
+
}}
|
|
158
|
+
placeholder="项目名称"
|
|
159
|
+
autoFocus
|
|
160
|
+
className="h-full min-w-0 flex-1 bg-transparent text-sm text-agent-foreground outline-none placeholder:text-agent-muted-foreground"
|
|
161
|
+
data-testid={isEdit ? 'edit-project-name' : 'create-project-name'}
|
|
162
|
+
/>
|
|
163
|
+
</label>
|
|
164
|
+
{!isEdit && (
|
|
165
|
+
<p className="px-1 text-[11px] text-agent-muted-foreground">
|
|
166
|
+
默认位置:{homePreview}
|
|
167
|
+
</p>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
<div>
|
|
171
|
+
<div className="mb-2 text-sm text-agent-foreground">源文件夹</div>
|
|
172
|
+
<div className="overflow-hidden rounded-2xl border border-agent-border">
|
|
173
|
+
{sourceFolders.map((folder) => (
|
|
174
|
+
<div
|
|
175
|
+
key={folder}
|
|
176
|
+
className="flex items-center gap-2 border-b border-agent-border/60 px-3 py-2 text-xs text-agent-foreground"
|
|
177
|
+
>
|
|
178
|
+
<LuFolder className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
|
|
179
|
+
<span className="min-w-0 flex-1 truncate font-mono" title={folder}>
|
|
180
|
+
{folder}
|
|
181
|
+
</span>
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
onClick={() =>
|
|
185
|
+
setSourceFolders((prev) => prev.filter((item) => item !== folder))
|
|
186
|
+
}
|
|
187
|
+
className="rounded-full p-0.5 text-agent-muted-foreground hover:text-agent-foreground"
|
|
188
|
+
aria-label={`移除 ${folder}`}
|
|
189
|
+
>
|
|
190
|
+
<LuX className="h-3.5 w-3.5" />
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
))}
|
|
194
|
+
<div className="flex items-center gap-2 px-3 py-2">
|
|
195
|
+
<LuFolderPlus className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
|
|
196
|
+
<input
|
|
197
|
+
type="text"
|
|
198
|
+
value={folderDraft}
|
|
199
|
+
onChange={(event) => setFolderDraft(event.target.value)}
|
|
200
|
+
onKeyDown={(event) => {
|
|
201
|
+
if (event.key === 'Enter') {
|
|
202
|
+
event.preventDefault();
|
|
203
|
+
void handleAddFolder();
|
|
204
|
+
}
|
|
205
|
+
}}
|
|
206
|
+
placeholder="输入路径或点添加浏览"
|
|
207
|
+
className="h-7 min-w-0 flex-1 bg-transparent text-xs text-agent-foreground outline-none placeholder:text-agent-muted-foreground"
|
|
208
|
+
data-testid={isEdit ? 'edit-project-folder-path' : 'create-project-folder-path'}
|
|
209
|
+
/>
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
onClick={() => void handleAddFolder()}
|
|
213
|
+
className="shrink-0 rounded-full px-2 py-1 text-xs font-medium text-agent-foreground hover:bg-agent-muted"
|
|
214
|
+
data-testid={isEdit ? 'edit-project-add-folder' : 'create-project-add-folder'}
|
|
215
|
+
>
|
|
216
|
+
添加
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
{error && (
|
|
223
|
+
<p className="text-xs text-agent-destructive" role="alert">
|
|
224
|
+
{error}
|
|
225
|
+
</p>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<div className="flex items-center gap-2 px-5 pb-4 pt-1">
|
|
230
|
+
{onDelete && (
|
|
231
|
+
<button
|
|
232
|
+
type="button"
|
|
233
|
+
onClick={() => void handleDelete()}
|
|
234
|
+
disabled={deleting || submitting}
|
|
235
|
+
title={
|
|
236
|
+
confirmDelete
|
|
237
|
+
? '再次点击确认删除(会话会保留为无项目对话)'
|
|
238
|
+
: '删除项目(会话保留为无项目对话)'
|
|
239
|
+
}
|
|
240
|
+
className="mr-auto h-9 rounded-full px-3 text-sm text-agent-destructive transition-colors hover:bg-agent-destructive/10 disabled:opacity-40"
|
|
241
|
+
data-testid="edit-project-delete"
|
|
242
|
+
>
|
|
243
|
+
{confirmDelete ? '再次点击确认删除' : '删除本地项目'}
|
|
244
|
+
</button>
|
|
245
|
+
)}
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
onClick={onClose}
|
|
249
|
+
className={`h-9 rounded-full px-4 text-sm text-agent-muted-foreground transition-colors hover:bg-agent-muted hover:text-agent-foreground ${
|
|
250
|
+
onDelete ? '' : 'ml-auto'
|
|
251
|
+
}`}
|
|
252
|
+
>
|
|
253
|
+
取消
|
|
254
|
+
</button>
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
onClick={() => void handleSubmit()}
|
|
258
|
+
disabled={!trimmed || submitting || deleting}
|
|
259
|
+
className="h-9 rounded-full bg-agent-foreground px-4 text-sm font-medium text-agent-canvas transition-opacity disabled:opacity-40"
|
|
260
|
+
data-testid={isEdit ? 'edit-project-submit' : 'create-project-submit'}
|
|
261
|
+
>
|
|
262
|
+
{submitting ? (isEdit ? '保存中…' : '创建中…') : isEdit ? '保存' : '创建项目'}
|
|
263
|
+
</button>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</div>,
|
|
267
|
+
document.body,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
@@ -161,8 +161,8 @@ export function ApprovalPromptMenu({
|
|
|
161
161
|
</p>
|
|
162
162
|
)}
|
|
163
163
|
<p className="mt-2 text-[11px] leading-relaxed text-agent-muted-foreground">
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
默认在工作区沙箱中执行(只能写入当前项目或本对话工作区)。写入
|
|
165
|
+
Downloads 等工作区外路径会被系统拒绝;需要时在输入框把沙箱切到「完整权限」后再让
|
|
166
166
|
Agent 重试。
|
|
167
167
|
</p>
|
|
168
168
|
</>
|
|
@@ -7,9 +7,12 @@ import {
|
|
|
7
7
|
LuFolderPlus,
|
|
8
8
|
LuFolderX,
|
|
9
9
|
LuLoaderCircle,
|
|
10
|
+
LuPlus,
|
|
10
11
|
} from 'react-icons/lu';
|
|
12
|
+
import { CreateProjectModal } from '@/components/CreateProjectModal';
|
|
11
13
|
import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
|
|
12
14
|
import {
|
|
15
|
+
createProject,
|
|
13
16
|
updateChatProject,
|
|
14
17
|
updateProject,
|
|
15
18
|
type LocalProject,
|
|
@@ -93,14 +96,47 @@ function MenuShell({
|
|
|
93
96
|
);
|
|
94
97
|
}
|
|
95
98
|
|
|
96
|
-
function MenuSectionLabel({
|
|
99
|
+
function MenuSectionLabel({
|
|
100
|
+
children,
|
|
101
|
+
onAdd,
|
|
102
|
+
}: {
|
|
103
|
+
children: ReactNode;
|
|
104
|
+
onAdd?: () => void;
|
|
105
|
+
}) {
|
|
97
106
|
return (
|
|
98
|
-
<div className="
|
|
99
|
-
|
|
107
|
+
<div className="flex items-center justify-between gap-1 px-2 py-1">
|
|
108
|
+
<div className="text-[10px] font-semibold uppercase tracking-wider text-agent-muted-foreground">
|
|
109
|
+
{children}
|
|
110
|
+
</div>
|
|
111
|
+
{onAdd && (
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
onClick={onAdd}
|
|
115
|
+
className="flex h-5 w-5 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/10 hover:text-agent-foreground"
|
|
116
|
+
title="新建项目"
|
|
117
|
+
aria-label="新建项目"
|
|
118
|
+
>
|
|
119
|
+
<LuPlus className="h-3 w-3" />
|
|
120
|
+
</button>
|
|
121
|
+
)}
|
|
100
122
|
</div>
|
|
101
123
|
);
|
|
102
124
|
}
|
|
103
125
|
|
|
126
|
+
function CreateProjectMenuItem({ onClick }: { onClick: () => void }) {
|
|
127
|
+
return (
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
role="menuitem"
|
|
131
|
+
onClick={onClick}
|
|
132
|
+
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5"
|
|
133
|
+
>
|
|
134
|
+
<LuFolderPlus className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
|
|
135
|
+
新建项目
|
|
136
|
+
</button>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
104
140
|
function ProjectMenuRow({
|
|
105
141
|
project,
|
|
106
142
|
selected,
|
|
@@ -147,9 +183,15 @@ export function ChatProjectBadge({
|
|
|
147
183
|
onChatProjectChanged: () => void | Promise<void>;
|
|
148
184
|
}) {
|
|
149
185
|
const [open, setOpen] = useState(false);
|
|
186
|
+
const [createOpen, setCreateOpen] = useState(false);
|
|
150
187
|
const [busy, setBusy] = useState(false);
|
|
151
188
|
const [error, setError] = useState<string | null>(null);
|
|
152
189
|
|
|
190
|
+
const openCreate = () => {
|
|
191
|
+
setOpen(false);
|
|
192
|
+
setCreateOpen(true);
|
|
193
|
+
};
|
|
194
|
+
|
|
153
195
|
const otherProjects = project
|
|
154
196
|
? projects.filter((p) => p.id !== project.id)
|
|
155
197
|
: projects;
|
|
@@ -226,7 +268,7 @@ export function ChatProjectBadge({
|
|
|
226
268
|
</div>
|
|
227
269
|
</>
|
|
228
270
|
) : (
|
|
229
|
-
<MenuSectionLabel>关联到项目</MenuSectionLabel>
|
|
271
|
+
<MenuSectionLabel onAdd={openCreate}>关联到项目</MenuSectionLabel>
|
|
230
272
|
)}
|
|
231
273
|
|
|
232
274
|
{otherProjects.length > 0 && (
|
|
@@ -247,9 +289,12 @@ export function ChatProjectBadge({
|
|
|
247
289
|
</>
|
|
248
290
|
)}
|
|
249
291
|
{!project && projects.length === 0 && (
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
292
|
+
<>
|
|
293
|
+
<div className="px-2 py-1.5 text-[11px] text-agent-muted-foreground/70">
|
|
294
|
+
还没有项目
|
|
295
|
+
</div>
|
|
296
|
+
<CreateProjectMenuItem onClick={openCreate} />
|
|
297
|
+
</>
|
|
253
298
|
)}
|
|
254
299
|
|
|
255
300
|
{project && (
|
|
@@ -283,6 +328,22 @@ export function ChatProjectBadge({
|
|
|
283
328
|
)}
|
|
284
329
|
</MenuShell>
|
|
285
330
|
)}
|
|
331
|
+
|
|
332
|
+
<CreateProjectModal
|
|
333
|
+
open={createOpen}
|
|
334
|
+
onClose={() => setCreateOpen(false)}
|
|
335
|
+
onCreate={async (input) => {
|
|
336
|
+
const res = await createProject({
|
|
337
|
+
name: input.name,
|
|
338
|
+
...(input.sourceFolders.length > 0 ? { sourceFolders: input.sourceFolders } : {}),
|
|
339
|
+
});
|
|
340
|
+
await onProjectsChanged();
|
|
341
|
+
if (res.project?.id) {
|
|
342
|
+
await updateChatProject(chatId, res.project.id);
|
|
343
|
+
await onChatProjectChanged();
|
|
344
|
+
}
|
|
345
|
+
}}
|
|
346
|
+
/>
|
|
286
347
|
</div>
|
|
287
348
|
);
|
|
288
349
|
}
|
|
@@ -293,15 +354,24 @@ export function ProjectPickerButton({
|
|
|
293
354
|
projects,
|
|
294
355
|
value,
|
|
295
356
|
onChange,
|
|
357
|
+
onProjectsChanged,
|
|
296
358
|
}: {
|
|
297
359
|
projects: LocalProject[];
|
|
298
360
|
/** 当前选中的 projectId;null = 无项目。 */
|
|
299
361
|
value: string | null;
|
|
300
362
|
onChange: (projectId: string | null) => void;
|
|
363
|
+
/** 新建项目后刷新列表。 */
|
|
364
|
+
onProjectsChanged: () => void | Promise<void>;
|
|
301
365
|
}) {
|
|
302
366
|
const [open, setOpen] = useState(false);
|
|
367
|
+
const [createOpen, setCreateOpen] = useState(false);
|
|
303
368
|
const selected = value ? (projects.find((p) => p.id === value) ?? null) : null;
|
|
304
369
|
|
|
370
|
+
const openCreate = () => {
|
|
371
|
+
setOpen(false);
|
|
372
|
+
setCreateOpen(true);
|
|
373
|
+
};
|
|
374
|
+
|
|
305
375
|
return (
|
|
306
376
|
<div className="relative">
|
|
307
377
|
<BadgeButton
|
|
@@ -313,13 +383,13 @@ export function ProjectPickerButton({
|
|
|
313
383
|
title={
|
|
314
384
|
selected
|
|
315
385
|
? `${selected.folderPath}\n新对话将绑定到此项目`
|
|
316
|
-
: '
|
|
386
|
+
: '为新对话选择项目。不选则在文稿/<应用名>/conversations/ 下建立本对话工作区'
|
|
317
387
|
}
|
|
318
388
|
/>
|
|
319
389
|
|
|
320
390
|
{open && (
|
|
321
391
|
<MenuShell onClose={() => setOpen(false)}>
|
|
322
|
-
<MenuSectionLabel>新对话所属项目</MenuSectionLabel>
|
|
392
|
+
<MenuSectionLabel onAdd={openCreate}>新对话所属项目</MenuSectionLabel>
|
|
323
393
|
<button
|
|
324
394
|
type="button"
|
|
325
395
|
role="menuitem"
|
|
@@ -349,12 +419,28 @@ export function ProjectPickerButton({
|
|
|
349
419
|
))}
|
|
350
420
|
</div>
|
|
351
421
|
{projects.length === 0 && (
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
422
|
+
<>
|
|
423
|
+
<div className="px-2 py-1.5 text-[11px] text-agent-muted-foreground/70">
|
|
424
|
+
还没有项目
|
|
425
|
+
</div>
|
|
426
|
+
<CreateProjectMenuItem onClick={openCreate} />
|
|
427
|
+
</>
|
|
355
428
|
)}
|
|
356
429
|
</MenuShell>
|
|
357
430
|
)}
|
|
431
|
+
|
|
432
|
+
<CreateProjectModal
|
|
433
|
+
open={createOpen}
|
|
434
|
+
onClose={() => setCreateOpen(false)}
|
|
435
|
+
onCreate={async (input) => {
|
|
436
|
+
const res = await createProject({
|
|
437
|
+
name: input.name,
|
|
438
|
+
...(input.sourceFolders.length > 0 ? { sourceFolders: input.sourceFolders } : {}),
|
|
439
|
+
});
|
|
440
|
+
await onProjectsChanged();
|
|
441
|
+
if (res.project?.id) onChange(res.project.id);
|
|
442
|
+
}}
|
|
443
|
+
/>
|
|
358
444
|
</div>
|
|
359
445
|
);
|
|
360
446
|
}
|
|
@@ -6,8 +6,8 @@ import type { ExecPolicy } from '@/lib/exec-policy';
|
|
|
6
6
|
* ExecPolicyPicker — 输入框上的命令沙箱切换(类 Codex 底部权限档)。
|
|
7
7
|
*
|
|
8
8
|
* 切档立刻记住,下一轮才生效:当前回合的 `execSandbox` 已经随流发出。
|
|
9
|
-
* 工作区 = Seatbelt/bwrap
|
|
10
|
-
*
|
|
9
|
+
* 工作区 = Seatbelt/bwrap 只允许写项目家目录或本对话工作区;完整权限
|
|
10
|
+
* = 不下发命令沙箱,本机路径(如 Downloads)不再被 Operation not permitted 拦住。
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const OPTIONS: Array<{
|
|
@@ -18,7 +18,8 @@ const OPTIONS: Array<{
|
|
|
18
18
|
{
|
|
19
19
|
id: 'workspace',
|
|
20
20
|
label: '工作区',
|
|
21
|
-
description:
|
|
21
|
+
description:
|
|
22
|
+
'命令只能写入当前项目或本对话工作区(文稿/<应用名>/conversations/);工作区外路径会被系统拒绝。',
|
|
22
23
|
},
|
|
23
24
|
{
|
|
24
25
|
id: 'full',
|
|
@@ -450,16 +450,18 @@ function AgentLayoutContent() {
|
|
|
450
450
|
<div className="relative flex h-full w-full bg-agent-muted/30">
|
|
451
451
|
{sidebarCollapsed ? (
|
|
452
452
|
// 收起态:窄 rail 只放展开按钮。新建对话仍可用 Cmd+N / 菜单触发。
|
|
453
|
-
<div className="flex h-full w-10 flex-shrink-0 flex-col items-center border-r border-agent-border/60 bg-agent-muted/70
|
|
454
|
-
<
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
453
|
+
<div className="flex h-full w-10 flex-shrink-0 flex-col items-center border-r border-agent-border/60 bg-agent-muted/70 backdrop-blur-md">
|
|
454
|
+
<div className="flex h-11 w-full items-center justify-center">
|
|
455
|
+
<button
|
|
456
|
+
type="button"
|
|
457
|
+
onClick={toggleSidebarCollapsed}
|
|
458
|
+
className="flex h-7 w-7 items-center justify-center rounded-full text-agent-muted-foreground transition-colors duration-200 hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
459
|
+
title="展开侧边栏"
|
|
460
|
+
aria-label="展开侧边栏"
|
|
461
|
+
>
|
|
462
|
+
<LuPanelLeftOpen className="h-3.5 w-3.5" />
|
|
463
|
+
</button>
|
|
464
|
+
</div>
|
|
463
465
|
</div>
|
|
464
466
|
) : (
|
|
465
467
|
<>
|
package/src/lib/exec-policy.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* 会话级命令执行沙箱(类 Codex 的 sandbox_mode)。
|
|
3
3
|
*
|
|
4
4
|
* 作用于下一轮 `chat.stream` 的 `execSandbox`,不是 sidecar 进程沙箱
|
|
5
|
-
* (设置页那一层)。工作区 =
|
|
6
|
-
*
|
|
5
|
+
* (设置页那一层)。工作区 = 只能写入当前项目或本对话工作区;完整权限
|
|
6
|
+
* = 关闭逐条命令沙箱。工具审批(允许/拒绝)仍按原策略生效。
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
export type ExecPolicy = 'workspace' | 'full';
|
package/src/lib/http-bridge.ts
CHANGED
|
@@ -44,6 +44,15 @@ function authHeaders(): Record<string, string> {
|
|
|
44
44
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function httpErrorMessage(data: unknown, status: number): string {
|
|
48
|
+
if (data && typeof data === 'object') {
|
|
49
|
+
const rec = data as { error?: unknown; detail?: unknown };
|
|
50
|
+
if (typeof rec.error === 'string' && rec.error.trim()) return rec.error;
|
|
51
|
+
if (typeof rec.detail === 'string' && rec.detail.trim()) return rec.detail;
|
|
52
|
+
}
|
|
53
|
+
return `Request failed (${status})`;
|
|
54
|
+
}
|
|
55
|
+
|
|
47
56
|
async function http<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
48
57
|
const res = await fetch(path, {
|
|
49
58
|
method,
|
|
@@ -53,11 +62,9 @@ async function http<T>(method: string, path: string, body?: unknown): Promise<T>
|
|
|
53
62
|
},
|
|
54
63
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
55
64
|
});
|
|
56
|
-
const data = (await res.json().catch(() => null)) as T
|
|
65
|
+
const data = (await res.json().catch(() => null)) as T;
|
|
57
66
|
if (!res.ok) {
|
|
58
|
-
const err = new Error(
|
|
59
|
-
(data as { detail?: string } | null)?.detail || `Request failed (${res.status})`,
|
|
60
|
-
) as Error & { status?: number };
|
|
67
|
+
const err = new Error(httpErrorMessage(data, res.status)) as Error & { status?: number };
|
|
61
68
|
err.status = res.status;
|
|
62
69
|
throw err;
|
|
63
70
|
}
|
|
@@ -197,9 +204,15 @@ export function createHttpBridge(): ElectronBridge {
|
|
|
197
204
|
platform: boot.platform,
|
|
198
205
|
|
|
199
206
|
local: {
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
207
|
+
// 浏览器 File System Access API 不给真实路径。改走宿主
|
|
208
|
+
// `/host/local/select-directory`(本机系统选择器);取消或无 GUI
|
|
209
|
+
// 时返回 canceled,UI 再走手动输入路径。
|
|
210
|
+
selectDirectory: async (options) =>
|
|
211
|
+
http<{ canceled: boolean; filePaths: string[] }>(
|
|
212
|
+
'POST',
|
|
213
|
+
'/host/local/select-directory',
|
|
214
|
+
options ?? {},
|
|
215
|
+
),
|
|
203
216
|
saveTextFile: async (options) => {
|
|
204
217
|
const blob = new Blob([options.content], { type: 'application/json' });
|
|
205
218
|
const url = URL.createObjectURL(blob);
|
package/src/lib/local-api.ts
CHANGED
|
@@ -353,7 +353,7 @@ export interface ResolvedLocalPath {
|
|
|
353
353
|
|
|
354
354
|
/**
|
|
355
355
|
* 批量确认正文里提到的路径是否真实存在(用于决定行内代码要不要变成可点击)。
|
|
356
|
-
*
|
|
356
|
+
* 相对路径由后端按对话绑定的项目根解析,无项目时按对话工作区;不存在的候选不回。
|
|
357
357
|
*/
|
|
358
358
|
export async function resolveLocalPaths(candidates: string[], chatId?: string | null) {
|
|
359
359
|
return bridge().localBackend.request<{ resolved: ResolvedLocalPath[] }>({
|
|
@@ -369,6 +369,8 @@ export interface LocalProject {
|
|
|
369
369
|
id: string;
|
|
370
370
|
name: string;
|
|
371
371
|
folderPath: string;
|
|
372
|
+
/** 附加源文件夹(只读)。缺省空。 */
|
|
373
|
+
sourceFolders?: string[];
|
|
372
374
|
/** W6-5: 信任后该项目目录里的规则文件才会注入模型上下文。缺省 false。 */
|
|
373
375
|
trusted?: boolean;
|
|
374
376
|
createdAt: string;
|
|
@@ -473,7 +475,11 @@ export async function listProjects() {
|
|
|
473
475
|
});
|
|
474
476
|
}
|
|
475
477
|
|
|
476
|
-
export async function createProject(input: {
|
|
478
|
+
export async function createProject(input: {
|
|
479
|
+
name: string;
|
|
480
|
+
folderPath?: string;
|
|
481
|
+
sourceFolders?: string[];
|
|
482
|
+
}) {
|
|
477
483
|
return bridge().localBackend.request<{ success: boolean; project: LocalProject }>({
|
|
478
484
|
method: 'POST',
|
|
479
485
|
path: '/api/v2/projects',
|
|
@@ -483,7 +489,7 @@ export async function createProject(input: { name: string; folderPath: string })
|
|
|
483
489
|
|
|
484
490
|
export async function updateProject(
|
|
485
491
|
projectId: string,
|
|
486
|
-
updates: { name?: string; folderPath?: string },
|
|
492
|
+
updates: { name?: string; folderPath?: string; sourceFolders?: string[] },
|
|
487
493
|
) {
|
|
488
494
|
return bridge().localBackend.request<{ success: boolean; project: LocalProject }>({
|
|
489
495
|
method: 'PUT',
|
package/src/pages/AgentPage.tsx
CHANGED
|
@@ -1234,18 +1234,18 @@ function EmptyChatGate() {
|
|
|
1234
1234
|
useEffect(() => {
|
|
1235
1235
|
setSelectedProjectId(projectIdFromUrl);
|
|
1236
1236
|
}, [projectIdFromUrl]);
|
|
1237
|
-
|
|
1237
|
+
const fetchProjects = useCallback(async () => {
|
|
1238
1238
|
if (!isElectron()) return;
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
return () => {
|
|
1246
|
-
cancelled = true;
|
|
1247
|
-
};
|
|
1239
|
+
try {
|
|
1240
|
+
const res = await listProjects();
|
|
1241
|
+
setProjects(res.projects ?? []);
|
|
1242
|
+
} catch {
|
|
1243
|
+
/* 列表失败保持旧数据 */
|
|
1244
|
+
}
|
|
1248
1245
|
}, []);
|
|
1246
|
+
useEffect(() => {
|
|
1247
|
+
void fetchProjects();
|
|
1248
|
+
}, [fetchProjects]);
|
|
1249
1249
|
|
|
1250
1250
|
useEffect(() => {
|
|
1251
1251
|
inputRef.current?.focusAtEnd();
|
|
@@ -1382,6 +1382,7 @@ function EmptyChatGate() {
|
|
|
1382
1382
|
projects={projects}
|
|
1383
1383
|
value={selectedProjectId}
|
|
1384
1384
|
onChange={setSelectedProjectId}
|
|
1385
|
+
onProjectsChanged={fetchProjects}
|
|
1385
1386
|
/>
|
|
1386
1387
|
) : undefined
|
|
1387
1388
|
}
|