@steerable/agent-shell-web 0.6.51 → 0.6.53

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.
Files changed (38) hide show
  1. package/package.json +4 -4
  2. package/src/brand.ts +7 -0
  3. package/src/components/AgentSidebar.tsx +284 -188
  4. package/src/components/ConfirmDialog.tsx +90 -0
  5. package/src/components/CreateProjectModal.tsx +32 -16
  6. package/src/components/chat/ApprovalModal.tsx +2 -5
  7. package/src/components/chat/ApprovalPromptProvider.tsx +38 -22
  8. package/src/components/chat/AskUserPromptProvider.tsx +1 -32
  9. package/src/components/chat/ChatInput.tsx +129 -59
  10. package/src/components/chat/ChatProjectBadge.tsx +1 -1
  11. package/src/components/chat/EmptyChatHero.tsx +22 -0
  12. package/src/components/chat/ExecPolicyPicker.tsx +2 -2
  13. package/src/components/chat/ExecutedActionsCard.tsx +22 -12
  14. package/src/components/chat/LocalChatPanel.tsx +25 -19
  15. package/src/components/chat/MessageList.tsx +77 -5
  16. package/src/components/chat/ModelPicker.tsx +21 -1
  17. package/src/components/chat/OrchestrationFlowCard.tsx +13 -3
  18. package/src/components/chat/SessionOrchestrationFlow.tsx +66 -19
  19. package/src/components/chat/SessionTodoList.tsx +12 -3
  20. package/src/components/chat/UserMessage.tsx +116 -25
  21. package/src/components/chat/composer-status-stack.tsx +79 -0
  22. package/src/components/chat/orchestration-flow-model.ts +108 -43
  23. package/src/components/chat/prompt-chat-scope.ts +33 -0
  24. package/src/components/settings/AgentsSettingsPanel.tsx +33 -22
  25. package/src/components/settings/McpSettingsPanel.tsx +144 -48
  26. package/src/components/settings/SkillsSettingsPanel.tsx +1 -1
  27. package/src/hooks/useProjects.ts +82 -0
  28. package/src/lib/attachments.ts +8 -0
  29. package/src/lib/electron-bridge.ts +16 -1
  30. package/src/lib/exec-policy.ts +1 -1
  31. package/src/lib/host-file-drop.ts +68 -0
  32. package/src/lib/host-paste.ts +38 -9
  33. package/src/lib/local-api.ts +1 -1
  34. package/src/lib/tauri-bridge.ts +5 -0
  35. package/src/pages/AgentPage.tsx +114 -64
  36. package/src/pages/SettingsPage.tsx +131 -46
  37. package/src/vite-env.d.ts +2 -0
  38. package/vite.base.js +1 -0
@@ -9,6 +9,7 @@ import {
9
9
  regenerateChatMessage,
10
10
  } from "@/lib/chat-transport";
11
11
  import { ChatHeader } from "@/components/ChatHeader";
12
+ import { EmptyChatHero } from "@/components/chat/EmptyChatHero";
12
13
  import { LocalChatPanel } from "@/components/chat/LocalChatPanel";
13
14
  import { ModelPicker } from "@/components/chat/ModelPicker";
