@steerable/agent-shell-web 0.6.51 → 0.6.52
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/src/brand.ts +7 -0
- package/src/components/AgentSidebar.tsx +284 -188
- package/src/components/ConfirmDialog.tsx +90 -0
- package/src/components/CreateProjectModal.tsx +32 -16
- package/src/components/chat/ApprovalModal.tsx +2 -5
- package/src/components/chat/ApprovalPromptProvider.tsx +38 -22
- package/src/components/chat/AskUserPromptProvider.tsx +1 -32
- package/src/components/chat/ChatInput.tsx +47 -57
- package/src/components/chat/ChatProjectBadge.tsx +1 -1
- package/src/components/chat/EmptyChatHero.tsx +22 -0
- package/src/components/chat/ExecPolicyPicker.tsx +2 -2
- package/src/components/chat/ExecutedActionsCard.tsx +22 -12
- package/src/components/chat/LocalChatPanel.tsx +25 -19
- package/src/components/chat/MessageList.tsx +77 -5
- package/src/components/chat/ModelPicker.tsx +21 -1
- package/src/components/chat/OrchestrationFlowCard.tsx +13 -3
- package/src/components/chat/SessionOrchestrationFlow.tsx +66 -19
- package/src/components/chat/SessionTodoList.tsx +12 -3
- package/src/components/chat/UserMessage.tsx +116 -25
- package/src/components/chat/composer-status-stack.tsx +79 -0
- package/src/components/chat/orchestration-flow-model.ts +108 -43
- package/src/components/chat/prompt-chat-scope.ts +33 -0
- package/src/components/settings/AgentsSettingsPanel.tsx +33 -22
- package/src/components/settings/McpSettingsPanel.tsx +1 -1
- package/src/components/settings/SkillsSettingsPanel.tsx +1 -1
- package/src/hooks/useProjects.ts +82 -0
- package/src/lib/electron-bridge.ts +1 -0
- package/src/lib/exec-policy.ts +1 -1
- package/src/lib/local-api.ts +1 -1
- package/src/pages/AgentPage.tsx +114 -64
- package/src/pages/SettingsPage.tsx +131 -46
- package/src/vite-env.d.ts +2 -0
- package/vite.base.js +1 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 侧栏项目分组和输入框「关联到项目」共用这一份列表。
|
|
3
|
+
* 任一侧新建、改名或改目录后调用 refresh,其它订阅者一起更新。
|
|
4
|
+
*/
|
|
5
|
+
import { useCallback, useEffect, useSyncExternalStore } from 'react';
|
|
6
|
+
import { isElectron } from '@/lib/electron-bridge';
|
|
7
|
+
import { hostToolChrome } from '@/lib/host-tools';
|
|
8
|
+
import { listProjects, type LocalProject } from '@/lib/local-api';
|
|
9
|
+
|
|
10
|
+
type ProjectsSnapshot = {
|
|
11
|
+
projects: LocalProject[];
|
|
12
|
+
error: string | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const EMPTY_SNAPSHOT: ProjectsSnapshot = { projects: [], error: null };
|
|
16
|
+
|
|
17
|
+
let snapshot: ProjectsSnapshot = EMPTY_SNAPSHOT;
|
|
18
|
+
let requestSeq = 0;
|
|
19
|
+
const listeners = new Set<() => void>();
|
|
20
|
+
|
|
21
|
+
function emit() {
|
|
22
|
+
for (const listener of listeners) listener();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function subscribeProjects(listener: () => void): () => void {
|
|
26
|
+
listeners.add(listener);
|
|
27
|
+
return () => {
|
|
28
|
+
listeners.delete(listener);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getProjectsSnapshot(): ProjectsSnapshot {
|
|
33
|
+
return snapshot;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 重新拉取项目列表并通知所有订阅者。非 Electron 或关掉项目入口时清空。 */
|
|
37
|
+
export async function refreshProjects(): Promise<void> {
|
|
38
|
+
const seq = ++requestSeq;
|
|
39
|
+
if (!isElectron() || !hostToolChrome('projects')) {
|
|
40
|
+
if (snapshot.projects.length > 0 || snapshot.error) {
|
|
41
|
+
snapshot = EMPTY_SNAPSHOT;
|
|
42
|
+
emit();
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const res = await listProjects();
|
|
48
|
+
if (seq !== requestSeq) return;
|
|
49
|
+
snapshot = { projects: res.projects ?? [], error: null };
|
|
50
|
+
emit();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (seq !== requestSeq) return;
|
|
53
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
54
|
+
console.error('获取项目列表失败:', err);
|
|
55
|
+
snapshot = { projects: snapshot.projects, error: message };
|
|
56
|
+
emit();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function useProjects(): {
|
|
61
|
+
projects: LocalProject[];
|
|
62
|
+
error: string | null;
|
|
63
|
+
refresh: () => Promise<void>;
|
|
64
|
+
} {
|
|
65
|
+
const current = useSyncExternalStore(
|
|
66
|
+
subscribeProjects,
|
|
67
|
+
getProjectsSnapshot,
|
|
68
|
+
getProjectsSnapshot,
|
|
69
|
+
);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
void refreshProjects();
|
|
72
|
+
}, []);
|
|
73
|
+
const refresh = useCallback(() => refreshProjects(), []);
|
|
74
|
+
return { projects: current.projects, error: current.error, refresh };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 测试隔离:丢掉上一个用例留下的列表,并忽略还在飞的请求。 */
|
|
78
|
+
export function resetProjectsStoreForTests(): void {
|
|
79
|
+
requestSeq += 1;
|
|
80
|
+
snapshot = EMPTY_SNAPSHOT;
|
|
81
|
+
emit();
|
|
82
|
+
}
|
package/src/lib/exec-policy.ts
CHANGED
package/src/lib/local-api.ts
CHANGED
|
@@ -387,7 +387,7 @@ export interface LocalProject {
|
|
|
387
387
|
id: string;
|
|
388
388
|
name: string;
|
|
389
389
|
folderPath: string;
|
|
390
|
-
/**
|
|
390
|
+
/** 附加源文件夹(可读写,含其子目录)。缺省空。 */
|
|
391
391
|
sourceFolders?: string[];
|
|
392
392
|
/** W6-5: 信任后该项目目录里的规则文件才会注入模型上下文。缺省 false。 */
|
|
393
393
|
trusted?: boolean;
|
package/src/pages/AgentPage.tsx
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
|
729
|
-
//
|
|
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
|
|
756
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
956
|
-
|
|
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 期间
|
|
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
|
-
|
|
1009
|
+
updateRemoteFollowUps((current) => [...current, text]);
|
|
966
1010
|
return;
|
|
967
1011
|
}
|
|
968
1012
|
followUpUserMessage({ content: text });
|
|
969
1013
|
},
|
|
970
|
-
[remoteStreaming,
|
|
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={
|
|
1107
|
-
onRemoveFollowUp={
|
|
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 =
|
|
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
|
-
<
|
|
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 = '
|
|
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 === '
|
|
51
|
-
if (raw === 'mcp' && settingsChrome(
|
|
52
|
-
|
|
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
|
-
* - `
|
|
59
|
-
*
|
|
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
|
|
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
|
-
<
|
|
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 === '
|
|
280
|
-
|
|
281
|
-
<
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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 ?? {})),
|