@steerable/agent-shell-web 0.6.32 → 0.6.33

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-web",
3
- "version": "0.6.32",
3
+ "version": "0.6.33",
4
4
  "type": "module",
5
5
  "description": "Steerable framework — product-neutral renderer SPA source for @steerable/agent-shell (React + Vite). Consumed by product web entry points via the `@/` alias (anchored at this package's src/) and the createProductViteConfig factory. Published as a source package (src/), plus the compiled vite.base config factory (vite.base.js + .d.ts) for Node-side config loading.",
6
6
  "license": "Apache-2.0",
@@ -15,10 +15,7 @@
15
15
  },
16
16
  "exports": {
17
17
  "./auth/gate": "./src/auth/gate.ts",
18
- "./vite.base": {
19
- "types": "./vite.base.d.ts",
20
- "default": "./vite.base.js"
21
- },
18
+ "./vite.base": "./vite.base.ts",
22
19
  "./package.json": "./package.json"
23
20
  },
24
21
  "files": [
@@ -46,8 +43,8 @@
46
43
  "react-router-dom": "^6.30.0",
47
44
  "rehype-raw": "^7.0.0",
48
45
  "remark-gfm": "^4.0.1",
49
- "@steerable/agent-protocol": "0.6.32",
50
- "@steerable/agent-ui": "0.6.32"
46
+ "@steerable/agent-protocol": "0.6.33",
47
+ "@steerable/agent-ui": "0.6.33"
51
48
  },
52
49
  "devDependencies": {
53
50
  "@tailwindcss/vite": "^4.3.0",
@@ -60,7 +57,7 @@
60
57
  "typescript": "^5.8.3",
61
58
  "vite": "^5.4.21",
62
59
  "vitest": "^3.2.4",
63
- "@steerable/pack-sdk": "0.6.32"
60
+ "@steerable/pack-sdk": "0.6.33"
64
61
  },