14
15
  import {
@@ -51,18 +52,17 @@ import {
51
52
  import { ProjectTrustBanner } from "@/components/chat/ProjectTrustBanner";
52
53
  import {
53
54
  getChatLiveStream,
54
- listProjects,
55
55
  type ChatLiveStream,
56
56
  type LocalChatMessage,
57
- type LocalProject,
58
57
  } from "@/lib/local-api";
58
+ import { useProjects } from "@/hooks/useProjects";
59
59
  import type { AgentOutletContext } from "@/layouts/AgentLayout";
60
60
  import {
61
61
  setPendingFirstMessage,
62
62
  takePendingFirstMessage,
63
63
  } from "@/lib/pending-first-message";
64
64
  import { isOrchestrationSettingEnabled } from "@/lib/orchestration-settings";
65
- import { BRAND_NAME, pickDefaultAgentId } from "@/brand";
65
+ import { pickDefaultAgentId } from "@/brand";
66
66
  import {
67
67
  appendAttachmentRefs,
68
68
  collectImageAttachments,
@@ -380,20 +380,8 @@ function AgentChatView({
380
380
 
381
381
  // 当前会话绑定的项目 — Codex 式 cwd 指示:输入框上方显示项目名徽章,
382
382
  // 点击可关联到其他项目 / 修改项目目录 / 移出项目。无项目会话不显示。
383
- const [projects, setProjects] = useState<LocalProject[]>([]);
384
383
  const showProjectsChrome = hostToolChrome("projects");
385
- const fetchProjects = useCallback(async () => {
386
- if (!isElectron() || !hostToolChrome("projects")) return;
387
- try {
388
- const res = await listProjects();
389
- setProjects(res.projects ?? []);
390
- } catch {
391
- /* 列表失败保持旧数据 */
392
- }
393
- }, []);
394
- useEffect(() => {
395
- void fetchProjects();
396
- }, [fetchProjects]);
384
+ const { projects, refresh: fetchProjects } = useProjects();
397
385
  const chatProject = chat?.projectId
398
386
  ? (projects.find((p) => p.id === chat.projectId) ?? null)
399
387
  : null;
@@ -695,6 +683,7 @@ function AgentChatView({
695
683
  pendingFollowUps,
696
684
  removeFollowUp,
697
685
  cancel,
686
+ setMessages,
698
687
  appendMessage,
699
688
  } = useChatStream({
700
689
  transport,
@@ -708,6 +697,15 @@ function AgentChatView({
708
697
  // 新 mount 没发起流,所以 isStreaming=false,但后端快照 liveStream.active
709
698
  // 为 true —— 此时用快照叠出「正在运行」的助手气泡 + 部分工具卡片/时间线。
710
699
  const remoteStreaming = liveStream.active === true && !isStreaming;
700
+ // 切回运行中的远端回合时,本 mount 没有 useChatStream 的本地队列。显式
701
+ // 保留 Enter 排队的消息,等远端回合结束后再由本 mount 依次发出。
702
+ const [remoteFollowUps, setRemoteFollowUps] = useState<string[]>([]);
703
+ const remoteFollowUpsRef = useRef<string[]>([]);
704
+ const updateRemoteFollowUps = useCallback((update: (current: string[]) => string[]) => {
705
+ const next = update(remoteFollowUpsRef.current);
706
+ remoteFollowUpsRef.current = next;
707
+ setRemoteFollowUps(next);
708
+ }, []);
711
709
 
712
710
  // 远端轮中转向:transport.steer 按 chatId 找活跃流,天然支持「切回后注入」
713
711
  // ——不像 useChatStream.steerOrFollowUpUserMessage 那样被本地 isStreamingRef
@@ -725,8 +723,8 @@ function AgentChatView({
725
723
  });
726
724
  return "steered";
727
725
  }
728
- // 回合可能恰好已结束——回落 queued 让 ChatInput 给出提示,紧跟着的
729
- // re-hydrate 会把最终消息拉出来。
726
+ // 回合可能恰好已结束——回落 queued,由 handleSteer 放进远端待发
727
+ // 队列;轮询确认回合结束后先对账历史,再启动下一轮。
730
728
  return "queued";
731
729
  },
732
730
  [transport, appendMessage],
@@ -752,16 +750,9 @@ function AgentChatView({
752
750
  };
753
751
  }, [chatId, remoteStreaming]);
754
752
 
755
- // 快照从 active=true → false 的边沿:远端回合结束了,re-hydrate 出最终
756
- // 落库消息(含真实 message id / executedActions / timeline)。
753
+ // 快照从 active=true → false 的边沿在 handleSubmit 声明后处理:没有排队
754
+ // 消息时重新水合,有排队消息时直接启动下一轮。
757
755
  const wasLiveActiveRef = useRef(liveStream.active === true);
758
- useEffect(() => {
759
- const nowActive = liveStream.active === true;
760
- if (wasLiveActiveRef.current && !nowActive) {
761
- onBranchTick();
762
- }
763
- wasLiveActiveRef.current = nowActive;
764
- }, [liveStream.active, onBranchTick]);
765
756
 
766
757
  // W7-1: 中断提示卡的可见性。初值来自 messages 响应的 interrupted 标记;
767
758
  // 点击「继续」/「忽略」或用户手动发起新一轮时本地清除(继续成功后后端的
@@ -821,7 +812,8 @@ function AgentChatView({
821
812
  const handleCancel = useCallback(() => {
822
813
  transport.cancelActive();
823
814
  cancel();
824
- }, [transport, cancel]);
815
+ updateRemoteFollowUps(() => []);
816
+ }, [transport, cancel, updateRemoteFollowUps]);
825
817
 
826
818
  // Reset the in-flight queue on every new user submit. We do this in a
827
819
  // wrapper rather than directly in `handleUnknownEvent`, because there's no
@@ -887,6 +879,48 @@ function AgentChatView({
887
879
  [handleSubmit],
888
880
  );
889
881
 
882
+ // 远端回合结束时,优先发送用户在等待期间排队的内容。第一条启动新回合,
883
+ // 其余条目进入 useChatStream 的本地队列;没有排队内容才重新水合历史。
884
+ useEffect(() => {
885
+ const nowActive = liveStream.active === true;
886
+ if (wasLiveActiveRef.current && !nowActive) {
887
+ const queued = remoteFollowUpsRef.current;
888
+ if (queued.length > 0) {
889
+ updateRemoteFollowUps(() => []);
890
+ void (async () => {
891
+ try {
892
+ const response = await getElectronBridge()?.localBackend.request<{
893
+ messages?: ChatMessageWithMetadata[];
894
+ }>({
895
+ method: "GET",
896
+ path: `/api/v2/chats/${encodeURIComponent(chatId)}/messages?limit=200`,
897
+ });
898
+ if (response?.messages) {
899
+ setMessages(chronological(response.messages));
900
+ }
901
+ } catch {
902
+ // 排队内容仍须发出;后续刷新会补齐刚结束的远端回复。
903
+ }
904
+ void handleSubmit({ content: queued[0] });
905
+ for (const content of queued.slice(1)) {
906
+ followUpUserMessage({ content });
907
+ }
908
+ })();
909
+ } else {
910
+ onBranchTick();
911
+ }
912
+ }
913
+ wasLiveActiveRef.current = nowActive;
914
+ }, [
915
+ followUpUserMessage,
916
+ chatId,
917
+ handleSubmit,
918
+ liveStream.active,
919
+ onBranchTick,
920
+ setMessages,
921
+ updateRemoteFollowUps,
922
+ ]);
923
+
890
924
  useEffect(() => {
891
925
  const bridge = getElectronBridge();
892
926
  if (!bridge?.onSuggestedReplies) return;
@@ -946,28 +980,38 @@ function AgentChatView({
946
980
  setInterrupted(false);
947
981
  }, []);
948
982
 
949
- // 轮中转向 + 失败兜底(W6-2):streaming 期间 Enter 优先注入运行中的
983
+ // 轮中插队 + 失败兜底(W6-2):streaming 期间 ⌘/Ctrl+Enter 优先注入运行中的
950
984
  // CoreLoop 回合;注入不了时 hook 按回合实况降级——仍在跑则排入待发队列
951
985
  // ('queued',ChatInput 提示"已改为排队"),恰好已结束则作为新回合直发
952
986
  // ('sent')。消息不会丢,ChatInput 对任何结果都清草稿。
953
987
  const handleSteer = useCallback(
954
- (text: string) =>
955
- remoteStreaming ? steerRemote(text) : steerOrFollowUpUserMessage(text),
956
- [remoteStreaming, steerRemote, steerOrFollowUpUserMessage],
988
+ async (text: string) => {
989
+ if (!remoteStreaming) return steerOrFollowUpUserMessage(text);
990
+ const outcome = await steerRemote(text);
991
+ if (outcome === "queued") {
992
+ updateRemoteFollowUps((current) => [...current, text]);
993
+ }
994
+ return outcome;
995
+ },
996
+ [
997
+ remoteStreaming,
998
+ steerRemote,
999
+ steerOrFollowUpUserMessage,
1000
+ updateRemoteFollowUps,
1001
+ ],
957
1002
  );
958
1003
 
959
- // follow-up 排队(W6-2):streaming 期间 ⌘/Ctrl+Enter 把文本排入待发
960
- // 队列,本轮结束后自动作为下一轮发出。与转向(注入当前回合)相对。
961
- // 切回后的远端回合没有本地 follow-up 队列可挂,降级为注入当前回合。
1004
+ // follow-up 排队(W6-2):streaming 期间 Enter 把文本排入待发队列,
1005
+ // 本轮结束后自动作为下一轮发出。与 ⌘/Ctrl+Enter 插队相对。
962
1006
  const handleFollowUp = useCallback(
963
1007
  (text: string) => {
964
1008
  if (remoteStreaming) {
965
- void steerRemote(text);
1009
+ updateRemoteFollowUps((current) => [...current, text]);
966
1010
  return;
967
1011
  }
968
1012
  followUpUserMessage({ content: text });
969
1013
  },
970
- [remoteStreaming, steerRemote, followUpUserMessage],
1014
+ [remoteStreaming, followUpUserMessage, updateRemoteFollowUps],
971
1015
  );
972
1016
 
973
1017
  // 首页输入框接力:EmptyChatGate 建好 chat 并跳转后,首条消息暂存在
@@ -1053,6 +1097,10 @@ function AgentChatView({
1053
1097
  }),
1054
1098
  );
1055
1099
  return () => {
1100
+ // 切走不取消回合。正在流式时卸载不要把侧栏指示清掉,侧栏会轮询
1101
+ // live-stream,等回合结束后再摘。本页确认已结束时,下一次 effect
1102
+ // 会带 isStreaming:false。
1103
+ if (effectiveIsStreaming) return;
1056
1104
  window.dispatchEvent(
1057
1105
  new CustomEvent("chat:streaming-change", {
1058
1106
  detail: { chatId, isStreaming: false },
@@ -1087,6 +1135,20 @@ function AgentChatView({
1087
1135
  suggestedReplies.messageId === tailAssistantDbId)
1088
1136
  ? suggestedReplies.suggestions
1089
1137
  : undefined;
1138
+ const visiblePendingFollowUps = [
1139
+ ...pendingFollowUps.map((message) => message.content),
1140
+ ...remoteFollowUps,
1141
+ ];
1142
+ const handleRemovePendingFollowUp = (index: number) => {
1143
+ if (index < pendingFollowUps.length) {
1144
+ removeFollowUp(index);
1145
+ return;
1146
+ }
1147
+ const remoteIndex = index - pendingFollowUps.length;
1148
+ updateRemoteFollowUps((current) =>
1149
+ current.filter((_, itemIndex) => itemIndex !== remoteIndex),
1150
+ );
1151
+ };
1090
1152
 
1091
1153
  return (
1092
1154
  <div className="flex h-full w-full flex-col">
@@ -1103,13 +1165,10 @@ function AgentChatView({
1103
1165
  onCancel={handleCancel}
1104
1166
  onSteer={handleSteer}
1105
1167
  onFollowUp={handleFollowUp}
1106
- pendingFollowUps={pendingFollowUps.map((m) => m.content)}
1107
- onRemoveFollowUp={removeFollowUp}
1168
+ pendingFollowUps={visiblePendingFollowUps}
1169
+ onRemoveFollowUp={handleRemovePendingFollowUp}
1108
1170
  className="flex-1"
1109
- emptyHero={{
1110
- title: BRAND_NAME,
1111
- subtitle: '输入消息,直接开始一段新对话。',
1112
- }}
1171
+ emptyHero
1113
1172
  header={
1114
1173
  <ChatHeader
1115
1174
  chat={chat}
@@ -1171,6 +1230,11 @@ function AgentChatView({
1171
1230
  reasoningEffort={effortOverride}
1172
1231
  onSelectModel={setModelOverride}
1173
1232
  onSelectEffort={setEffortOverride}
1233
+ onOpenSettings={
1234
+ isElectron() && settingsChrome("llm")
1235
+ ? () => setLlmSettingsOpen(true)
1236
+ : undefined
1237
+ }
1174
1238
  disabled={isStreaming}
1175
1239
  />
1176
1240
  }
@@ -1275,7 +1339,6 @@ function EmptyChatGate() {
1275
1339
 
1276
1340
  // 落地页项目选择:还没有 chat 可绑,选中的 projectId 在首次提交
1277
1341
  // createChat 时一并传入。侧栏项目组「+」会带 ?projectId= 预选。
1278
- const [projects, setProjects] = useState<LocalProject[]>([]);
1279
1342
  const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
1280
1343
  projectIdFromUrl,
1281
1344
  );
@@ -1283,18 +1346,7 @@ function EmptyChatGate() {
1283
1346
  setSelectedProjectId(projectIdFromUrl);
1284
1347
  }, [projectIdFromUrl]);
1285
1348
  const showProjectsChrome = hostToolChrome("projects");
1286
- const fetchProjects = useCallback(async () => {
1287
- if (!isElectron() || !hostToolChrome("projects")) return;
1288
- try {
1289
- const res = await listProjects();
1290
- setProjects(res.projects ?? []);
1291
- } catch {
1292
- /* 列表失败保持旧数据 */
1293
- }
1294
- }, []);
1295
- useEffect(() => {
1296
- void fetchProjects();
1297
- }, [fetchProjects]);
1349
+ const { projects, refresh: fetchProjects } = useProjects();
1298
1350
 
1299
1351
  useEffect(() => {
1300
1352
  inputRef.current?.focusAtEnd();
@@ -1404,14 +1456,7 @@ function EmptyChatGate() {
1404
1456
  data-testid="empty-chat-home"
1405
1457
  >
1406
1458
  <div className="flex w-full max-w-3xl flex-col items-center gap-4">
1407
- <div className="text-center">
1408
- <h1 className="text-xl font-semibold tracking-tight text-agent-foreground">
1409
- {BRAND_NAME}
1410
- </h1>
1411
- <p className="mt-1.5 text-xs text-agent-muted-foreground">
1412
- 输入消息,直接开始一段新对话。
1413
- </p>
1414
- </div>
1459
+ <EmptyChatHero />
1415
1460
  <div className="w-full">
1416
1461
  <ChatInput
1417
1462
  ref={inputRef}
@@ -1440,6 +1485,11 @@ function EmptyChatGate() {
1440
1485
  reasoningEffort={effortOverride}
1441
1486
  onSelectModel={setModelOverride}
1442
1487
  onSelectEffort={setEffortOverride}
1488
+ onOpenSettings={
1489
+ isElectron() && settingsChrome("llm")
1490
+ ? () => setLlmSettingsOpen(true)
1491
+ : undefined
1492
+ }
1443
1493
  disabled={!isElectron() || isCreating}
1444
1494
  />
1445
1495
  }
@@ -44,27 +44,71 @@ import { settingsChrome } from '@/lib/host-tools';
44
44
  import { isPortableEnabled } from '@/lib/portable';
45
45
  import type { AgentOutletContext } from '@/layouts/AgentLayout';
46
46
 
47
- type SettingsSection = 'skills' | 'mcp' | 'agents' | 'general';
47
+ type SettingsSection = 'plugins' | 'general';
48
+ type PluginTab = 'agents' | 'skills' | 'mcp' | 'web-search';
49
+
50
+ const PLUGIN_TABS: readonly PluginTab[] = ['agents', 'skills', 'mcp', 'web-search'];
51
+
52
+ const PLUGIN_TAB_META: Record<
53
+ PluginTab,
54
+ { label: string; noun: string; Icon: typeof LuBot }
55
+ > = {
56
+ agents: { label: '智能体', noun: '智能体', Icon: LuBot },
57
+ skills: { label: 'Skills', noun: '技能', Icon: LuBlocks },
58
+ mcp: { label: 'MCP', noun: 'MCP 服务', Icon: LuPlug },
59
+ 'web-search': { label: '网络搜索', noun: '网络搜索', Icon: LuSearch },
60
+ };
61
+
62
+ function isPluginTab(value: string | null): value is PluginTab {
63
+ return (PLUGIN_TABS as readonly string[]).includes(value ?? '');
64
+ }
65
+
66
+ function pluginsSummary(tabs: readonly PluginTab[]): string {
67
+ const nouns = tabs.map((id) => PLUGIN_TAB_META[id].noun);
68
+ if (nouns.length <= 1) return `管理${nouns[0] ?? '插件'}。`;
69
+ return `管理${nouns.slice(0, -1).join('、')}和${nouns[nouns.length - 1]}。`;
70
+ }
71
+
72
+ function enabledPluginTabs(): PluginTab[] {
73
+ return PLUGIN_TABS.filter((id) => settingsChrome(id));
74
+ }
48
75
 
49
76
  function resolveSection(raw: string | null): SettingsSection {
50
- if (raw === 'skills' && settingsChrome('skills')) return 'skills';
51
- if (raw === 'mcp' && settingsChrome('mcp')) return 'mcp';
52
- if (raw === 'agents' && settingsChrome('agents')) return 'agents';
77
+ if (raw === 'plugins') return enabledPluginTabs().length > 0 ? 'plugins' : 'general';
78
+ if ((raw === 'agents' || raw === 'skills' || raw === 'mcp') && settingsChrome(raw)) {
79
+ return 'plugins';
80
+ }
53
81
  return 'general';
54
82
  }
55
83
 
84
+ /** `?section=agents|skills|mcp` 是旧深链,仍打开插件页上对应分类。 */
85
+ function resolvePluginTab(section: string | null, tab: string | null): PluginTab | null {
86
+ const enabled = enabledPluginTabs();
87
+ const requested =
88
+ section === 'agents' || section === 'skills' || section === 'mcp' ? section : tab;
89
+ if (isPluginTab(requested) && enabled.includes(requested)) {
90
+ return requested;
91
+ }
92
+ return enabled[0] ?? null;
93
+ }
94
+
56
95
  /**
57
96
  * `/settings` — 右侧内容区的设置页,按 `?section=` 分成独立页面:
58
- * - `skills` 侧栏「Skill 设置」
59
- * - `mcp` 侧栏「MCP 设置」
60
- * - `agents` 侧栏「智能体管理」
97
+ * - `plugins` 侧栏「插件」。顶栏分类切换智能体 / Skills / MCP / 网络搜索
98
+ * (`?tab=`,旧的 `?section=agents|skills|mcp` 仍落到对应分类)
61
99
  * - 缺省/`general` 侧栏底「设置」(界面 / 模型 / 搜索 / 用量 / 安全 / 洞察 / 遥测 / 关于)
62
100
  *
63
101
  * Panel 数据自管理(挂载即拉取),页面本身不持有后端状态。
64
102
  */
65
103
  export function SettingsPage() {
66
- const [searchParams] = useSearchParams();
67
- const section = resolveSection(searchParams.get('section'));
104
+ const [searchParams, setSearchParams] = useSearchParams();
105
+ const rawSection = searchParams.get('section');
106
+ const section = resolveSection(rawSection);
107
+ const pluginTab = section === 'plugins' ? resolvePluginTab(rawSection, searchParams.get('tab')) : null;
108
+ const pluginTabs = section === 'plugins' ? enabledPluginTabs() : [];
109
+ const selectPluginTab = useCallback((id: PluginTab) => {
110
+ setSearchParams({ section: 'plugins', tab: id });
111
+ }, [setSearchParams]);
68
112
  const catalog = useOutletContext<AgentOutletContext | null>();
69
113
  const release = useAppRelease();
70
114
  const llmPanelRef = useRef<LlmSettingsPanelHandle>(null);
@@ -227,19 +271,46 @@ export function SettingsPage() {
227
271
  }
228
272
  }, [menuItems]);
229
273
 
230
- const title =
231
- section === 'mcp'
232
- ? 'MCP 设置'
233
- : section === 'skills'
234
- ? 'Skill 设置'
235
- : section === 'agents'
236
- ? '智能体管理'
237
- : '设置';
274
+ const title = section === 'plugins' ? '插件' : '设置';
238
275
 
239
276
  return (
240
277
  <div ref={pageContainerRef} className="flex h-full w-full flex-col overflow-hidden">
241
278
  <header className="flex h-9 flex-shrink-0 items-center justify-between gap-2 border-b border-agent-border px-2.5">
242
- <h1 className="text-xs font-semibold text-agent-foreground">{title}</h1>
279
+ <div className="flex min-w-0 items-center gap-2">
280
+ <h1 className="shrink-0 text-xs font-semibold text-agent-foreground">{title}</h1>
281
+ {section === 'plugins' && pluginTabs.length > 1 && (
282
+ <div
283
+ role="tablist"
284
+ aria-label="插件"
285
+ data-testid="plugins-tabs"
286
+ className="flex min-w-0 items-center gap-1"
287
+ >
288
+ {pluginTabs.map((id) => {
289
+ const meta = PLUGIN_TAB_META[id];
290
+ const active = pluginTab === id;
291
+ return (
292
+ <button
293
+ key={id}
294
+ type="button"
295
+ role="tab"
296
+ aria-selected={active}
297
+ data-testid={`plugins-tab-${id}`}
298
+ onClick={() => selectPluginTab(id)}
299
+ className={[
300
+ 'flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors',
301
+ active
302
+ ? 'bg-agent-foreground/10 font-medium text-agent-foreground'
303
+ : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
304
+ ].join(' ')}
305
+ >
306
+ <meta.Icon className="h-3.5 w-3.5" />
307
+ {meta.label}
308
+ </button>
309
+ );
310
+ })}
311
+ </div>
312
+ )}
313
+ </div>
243
314
  {section === 'general' && settingsChrome('llm') && (
244
315
  <SettingsSaveButton
245
316
  testId="settings-header-save"
@@ -276,34 +347,48 @@ export function SettingsPage() {
276
347
  </p>
277
348
  )}
278
349
 
279
- {section === 'skills' && (
280
- <section className="space-y-2" data-testid="settings-section-skills">
281
- <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
282
- <LuBlocks className="h-3.5 w-3.5 text-agent-muted-foreground" />
283
- 本地技能
284
- </h2>
285
- <SkillsSettingsPanel />
286
- </section>
287
- )}
288
-
289
- {section === 'mcp' && (
290
- <section className="space-y-2" data-testid="settings-section-mcp">
291
- <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
292
- <LuPlug className="h-3.5 w-3.5 text-agent-muted-foreground" />
293
- MCP 服务
294
- </h2>
295
- <McpSettingsPanel />
296
- </section>
297
- )}
298
-
299
- {section === 'agents' && (
300
- <section className="space-y-2" data-testid="settings-section-agents">
301
- <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
302
- <LuBot className="h-3.5 w-3.5 text-agent-muted-foreground" />
303
- 智能体
304
- </h2>
305
- <AgentsSettingsPanel onCatalogChange={catalog?.refreshAgents} />
306
- </section>
350
+ {section === 'plugins' && (
351
+ <>
352
+ <p className="text-xs text-agent-muted-foreground" data-testid="plugins-summary">
353
+ {pluginsSummary(pluginTabs)}
354
+ </p>
355
+ {pluginTab === 'skills' && (
356
+ <section className="space-y-2" data-testid="settings-section-skills">
357
+ <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
358
+ <LuBlocks className="h-3.5 w-3.5 text-agent-muted-foreground" />
359
+ 本地技能
360
+ </h2>
361
+ <SkillsSettingsPanel />
362
+ </section>
363
+ )}
364
+ {pluginTab === 'mcp' && (
365
+ <section className="space-y-2" data-testid="settings-section-mcp">
366
+ <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
367
+ <LuPlug className="h-3.5 w-3.5 text-agent-muted-foreground" />
368
+ MCP 服务
369
+ </h2>
370
+ <McpSettingsPanel />
371
+ </section>
372
+ )}
373
+ {pluginTab === 'agents' && (
374
+ <section className="space-y-2" data-testid="settings-section-agents">
375
+ <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
376
+ <LuBot className="h-3.5 w-3.5 text-agent-muted-foreground" />
377
+ 智能体
378
+ </h2>
379
+ <AgentsSettingsPanel onCatalogChange={catalog?.refreshAgents} />
380
+ </section>
381
+ )}
382
+ {pluginTab === 'web-search' && settingsChrome('web-search') && (
383
+ <section className="space-y-2" data-testid="settings-section-web-search">
384
+ <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
385
+ <LuSearch className="h-3.5 w-3.5 text-agent-muted-foreground" />
386
+ 网络搜索
387
+ </h2>
388
+ <WebSearchSettingsPanel />
389
+ </section>
390
+ )}
391
+ </>
307
392
  )}
308
393
 
309
394
  {section === 'general' && (
package/src/vite-env.d.ts CHANGED
@@ -5,6 +5,8 @@ interface ImportMetaEnv {
5
5
  readonly VITE_BRAND_NAME?: string;
6
6
  readonly VITE_BRAND_TITLE?: string;
7
7
  readonly VITE_BRAND_TAGLINE?: string;
8
+ /** 新对话落地页副文案。空字符串表示不显示。 */
9
+ readonly VITE_BRAND_HOME_HINT?: string;
8
10
  readonly VITE_DEFAULT_AGENT_ID?: string;
9
11
  readonly VITE_HOST_TOOLS?: string;
10
12
  readonly VITE_APPROVAL?: string;
package/vite.base.js CHANGED
@@ -52,6 +52,7 @@ export function createProductViteConfig(options) {
52
52
  'import.meta.env.VITE_BRAND_NAME': JSON.stringify(brand?.displayName ?? 'Steerable Shell'),
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
+ 'import.meta.env.VITE_BRAND_HOME_HINT': JSON.stringify(brand?.homeHint ?? '输入消息,直接开始一段新对话。'),
55
56
  'import.meta.env.VITE_DEFAULT_AGENT_ID': JSON.stringify(brand?.defaultAgentId ?? 'local-assistant'),
56
57
  // 宿主工具族 / 安全询问:与 node 侧 setProductConfig 读同一份 product.json。
57
58
  'import.meta.env.VITE_HOST_TOOLS': JSON.stringify(JSON.stringify(product.hostTools ?? {})),