65
62
  "scripts": {
66
63
  "gen": "echo agent-shell-web",
@@ -103,6 +103,7 @@ import {
103
103
  import { parseChatTitle } from "@/lib/chat-title";
104
104
  import { getDateGroupLabel, getDateGroupPriority } from "@/lib/date-groups";
105
105
  import { getElectronBridge, isElectron } from "@/lib/electron-bridge";
106
+ import { hasGeneralSettingsChrome, hostToolChrome, settingsChrome } from "@/lib/host-tools";
106
107
  import {
107
108
  createProject,
108
109
  deleteProject,
@@ -173,7 +174,7 @@ function ProjectOverflowMenu({
173
174
  onClose: () => void;
174
175
  onRename: () => void;
175
176
  onChangeFolder: () => void;
176
- onReveal: () => void;
177
+ onReveal?: () => void;
177
178
  onDelete: () => void;
178
179
  }) {
179
180
  const pos = placeProjectMenu(anchor);
@@ -229,6 +230,7 @@ function ProjectOverflowMenu({
229
230
  <LuFolderOpen className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
230
231
  编辑项目
231
232
  </button>
233
+ {onReveal && (
232
234
  <button
233
235
  type="button"
234
236
  role="menuitem"
@@ -239,6 +241,7 @@ function ProjectOverflowMenu({
239
241
  <LuFolder className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
240
242
  {revealLabel}
241
243
  </button>
244
+ )}
242
245
  <div className="mx-1 my-1 border-t border-agent-border/60" />
243
246
  <button
244
247
  type="button"
@@ -351,8 +354,10 @@ export function AgentSidebar({
351
354
  anchor: HTMLElement;
352
355
  } | null>(null);
353
356
 
357
+ const showProjectsChrome = hostToolChrome("projects");
358
+
354
359
  const fetchProjects = useCallback(async () => {
355
- if (!isElectron()) return;
360
+ if (!isElectron() || !hostToolChrome("projects")) return;
356
361
  try {
357
362
  const res = await listProjects();
358
363
  setProjects(res.projects || []);
@@ -547,8 +552,11 @@ export function AgentSidebar({
547
552
  // 顶层按项目分组:无项目对话(含项目已被删但列表还没刷新的孤儿会话)
548
553
  // 保持原有的日期分组;每个项目一个分组,组内按 pin + 时间排序。
549
554
  const knownProjectIds = useMemo(
550
- () => new Set(projects.map((p) => p.id)),
551
- [projects],
555
+ () =>
556
+ showProjectsChrome
557
+ ? new Set(projects.map((p) => p.id))
558
+ : new Set<string>(),
559
+ [projects, showProjectsChrome],
552
560
  );
553
561
 
554
562
  const noProjectChats = useMemo(
@@ -561,11 +569,13 @@ export function AgentSidebar({
561
569
 
562
570
  const projectGroups = useMemo(
563
571
  () =>
564
- projects.map((project) => ({
565
- project,
566
- items: normalizedChats.filter((c) => c.projectId === project.id),
567
- })),
568
- [projects, normalizedChats],
572
+ showProjectsChrome
573
+ ? projects.map((project) => ({
574
+ project,
575
+ items: normalizedChats.filter((c) => c.projectId === project.id),
576
+ }))
577
+ : [],
578
+ [projects, normalizedChats, showProjectsChrome],
569
579
  );
570
580
 
571
581
  const chatGroups = useMemo(() => {
@@ -750,15 +760,17 @@ export function AgentSidebar({
750
760
  // `menu:open-terminal`). Same pattern as `menu:new-chat` above. The
751
761
  // terminal is a toggleable panel beside the chat, so this just flips
752
762
  // the layout state owned by AgentLayout.
763
+ const showTerminalChrome = hostToolChrome("terminal");
764
+
753
765
  useEffect(() => {
754
- if (!bridge?.onMenuOpenTerminal) return;
766
+ if (!showTerminalChrome || !bridge?.onMenuOpenTerminal) return;
755
767
  bridge.onMenuOpenTerminal(() => {
756
768
  onToggleRightPanel("terminal");
757
769
  });
758
770
  return () => {
759
771
  bridge.offMenuOpenTerminal?.();
760
772
  };
761
- }, [bridge, onToggleRightPanel]);
773
+ }, [bridge, onToggleRightPanel, showTerminalChrome]);
762
774
 
763
775
  const hasElectron = isElectron();
764
776
 
@@ -799,6 +811,7 @@ export function AgentSidebar({
799
811
  <LuPlus className="h-3.5 w-3.5" />
800
812
  <span>新对话</span>
801
813
  </button>
814
+ {settingsChrome("agents") && (
802
815
  <button
803
816
  type="button"
804
817
  onClick={() => navigate("/settings?section=agents")}
@@ -814,6 +827,8 @@ export function AgentSidebar({
814
827
  <LuBot className="h-3.5 w-3.5" />
815
828
  <span>智能体管理</span>
816
829
  </button>
830
+ )}
831
+ {settingsChrome("skills") && (
817
832
  <button
818
833
  type="button"
819
834
  onClick={() => navigate("/settings?section=skills")}
@@ -829,6 +844,8 @@ export function AgentSidebar({
829
844
  <LuBlocks className="h-3.5 w-3.5" />
830
845
  <span>Skill 设置</span>
831
846
  </button>
847
+ )}
848
+ {settingsChrome("mcp") && (
832
849
  <button
833
850
  type="button"
834
851
  onClick={() => navigate("/settings?section=mcp")}
@@ -844,6 +861,7 @@ export function AgentSidebar({
844
861
  <LuPlug className="h-3.5 w-3.5" />
845
862
  <span>MCP 设置</span>
846
863
  </button>
864
+ )}
847
865
  </div>
848
866
  <div className="flex flex-shrink-0 items-center justify-between py-1 pl-2.5 pr-2.5">
849
867
  <button
@@ -861,7 +879,7 @@ export function AgentSidebar({
861
879
  </span>
862
880
  </button>
863
881
  <div className="flex items-center gap-1">
864
- {hasElectron && (
882
+ {hasElectron && showProjectsChrome && (
865
883
  <button
866
884
  type="button"
867
885
  onClick={() => setCreateProjectOpen(true)}
@@ -893,7 +911,8 @@ export function AgentSidebar({
893
911
  ) : (
894
912
  <>
895
913
  {/* 项目分组在前:组头可折叠,hover 出 + 新建对话 / ·· 菜单 */}
896
- {projectGroups.map(({ project, items }) => (
914
+ {showProjectsChrome &&
915
+ projectGroups.map(({ project, items }) => (
897
916
  <div key={project.id} className="mb-1">
898
917
  <div className="group/proj relative">
899
918
  {renamingProjectId === project.id ? (
@@ -1036,6 +1055,7 @@ export function AgentSidebar({
1036
1055
  {/* ───── Footer: 右侧面板切换(终端 | 包槽位)+ 设置 ───── */}
1037
1056
  <div className="flex-shrink-0 border-t border-agent-border/40 px-2.5 py-1.5">
1038
1057
  {chatSlots.length === 0 ? (
1058
+ showTerminalChrome ? (
1039
1059
  <button
1040
1060
  type="button"
1041
1061
  onClick={() => onToggleRightPanel("terminal")}
@@ -1054,12 +1074,14 @@ export function AgentSidebar({
1054
1074
  {isMac ? "⌘T" : "Ctrl+T"}
1055
1075
  </span>
1056
1076
  </button>
1077
+ ) : null
1057
1078
  ) : (
1058
1079
  <div
1059
1080
  role="group"
1060
1081
  aria-label="右侧面板切换"
1061
1082
  className="flex h-7 w-full items-center gap-0.5 rounded-full bg-agent-foreground/5 p-0.5"
1062
1083
  >
1084
+ {showTerminalChrome ? (
1063
1085
  <button
1064
1086
  type="button"
1065
1087
  onClick={() => onToggleRightPanel("terminal")}
@@ -1075,6 +1097,7 @@ export function AgentSidebar({
1075
1097
  <LuTerminal className="h-3.5 w-3.5" />
1076
1098
  <span>终端</span>
1077
1099
  </button>
1100
+ ) : null}
1078
1101
  {chatSlots.map((slot) => (
1079
1102
  <button
1080
1103
  key={slot.slotId}
@@ -1095,6 +1118,7 @@ export function AgentSidebar({
1095
1118
  ))}
1096
1119
  </div>
1097
1120
  )}
1121
+ {hasGeneralSettingsChrome() && (
1098
1122
  <button
1099
1123
  type="button"
1100
1124
  onClick={() => navigate("/settings")}
@@ -1110,6 +1134,7 @@ export function AgentSidebar({
1110
1134
  <LuSettings className="h-3.5 w-3.5" />
1111
1135
  <span>设置</span>
1112
1136
  </button>
1137
+ )}
1113
1138
  </div>
1114
1139
 
1115
1140
  {projectMenu &&
@@ -1137,10 +1162,14 @@ export function AgentSidebar({
1137
1162
  setProjectMenu(null);
1138
1163
  setEditingProjectId(project.id);
1139
1164
  }}
1140
- onReveal={() => {
1141
- setProjectMenu(null);
1142
- void handleRevealProjectFolder(project.folderPath);
1143
- }}
1165
+ onReveal={
1166
+ hostToolChrome("local-fs")
1167
+ ? () => {
1168
+ setProjectMenu(null);
1169
+ void handleRevealProjectFolder(project.folderPath);
1170
+ }
1171
+ : undefined
1172
+ }
1144
1173
  onDelete={() => void handleDeleteProject(project.id)}
1145
1174
  />
1146
1175
  );
@@ -77,16 +77,18 @@ export function CreateProjectModal({
77
77
  addSourceFolder(typed);
78
78
  return;
79
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
- }
80
+ const picker = getElectronBridge()?.local?.selectDirectory;
81
+ if (!picker) {
82
+ setError('请输入文件夹路径');
87
83
  return;
88
84
  }
89
- addSourceFolder(result.filePaths[0]);
85
+ try {
86
+ const result = await picker({ title: '添加源文件夹' });
87
+ if (!result || result.canceled || result.filePaths.length === 0) return;
88
+ addSourceFolder(result.filePaths[0]);
89
+ } catch (err) {
90
+ setError(err instanceof Error ? err.message : String(err));
91
+ }
90
92
  };
91
93
 
92
94
  const handleSubmit = async () => {
@@ -32,6 +32,7 @@ import {
32
32
  } from 'react-icons/lu';
33
33
  import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
34
34
  import type { ExecPolicy } from '@/lib/exec-policy';
35
+ import { getWebChatModes, hostToolChrome, settingsChrome } from '@/lib/host-tools';
35
36
  import type { AttachmentFile } from '@/lib/attachments';
36
37
  import type { SteerOutcome } from '@steerable/agent-ui';
37
38
  import {
@@ -1112,8 +1113,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1112
1113
  const trimmed = value.trim();
1113
1114
  const canSend = (trimmed.length > 0 || actualFiles.length > 0) && !disabled && !isStreaming;
1114
1115
 
1116
+ const allowFileAttach = hostToolChrome('local-fs');
1117
+
1115
1118
  const handleDragOver = (event: React.DragEvent) => {
1116
1119
  event.preventDefault();
1120
+ if (!allowFileAttach) return;
1117
1121
  if (event.dataTransfer.types.includes('Files') && !disabled) {
1118
1122
  setIsDragging(true);
1119
1123
  }
@@ -1143,7 +1147,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1143
1147
  // 按钮点击 → 触发隐藏的 <input type="file" multiple>,由浏览器/Electron
1144
1148
  // 直接弹出系统文件选择框(不依赖 IPC,避免「点击无反应」)。
1145
1149
  const handlePickFiles = () => {
1146
- if (disabled) return;
1150
+ if (disabled || !allowFileAttach) return;
1147
1151
  fileInputRef.current?.click();
1148
1152
  };
1149
1153
  const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -1158,7 +1162,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1158
1162
  event.preventDefault();
1159
1163
  setIsDragging(false);
1160
1164
 
1161
- if (disabled) return;
1165
+ if (disabled || !allowFileAttach) return;
1162
1166
 
1163
1167
  const droppedFiles = Array.from(event.dataTransfer.files);
1164
1168
  if (droppedFiles.length > 0) {
@@ -1816,7 +1820,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1816
1820
  Agent picker lives in the meta row above the box, next to
1817
1821
  the project badge. min-w-0 允许窄屏时胶囊截断收缩。 */}
1818
1822
  <div ref={toolbarLeftRef} className="flex min-w-0 items-center gap-1">
1819
- {mode && onModeChange && (
1823
+ {mode && onModeChange && getWebChatModes().length > 1 && (
1820
1824
  <ModeToggle
1821
1825
  mode={mode}
1822
1826
  disabled={disabled || isStreaming}
@@ -1831,6 +1835,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1831
1835
  />
1832
1836
  )}
1833
1837
  {toolbarExtras}
1838
+ {allowFileAttach && (
1839
+ <>
1834
1840
  <button
1835
1841
  type="button"
1836
1842
  onClick={handlePickFiles}
@@ -1851,6 +1857,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1851
1857
  tabIndex={-1}
1852
1858
  onChange={handleFileInputChange}
1853
1859
  />
1860
+ </>
1861
+ )}
1854
1862
  {onOpenSettings && (
1855
1863
  <button
1856
1864
  type="button"
@@ -2142,6 +2150,7 @@ function AgentSelect({
2142
2150
  </button>
2143
2151
  );
2144
2152
  })}
2153
+ {settingsChrome('agents') && (
2145
2154
  <button
2146
2155
  type="button"
2147
2156
  role="menuitem"
@@ -2155,6 +2164,7 @@ function AgentSelect({
2155
2164
  <LuBot className="h-3.5 w-3.5 shrink-0" />
2156
2165
  <span className="text-xs font-medium">管理智能体</span>
2157
2166
  </button>
2167
+ )}
2158
2168
  </div>
2159
2169
  )}
2160
2170
  </div>
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
2
  import { openLocalPath, type ResolvedLocalPath } from '@/lib/local-api';
3
+ import { hostToolChrome } from '@/lib/host-tools';
3
4
  import { peekResolvedPath, subscribeResolvedPath } from './path-mentions';
4
5
  import { splitTurnFilePath } from './turn-files';
5
6
 
@@ -27,12 +28,15 @@ export function FilePathCode({ candidate, chatId, children, ...rest }: FilePathC
27
28
  );
28
29
  const [openError, setOpenError] = useState<string | null>(null);
29
30
 
31
+ const allowOpenPath = hostToolChrome('local-fs');
32
+
30
33
  useEffect(() => {
31
34
  setOpenError(null);
35
+ if (!allowOpenPath) return;
32
36
  return subscribeResolvedPath(candidate, chatId, (entry) => setResolved(entry));
33
- }, [candidate, chatId]);
37
+ }, [allowOpenPath, candidate, chatId]);
34
38
 
35
- if (!resolved) {
39
+ if (!resolved || !allowOpenPath) {
36
40
  return (
37
41
  <code {...rest} className={INLINE_CODE_CLASS}>
38
42
  {children}
@@ -6,6 +6,7 @@ import {
6
6
  LuLoaderCircle,
7
7
  } from 'react-icons/lu';
8
8
  import { openLocalPath } from '@/lib/local-api';
9
+ import { hostToolChrome } from '@/lib/host-tools';
9
10
  import {
10
11
  formatFileSize,
11
12
  splitTurnFilePath,
@@ -30,10 +31,12 @@ interface TurnFilesCardProps {
30
31
  export function TurnFilesCard({ files }: TurnFilesCardProps) {
31
32
  const [openingPath, setOpeningPath] = useState<string | null>(null);
32
33
  const [openError, setOpenError] = useState<{ path: string; message: string } | null>(null);
34
+ const allowOpenPath = hostToolChrome('local-fs');
33
35
 
34
36
  if (files.length === 0) return null;
35
37
 
36
38
  const handleOpen = async (file: TurnFile) => {
39
+ if (!allowOpenPath) return;
37
40
  if (openingPath) return;
38
41
  setOpeningPath(file.path);
39
42
  setOpenError(null);
@@ -59,7 +62,11 @@ export function TurnFilesCard({ files }: TurnFilesCardProps) {
59
62
  >
60
63
  <div className="flex items-center gap-1.5 border-b border-agent-border/60 px-2.5 py-1 text-[11px] text-agent-muted-foreground">
61
64
  <LuFiles className="h-3.5 w-3.5" />
62
- <span>本轮产生了 {files.length} 个文件,点击打开</span>
65
+ <span>
66
+ {allowOpenPath
67
+ ? `本轮产生了 ${files.length} 个文件,点击打开`
68
+ : `本轮产生了 ${files.length} 个文件`}
69
+ </span>
63
70
  </div>
64
71
  <ul className="max-h-64 overflow-y-auto py-1">
65
72
  {files.map((file) => {
@@ -72,9 +79,13 @@ export function TurnFilesCard({ files }: TurnFilesCardProps) {
72
79
  <button
73
80
  type="button"
74
81
  onClick={() => void handleOpen(file)}
75
- disabled={openingPath !== null}
82
+ disabled={!allowOpenPath || openingPath !== null}
76
83
  title={file.path}
77
- className="flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs transition-colors hover:bg-agent-foreground/5 disabled:cursor-wait"
84
+ className={`flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs transition-colors ${
85
+ allowOpenPath
86
+ ? 'hover:bg-agent-foreground/5 disabled:cursor-wait'
87
+ : 'cursor-default'
88
+ }`}
78
89
  data-turn-file=""
79
90
  data-kind={file.kind}
80
91
  >
@@ -15,6 +15,7 @@ import {
15
15
  useChatsAndAgents,
16
16
  type UseChatsAndAgentsResult,
17
17
  } from '@/hooks/useChatsAndAgents';
18
+ import { hostToolChrome, sanitizeRightPanelKind, settingsChrome } from '@/lib/host-tools';
18
19
 
19
20
  /**
20
21
  * AgentLayout wraps `/agent`, `/agent/:chatId`, and `/` (default) with a
@@ -272,14 +273,16 @@ function AgentLayoutContent() {
272
273
  try {
273
274
  const saved = window.localStorage.getItem(RIGHT_PANEL_KEY);
274
275
  if (saved !== null) {
275
- return saved === 'terminal' || packChatSlots.some((s) => s.slotId === saved)
276
- ? saved
277
- : null;
276
+ const raw =
277
+ saved === 'terminal' || packChatSlots.some((s) => s.slotId === saved)
278
+ ? saved
279
+ : null;
280
+ return sanitizeRightPanelKind(raw);
278
281
  }
279
282
  // 新 key 不存在时才迁移老版本仅持久化终端的旧 key。
280
- return window.localStorage.getItem(TERMINAL_OPEN_KEY) === '1'
281
- ? 'terminal'
282
- : null;
283
+ return sanitizeRightPanelKind(
284
+ window.localStorage.getItem(TERMINAL_OPEN_KEY) === '1' ? 'terminal' : null,
285
+ );
283
286
  } catch {
284
287
  return null;
285
288
  }
@@ -311,7 +314,8 @@ function AgentLayoutContent() {
311
314
  打开任一面板即离开任务 dock(栏位同一时刻只呈现一个内容)。 */
312
315
  const toggleRightPanel = useCallback(
313
316
  (kind: string) => {
314
- const next: RightPanelState = rightPanelRef.current === kind ? null : kind;
317
+ const raw: RightPanelState = rightPanelRef.current === kind ? null : kind;
318
+ const next = sanitizeRightPanelKind(raw);
315
319
  rightPanelRef.current = next;
316
320
  if (next !== null) setInspectedTask(null);
317
321
  setRightPanelState(next);
@@ -363,6 +367,7 @@ function AgentLayoutContent() {
363
367
  }, []);
364
368
 
365
369
  const showTerminalFromTask = useCallback(() => {
370
+ if (!hostToolChrome('terminal')) return;
366
371
  setInspectedTask(null);
367
372
  rightPanelRef.current = 'terminal';
368
373
  setRightPanelState('terminal');
@@ -499,7 +504,7 @@ function AgentLayoutContent() {
499
504
  } satisfies AgentOutletContext}
500
505
  />
501
506
  </div>
502
- <InsightsConsentBanner />
507
+ {settingsChrome('insights') && <InsightsConsentBanner />}
503
508
  </div>
504
509
  {(rightPanel !== null || inspectedTask !== null) && (
505
510
  <>
@@ -523,7 +528,9 @@ function AgentLayoutContent() {
523
528
  <TaskProcessPanel
524
529
  inspected={inspectedTask}
525
530
  onClose={closeTaskProcess}
526
- onShowTerminal={showTerminalFromTask}
531
+ onShowTerminal={
532
+ hostToolChrome('terminal') ? showTerminalFromTask : undefined
533
+ }
527
534
  />
528
535
  ) : rightPanel === 'terminal' ? (
529
536
  <TerminalPanel
@@ -6,6 +6,8 @@
6
6
  * = 关闭逐条命令沙箱。工具审批(允许/拒绝)仍按原策略生效。
7
7
  */
8
8
 
9
+ import { hostToolChrome } from './host-tools';
10
+
9
11
  export type ExecPolicy = 'workspace' | 'full';
10
12
 
11
13
  export const EXEC_POLICY_STORAGE_KEY = 'agent-exec-policy';
@@ -16,6 +18,7 @@ export function parseExecPolicy(value: unknown): ExecPolicy {
16
18
 
17
19
  export function readStoredExecPolicy(): ExecPolicy {
18
20
  if (typeof localStorage === 'undefined') return 'workspace';
21
+ if (!hostToolChrome('local-fs')) return 'workspace';
19
22
  return parseExecPolicy(localStorage.getItem(EXEC_POLICY_STORAGE_KEY));
20
23
  }
21
24
 
@@ -0,0 +1,206 @@
1
+ /**
2
+ * 渲染层读构建期注入的宿主工具族 / 安全询问。
3
+ * 解析规则与 ts/src/host-tools.ts 对齐:缺省全开;对象必须显式 chrome:true。
4
+ */
5
+
6
+ export type HostToolFamilyId =
7
+ | 'terminal'
8
+ | 'local-fs'
9
+ | 'projects'
10
+ | 'background-tasks'
11
+ | 'mcp'
12
+ | 'web'
13
+ | 'plugins';
14
+
15
+ export interface HostToolFamilySurface {
16
+ capability: boolean;
17
+ chrome: boolean;
18
+ }
19
+
20
+ export type HostToolsConfig = Partial<
21
+ Record<HostToolFamilyId, boolean | { capability?: boolean; chrome?: boolean }>
22
+ >;
23
+
24
+ export type ResolvedHostTools = Record<HostToolFamilyId, HostToolFamilySurface>;
25
+
26
+ const FAMILY_IDS: readonly HostToolFamilyId[] = [
27
+ 'terminal',
28
+ 'local-fs',
29
+ 'projects',
30
+ 'background-tasks',
31
+ 'mcp',
32
+ 'web',
33
+ 'plugins',
34
+ ];
35
+
36
+ function resolveSurface(
37
+ value: boolean | { capability?: boolean; chrome?: boolean } | undefined,
38
+ ): HostToolFamilySurface {
39
+ if (value === undefined || value === true) return { capability: true, chrome: true };
40
+ if (value === false) return { capability: false, chrome: false };
41
+ return {
42
+ capability: value.capability !== false,
43
+ chrome: value.chrome === true,
44
+ };
45
+ }
46
+
47
+ export function resolveWebHostTools(config?: HostToolsConfig | null): ResolvedHostTools {
48
+ const out = {} as ResolvedHostTools;
49
+ for (const id of FAMILY_IDS) {
50
+ out[id] = resolveSurface(config?.[id]);
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function readHostToolsConfig(): HostToolsConfig {
56
+ const raw = import.meta.env.VITE_HOST_TOOLS;
57
+ if (!raw) return {};
58
+ try {
59
+ const parsed = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
60
+ return parsed && typeof parsed === 'object' ? (parsed as HostToolsConfig) : {};
61
+ } catch {
62
+ return {};
63
+ }
64
+ }
65
+
66
+ let cached: ResolvedHostTools | null = null;
67
+
68
+ export function getWebHostTools(): ResolvedHostTools {
69
+ cached ??= resolveWebHostTools(readHostToolsConfig());
70
+ return cached;
71
+ }
72
+
73
+ export function hostToolChrome(id: HostToolFamilyId): boolean {
74
+ return getWebHostTools()[id].chrome;
75
+ }
76
+
77
+ export function hostToolCapability(id: HostToolFamilyId): boolean {
78
+ return getWebHostTools()[id].capability;
79
+ }
80
+
81
+ export function isWebApprovalEnabled(): boolean {
82
+ return import.meta.env.VITE_APPROVAL !== 'off';
83
+ }
84
+
85
+ export type ChatModeId = 'agent' | 'plan';
86
+
87
+ const DEFAULT_CHAT_MODES: ChatModeId[] = ['agent', 'plan'];
88
+
89
+ export function resolveWebChatModes(value?: unknown): ChatModeId[] {
90
+ if (!Array.isArray(value)) return [...DEFAULT_CHAT_MODES];
91
+ const allowed = DEFAULT_CHAT_MODES.filter((id) => value.includes(id));
92
+ return allowed.length > 0 ? allowed : ['agent'];
93
+ }
94
+
95
+ function readChatModesConfig(): unknown {
96
+ const raw = import.meta.env.VITE_CHAT_MODES;
97
+ if (!raw) return undefined;
98
+ try {
99
+ return typeof raw === 'string' ? JSON.parse(raw) : raw;
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ let cachedModes: ChatModeId[] | null = null;
106
+
107
+ export function getWebChatModes(): ChatModeId[] {
108
+ cachedModes ??= resolveWebChatModes(readChatModesConfig());
109
+ return cachedModes;
110
+ }
111
+
112
+ export function clampWebChatMode(requested: unknown): ChatModeId {
113
+ return requested === 'plan' && getWebChatModes().includes('plan') ? 'plan' : 'agent';
114
+ }
115
+
116
+ export const SETTINGS_ITEM_IDS = [
117
+ 'agents',
118
+ 'skills',
119
+ 'mcp',
120
+ 'appearance',
121
+ 'llm',
122
+ 'web-search',
123
+ 'usage',
124
+ 'diagnose',
125
+ 'security',
126
+ 'insights',
127
+ 'telemetry',
128
+ ] as const;
129
+
130
+ export type SettingsItemId = (typeof SETTINGS_ITEM_IDS)[number];
131
+
132
+ export const GENERAL_SETTINGS_ITEM_IDS = [
133
+ 'appearance',
134
+ 'llm',
135
+ 'web-search',
136
+ 'usage',
137
+ 'diagnose',
138
+ 'security',
139
+ 'insights',
140
+ 'telemetry',
141
+ ] as const;
142
+
143
+ export type SettingsChromeConfig = Partial<Record<SettingsItemId, boolean>>;
144
+
145
+ export type ResolvedSettingsChrome = Record<SettingsItemId, boolean>;
146
+
147
+ function settingsFollowsHostTool(id: SettingsItemId, tools: ResolvedHostTools): boolean {
148
+ if (id === 'skills') return tools.plugins.chrome;
149
+ if (id === 'mcp') return tools.mcp.chrome;
150
+ if (id === 'web-search') return tools.web.chrome;
151
+ return true;
152
+ }
153
+
154
+ export function resolveWebSettingsChrome(
155
+ value?: unknown,
156
+ tools: ResolvedHostTools = resolveWebHostTools(),
157
+ ): ResolvedSettingsChrome {
158
+ const config =
159
+ value && typeof value === 'object' && !Array.isArray(value)
160
+ ? (value as Record<string, unknown>)
161
+ : {};
162
+ const out = {} as ResolvedSettingsChrome;
163
+ for (const id of SETTINGS_ITEM_IDS) {
164
+ out[id] = config[id] !== false && settingsFollowsHostTool(id, tools);
165
+ }
166
+ return out;
167
+ }
168
+
169
+ function readSettingsConfig(): unknown {
170
+ const raw = import.meta.env.VITE_SETTINGS;
171
+ if (!raw) return undefined;
172
+ try {
173
+ return typeof raw === 'string' ? JSON.parse(raw) : raw;
174
+ } catch {
175
+ return undefined;
176
+ }
177
+ }
178
+
179
+ let cachedSettings: ResolvedSettingsChrome | null = null;
180
+
181
+ export function getWebSettingsChrome(): ResolvedSettingsChrome {
182
+ cachedSettings ??= resolveWebSettingsChrome(readSettingsConfig(), getWebHostTools());
183
+ return cachedSettings;
184
+ }
185
+
186
+ export function settingsChrome(id: SettingsItemId): boolean {
187
+ return getWebSettingsChrome()[id];
188
+ }
189
+
190
+ export function hasGeneralSettingsChrome(): boolean {
191
+ const chrome = getWebSettingsChrome();
192
+ return GENERAL_SETTINGS_ITEM_IDS.some((id) => chrome[id]);
193
+ }
194
+
195
+ export function sanitizeRightPanelKind(kind: string | null | undefined): string | null {
196
+ if (!kind) return null;
197
+ if (kind === 'terminal') return hostToolChrome('terminal') ? 'terminal' : null;
198
+ return kind;
199
+ }
200
+
201
+ /** 测试用:清掉缓存,让下次按新 env 重读。 */
202
+ export function resetWebHostToolsForTests(): void {
203
+ cached = null;
204
+ cachedModes = null;
205
+ cachedSettings = null;
206
+ }
@@ -22,6 +22,7 @@ import {
22
22
  readStoredExecPolicy,
23
23
  type ExecPolicy,
24
24
  } from "@/lib/exec-policy";
25
+ import { clampWebChatMode, hostToolChrome, settingsChrome } from "@/lib/host-tools";
25
26
  import type { ExecutedAction } from "@/components/chat/ExecutedActionsCard";
26
27
  import { inspectTaskTitle, type InspectTaskInput } from "@/components/chat/executed-actions-model";
27
28
  import type { ChildInfo } from "@/components/chat/OrchestrationChildrenCard";
@@ -85,10 +86,8 @@ type ChatMessageWithMetadata = ChatMessage & LocalChatMessage;
85
86
  const CHAT_MODE_STORAGE_KEY = "agent-chat-mode";
86
87
 
87
88
  function readStoredMode(): ChatMode {
88
- if (typeof localStorage === "undefined") return "agent";
89
- return localStorage.getItem(CHAT_MODE_STORAGE_KEY) === "plan"
90
- ? "plan"
91
- : "agent";
89
+ if (typeof localStorage === "undefined") return clampWebChatMode("agent");
90
+ return clampWebChatMode(localStorage.getItem(CHAT_MODE_STORAGE_KEY));
92
91
  }
93
92
 
94
93
  function extractPersistedActions(
@@ -374,8 +373,9 @@ function AgentChatView({
374
373
  // 当前会话绑定的项目 — Codex 式 cwd 指示:输入框上方显示项目名徽章,
375
374
  // 点击可关联到其他项目 / 修改项目目录 / 移出项目。无项目会话不显示。
376
375
  const [projects, setProjects] = useState<LocalProject[]>([]);
376
+ const showProjectsChrome = hostToolChrome("projects");
377
377
  const fetchProjects = useCallback(async () => {
378
- if (!isElectron()) return;
378
+ if (!isElectron() || !hostToolChrome("projects")) return;
379
379
  try {
380
380
  const res = await listProjects();
381
381
  setProjects(res.projects ?? []);
@@ -516,9 +516,10 @@ function AgentChatView({
516
516
  );
517
517
 
518
518
  const handleModeChange = useCallback((next: ChatMode) => {
519
- setMode(next);
519
+ const clamped = clampWebChatMode(next);
520
+ setMode(clamped);
520
521
  if (typeof localStorage !== "undefined") {
521
- localStorage.setItem(CHAT_MODE_STORAGE_KEY, next);
522
+ localStorage.setItem(CHAT_MODE_STORAGE_KEY, clamped);
522
523
  }
523
524
  }, []);
524
525
 
@@ -1121,7 +1122,9 @@ function AgentChatView({
1121
1122
  suggestedReplies={visibleSuggestedReplies}
1122
1123
  onSelectSuggestion={handleSelectSuggestion}
1123
1124
  onOpenSettings={
1124
- isElectron() ? () => setLlmSettingsOpen(true) : undefined
1125
+ isElectron() && settingsChrome("llm")
1126
+ ? () => setLlmSettingsOpen(true)
1127
+ : undefined
1125
1128
  }
1126
1129
  inputToolbarExtras={
1127
1130
  <ModelPicker
@@ -1134,25 +1137,31 @@ function AgentChatView({
1134
1137
  }
1135
1138
  mode={mode}
1136
1139
  onModeChange={handleModeChange}
1137
- execPolicy={execPolicy}
1138
- onExecPolicyChange={handleExecPolicyChange}
1140
+ execPolicy={hostToolChrome("local-fs") ? execPolicy : "workspace"}
1141
+ onExecPolicyChange={
1142
+ hostToolChrome("local-fs") ? handleExecPolicyChange : undefined
1143
+ }
1139
1144
  inputLeadingChrome={
1140
- <ChatProjectBadge
1141
- chatId={chatId}
1142
- project={chatProject}
1143
- projects={projects}
1144
- onProjectsChanged={fetchProjects}
1145
- onChatProjectChanged={ctx.refreshChats}
1146
- />
1145
+ showProjectsChrome ? (
1146
+ <ChatProjectBadge
1147
+ chatId={chatId}
1148
+ project={chatProject}
1149
+ projects={projects}
1150
+ onProjectsChanged={fetchProjects}
1151
+ onChatProjectChanged={ctx.refreshChats}
1152
+ />
1153
+ ) : undefined
1147
1154
  }
1148
1155
  inputBanner={
1149
1156
  <>
1150
1157
  {/* W6-5 项目信任门控:项目含规则文件但未信任时提示授权。 */}
1158
+ {showProjectsChrome ? (
1151
1159
  <ProjectTrustBanner
1152
1160
  chatId={chatId}
1153
1161
  project={chatProject}
1154
1162
  onTrustChanged={fetchProjects}
1155
1163
  />
1164
+ ) : null}
1156
1165
  {planReady && !isStreaming ? (
1157
1166
  <div className="mx-2.5 mb-1 flex items-center justify-between gap-2 rounded-agent-md border border-amber-400/50 bg-amber-400/10 px-2.5 py-1.5 text-xs">
1158
1167
  <div className="flex items-center gap-2 text-amber-700 dark:text-amber-300">
@@ -1183,7 +1192,7 @@ function AgentChatView({
1183
1192
  </>
1184
1193
  }
1185
1194
  />
1186
- {isElectron() && (
1195
+ {isElectron() && settingsChrome("llm") && (
1187
1196
  <LocalLlmSettingsModal
1188
1197
  open={llmSettingsOpen}
1189
1198
  onClose={() => setLlmSettingsOpen(false)}
@@ -1234,8 +1243,9 @@ function EmptyChatGate() {
1234
1243
  useEffect(() => {
1235
1244
  setSelectedProjectId(projectIdFromUrl);
1236
1245
  }, [projectIdFromUrl]);
1246
+ const showProjectsChrome = hostToolChrome("projects");
1237
1247
  const fetchProjects = useCallback(async () => {
1238
- if (!isElectron()) return;
1248
+ if (!isElectron() || !hostToolChrome("projects")) return;
1239
1249
  try {
1240
1250
  const res = await listProjects();
1241
1251
  setProjects(res.projects ?? []);
@@ -1258,9 +1268,10 @@ function EmptyChatGate() {
1258
1268
  null;
1259
1269
 
1260
1270
  const handleModeChange = useCallback((next: ChatMode) => {
1261
- setMode(next);
1271
+ const clamped = clampWebChatMode(next);
1272
+ setMode(clamped);
1262
1273
  if (typeof localStorage !== "undefined") {
1263
- localStorage.setItem(CHAT_MODE_STORAGE_KEY, next);
1274
+ localStorage.setItem(CHAT_MODE_STORAGE_KEY, clamped);
1264
1275
  }
1265
1276
  }, []);
1266
1277
 
@@ -1316,7 +1327,7 @@ function EmptyChatGate() {
1316
1327
  setCreateError(null);
1317
1328
  try {
1318
1329
  const id = await ctx.createChat({
1319
- ...(selectedProjectId ? { projectId: selectedProjectId } : {}),
1330
+ ...(selectedProjectId && showProjectsChrome ? { projectId: selectedProjectId } : {}),
1320
1331
  ...(ctx.selectedAgentId ? { agentId: ctx.selectedAgentId } : {}),
1321
1332
  });
1322
1333
  if (!id) throw new Error("创建对话失败,请重试");
@@ -1365,7 +1376,9 @@ function EmptyChatGate() {
1365
1376
  selectedAgentId={ctx.selectedAgentId}
1366
1377
  onSelectAgent={handleSelectAgent}
1367
1378
  onOpenSettings={
1368
- isElectron() ? () => setLlmSettingsOpen(true) : undefined
1379
+ isElectron() && settingsChrome("llm")
1380
+ ? () => setLlmSettingsOpen(true)
1381
+ : undefined
1369
1382
  }
1370
1383
  toolbarExtras={
1371
1384
  <ModelPicker
@@ -1377,7 +1390,7 @@ function EmptyChatGate() {
1377
1390
  />
1378
1391
  }
1379
1392
  leadingChrome={
1380
- isElectron() ? (
1393
+ isElectron() && showProjectsChrome ? (
1381
1394
  <ProjectPickerButton
1382
1395
  projects={projects}
1383
1396
  value={selectedProjectId}
@@ -1388,8 +1401,10 @@ function EmptyChatGate() {
1388
1401
  }
1389
1402
  mode={mode}
1390
1403
  onModeChange={handleModeChange}
1391
- execPolicy={execPolicy}
1392
- onExecPolicyChange={handleExecPolicyChange}
1404
+ execPolicy={hostToolChrome("local-fs") ? execPolicy : "workspace"}
1405
+ onExecPolicyChange={
1406
+ hostToolChrome("local-fs") ? handleExecPolicyChange : undefined
1407
+ }
1393
1408
  files={files}
1394
1409
  onFilesChange={setFiles}
1395
1410
  onMentionReferencesChange={setMentionReferences}
@@ -1411,7 +1426,7 @@ function EmptyChatGate() {
1411
1426
  </p>
1412
1427
  )}
1413
1428
  </div>
1414
- {isElectron() && (
1429
+ {isElectron() && settingsChrome("llm") && (
1415
1430
  <LocalLlmSettingsModal
1416
1431
  open={llmSettingsOpen}
1417
1432
  onClose={() => setLlmSettingsOpen(false)}
@@ -19,12 +19,15 @@ import { TelemetrySettingsPanel } from '@/components/settings/TelemetrySettingsP
19
19
  import { UsagePanel } from '@/components/settings/UsagePanel';
20
20
  import { WebSearchSettingsPanel } from '@/components/settings/WebSearchSettingsPanel';
21
21
  import { getPackSettingsPanels } from '@/packs/registry';
22
+ import { settingsChrome } from '@/lib/host-tools';
22
23
  import type { AgentOutletContext } from '@/layouts/AgentLayout';
23
24
 
24
25
  type SettingsSection = 'skills' | 'mcp' | 'agents' | 'general';
25
26
 
26
27
  function resolveSection(raw: string | null): SettingsSection {
27
- if (raw === 'skills' || raw === 'mcp' || raw === 'agents') return raw;
28
+ if (raw === 'skills' && settingsChrome('skills')) return 'skills';
29
+ if (raw === 'mcp' && settingsChrome('mcp')) return 'mcp';
30
+ if (raw === 'agents' && settingsChrome('agents')) return 'agents';
28
31
  return 'general';
29
32
  }
30
33
 
@@ -61,7 +64,7 @@ export function SettingsPage() {
61
64
  <div className="flex h-full w-full flex-col overflow-hidden">
62
65
  <header className="flex h-9 flex-shrink-0 items-center justify-between gap-2 border-b border-agent-border px-2.5">
63
66
  <h1 className="text-xs font-semibold text-agent-foreground">{title}</h1>
64
- {section === 'general' && (
67
+ {section === 'general' && settingsChrome('llm') && (
65
68
  <SettingsSaveButton
66
69
  testId="settings-header-save"
67
70
  saving={llmSaveUi.saving}
@@ -112,6 +115,7 @@ export function SettingsPage() {
112
115
 
113
116
  {section === 'general' && (
114
117
  <>
118
+ {settingsChrome('appearance') && (
115
119
  <section className="space-y-2" data-testid="settings-section-appearance">
116
120
  <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
117
121
  <LuMonitor className="h-3.5 w-3.5 text-agent-muted-foreground" />
@@ -119,7 +123,9 @@ export function SettingsPage() {
119
123
  </h2>
120
124
  <AppearanceSettingsPanel />
121
125
  </section>
126
+ )}
122
127
 
128
+ {settingsChrome('llm') && (
123
129
  <section className="space-y-2" data-testid="settings-section-llm">
124
130
  <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
125
131
  <LuSettings className="h-3.5 w-3.5 text-agent-muted-foreground" />
@@ -131,7 +137,9 @@ export function SettingsPage() {
131
137
  onSaveUiChange={setLlmSaveUi}
132
138
  />
133
139
  </section>
140
+ )}
134
141
 
142
+ {settingsChrome('web-search') && (
135
143
  <section className="space-y-2" data-testid="settings-section-web-search">
136
144
  <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
137
145
  <LuSearch className="h-3.5 w-3.5 text-agent-muted-foreground" />
@@ -139,7 +147,9 @@ export function SettingsPage() {
139
147
  </h2>
140
148
  <WebSearchSettingsPanel />
141
149
  </section>
150
+ )}
142
151
 
152
+ {settingsChrome('usage') && (
143
153
  <section className="space-y-2" data-testid="settings-section-usage">
144
154
  <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
145
155
  <LuChartBar className="h-3.5 w-3.5 text-agent-muted-foreground" />
@@ -147,7 +157,9 @@ export function SettingsPage() {
147
157
  </h2>
148
158
  <UsagePanel />
149
159
  </section>
160
+ )}
150
161
 
162
+ {settingsChrome('diagnose') && (
151
163
  <section className="space-y-2" data-testid="settings-section-diagnose">
152
164
  <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
153
165
  <LuNetwork className="h-3.5 w-3.5 text-agent-muted-foreground" />
@@ -155,7 +167,9 @@ export function SettingsPage() {
155
167
  </h2>
156
168
  <DiagnoseSettingsPanel />
157
169
  </section>
170
+ )}
158
171
 
172
+ {settingsChrome('security') && (
159
173
  <section className="space-y-2" data-testid="settings-section-security">
160
174
  <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
161
175
  <LuShieldCheck className="h-3.5 w-3.5 text-agent-muted-foreground" />
@@ -163,7 +177,9 @@ export function SettingsPage() {
163
177
  </h2>
164
178
  <SecuritySettingsPanel />
165
179
  </section>
180
+ )}
166
181
 
182
+ {settingsChrome('insights') && (
167
183
  <section className="space-y-2" data-testid="settings-section-insights">
168
184
  <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
169
185
  <LuChartBar className="h-3.5 w-3.5 text-agent-muted-foreground" />
@@ -171,7 +187,9 @@ export function SettingsPage() {
171
187
  </h2>
172
188
  <InsightsSettingsPanel />
173
189
  </section>
190
+ )}
174
191
 
192
+ {settingsChrome('telemetry') && (
175
193
  <section className="space-y-2" data-testid="settings-section-telemetry">
176
194
  <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
177
195
  <LuActivity className="h-3.5 w-3.5 text-agent-muted-foreground" />
@@ -179,6 +197,7 @@ export function SettingsPage() {
179
197
  </h2>
180
198
  <TelemetrySettingsPanel />
181
199
  </section>
200
+ )}
182
201
 
183
202
  {/* 场景包设置面板(1.2 起由包渲染层贡献)。
184
203
  包经 packs/registry 注册,未激活 flavor 的产物里没有包组件。 */}
package/src/vite-env.d.ts CHANGED
@@ -1 +1,17 @@
1
1
  /// <reference types="vite/client" />
2
+
3
+ interface ImportMetaEnv {
4
+ readonly VITE_APP_FLAVOR?: string;
5
+ readonly VITE_BRAND_NAME?: string;
6
+ readonly VITE_BRAND_TITLE?: string;
7
+ readonly VITE_BRAND_TAGLINE?: string;
8
+ readonly VITE_DEFAULT_AGENT_ID?: string;
9
+ readonly VITE_HOST_TOOLS?: string;
10
+ readonly VITE_APPROVAL?: string;
11
+ readonly VITE_CHAT_MODES?: string;
12
+ readonly VITE_SETTINGS?: string;
13
+ }
14
+
15
+ interface ImportMeta {
16
+ readonly env: ImportMetaEnv;
17
+ }
package/vite.base.js CHANGED
@@ -53,6 +53,11 @@ export function createProductViteConfig(options) {
53
53
  'import.meta.env.VITE_BRAND_TITLE': JSON.stringify(brand?.title ?? ''),
54
54
  'import.meta.env.VITE_BRAND_TAGLINE': JSON.stringify(brand?.tagline ?? '一款本地桌面 AI 伙伴'),
55
55
  'import.meta.env.VITE_DEFAULT_AGENT_ID': JSON.stringify(brand?.defaultAgentId ?? 'local-assistant'),
56
+ // 宿主工具族 / 安全询问:与 node 侧 setProductConfig 读同一份 product.json。
57
+ 'import.meta.env.VITE_HOST_TOOLS': JSON.stringify(JSON.stringify(product.hostTools ?? {})),
58
+ 'import.meta.env.VITE_APPROVAL': JSON.stringify(product.approval ?? 'host'),
59
+ 'import.meta.env.VITE_CHAT_MODES': JSON.stringify(JSON.stringify(product.chatModes ?? ['agent', 'plan'])),
60
+ 'import.meta.env.VITE_SETTINGS': JSON.stringify(JSON.stringify(product.settings ?? {})),
56
61
  },
57
62
  resolve: {
58
63
  alias: {