@steerable/agent-shell-web 0.6.41 → 0.6.43
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/README.md +6 -1
- package/package.json +8 -6
- package/src/components/chat/AskUserPromptProvider.tsx +68 -19
- package/src/components/chat/AskUserQuestionMenu.tsx +6 -4
- package/src/components/chat/ChatInput.tsx +102 -21
- package/src/components/chat/LocalChatPanel.tsx +1 -0
- package/src/hooks/useChatsAndAgents.ts +1 -1
- package/src/lib/attachments.ts +2 -2
- package/src/lib/chat-transport.ts +1 -1
- package/src/lib/electron-bridge.ts +32 -8
- package/src/lib/host-bridge.ts +7 -0
- package/src/lib/tauri-bridge.ts +96 -0
package/README.md
CHANGED
|
@@ -14,9 +14,14 @@ export default createProductViteConfig({ productDir, flavor });
|
|
|
14
14
|
|
|
15
15
|
The product's Vite build compiles this package's `src/` into the product's own
|
|
16
16
|
bundle; there is no prebuilt `dist/` here. See
|
|
17
|
-
[`docs/spec/architecture.md`](https://github.com/pathlyapp/
|
|
17
|
+
[`docs/spec/architecture.md`](https://github.com/pathlyapp/steerable/blob/develop/docs/spec/architecture.md)
|
|
18
18
|
for the tier model.
|
|
19
19
|
|
|
20
|
+
Renderer code reads native and backend capabilities through `HostBridge`.
|
|
21
|
+
Browser mode uses the HTTP/SSE adapter; Tauri mode layers native commands and
|
|
22
|
+
menu events over that same adapter. `ElectronBridge` and
|
|
23
|
+
`getElectronBridge()` remain deprecated compatibility aliases.
|
|
24
|
+
|
|
20
25
|
Products that require authentication can register an `AppShellGate` from
|
|
21
26
|
`@steerable/agent-shell-web/auth/gate` before calling `bootstrap()`. An
|
|
22
27
|
enabled gate renders before the application router and calls
|
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.43",
|
|
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",
|
|
7
7
|
"homepage": "https://steerableframework.com/",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/pathlyapp/
|
|
10
|
+
"url": "git+https://github.com/pathlyapp/steerable.git",
|
|
11
11
|
"directory": "packages/agent-shell/web"
|
|
12
12
|
},
|
|
13
13
|
"bugs": {
|
|
14
|
-
"url": "https://github.com/pathlyapp/
|
|
14
|
+
"url": "https://github.com/pathlyapp/steerable/issues"
|
|
15
15
|
},
|
|
16
16
|
"exports": {
|
|
17
17
|
"./auth/gate": "./src/auth/gate.ts",
|
|
@@ -32,10 +32,12 @@
|
|
|
32
32
|
"provenance": true
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"@tauri-apps/api": "^2.11.1",
|
|
35
36
|
"@xterm/addon-fit": "^0.11.0",
|
|
36
37
|
"@xterm/addon-web-links": "^0.12.0",
|
|
37
38
|
"@xterm/xterm": "^6.0.0",
|
|
38
39
|
"framer-motion": "^12.38.0",
|
|
40
|
+
"html2canvas": "^1.4.1",
|
|
39
41
|
"react": "^18.3.1",
|
|
40
42
|
"react-dom": "^18.3.1",
|
|
41
43
|
"react-icons": "^5.5.0",
|
|
@@ -43,8 +45,8 @@
|
|
|
43
45
|
"react-router-dom": "^6.30.0",
|
|
44
46
|
"rehype-raw": "^7.0.0",
|
|
45
47
|
"remark-gfm": "^4.0.1",
|
|
46
|
-
"@steerable/agent-protocol": "0.6.
|
|
47
|
-
"@steerable/agent-ui": "0.6.
|
|
48
|
+
"@steerable/agent-protocol": "0.6.43",
|
|
49
|
+
"@steerable/agent-ui": "0.6.43"
|
|
48
50
|
},
|
|
49
51
|
"devDependencies": {
|
|
50
52
|
"@tailwindcss/vite": "^4.3.0",
|
|
@@ -57,7 +59,7 @@
|
|
|
57
59
|
"typescript": "^5.8.3",
|
|
58
60
|
"vite": "^5.4.21",
|
|
59
61
|
"vitest": "^3.2.4",
|
|
60
|
-
"@steerable/pack-sdk": "0.6.
|
|
62
|
+
"@steerable/pack-sdk": "0.6.43"
|
|
61
63
|
},
|
|
62
64
|
"scripts": {
|
|
63
65
|
"gen": "echo agent-shell-web",
|
|
@@ -13,16 +13,53 @@ import { getElectronBridge, type AskUserPromptRequest } from '@/lib/electron-bri
|
|
|
13
13
|
type AnswerValue = string | string[];
|
|
14
14
|
|
|
15
15
|
interface AskUserPromptContextValue {
|
|
16
|
+
queue: AskUserPromptRequest[];
|
|
17
|
+
answer: (requestId: string, answers: Record<string, AnswerValue>) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface AskUserPromptView {
|
|
16
21
|
current: AskUserPromptRequest | null;
|
|
17
22
|
pendingCount: number;
|
|
18
23
|
answer: (answers: Record<string, AnswerValue>) => void;
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Chat id from `#/agent/:chatId` (hash router) or `/agent/:chatId`.
|
|
28
|
+
* The running server build drops `chatId` on the ask-user payload.
|
|
29
|
+
*/
|
|
30
|
+
function chatIdFromLocation(location: { pathname: string; hash: string }): string | null {
|
|
31
|
+
const hashed = location.hash.startsWith('#') ? location.hash.slice(1) : '';
|
|
32
|
+
const raw = hashed || location.pathname;
|
|
33
|
+
const match = raw.match(/\/agent\/([^/?#]+)/);
|
|
34
|
+
if (!match?.[1]) return null;
|
|
35
|
+
try {
|
|
36
|
+
return decodeURIComponent(match[1]);
|
|
37
|
+
} catch {
|
|
38
|
+
return match[1];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function bindPromptToActiveChat(request: AskUserPromptRequest): AskUserPromptRequest {
|
|
43
|
+
if (request.chatId) return request;
|
|
44
|
+
const chatId = chatIdFromLocation(window.location);
|
|
45
|
+
if (!chatId) return request;
|
|
46
|
+
return { ...request, chatId };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A prompt with a chat id belongs only to that chat. A prompt without one stays visible so an older request is still answerable. */
|
|
50
|
+
function promptVisibleInChat(
|
|
51
|
+
request: AskUserPromptRequest,
|
|
52
|
+
chatId: string | null | undefined,
|
|
53
|
+
): boolean {
|
|
54
|
+
if (!request.chatId) return !chatId;
|
|
55
|
+
return request.chatId === chatId;
|
|
56
|
+
}
|
|
57
|
+
|
|
21
58
|
const AskUserPromptContext = createContext<AskUserPromptContextValue | null>(null);
|
|
22
59
|
|
|
23
60
|
/**
|
|
24
|
-
* Keeps structured user prompts alive across chat navigation
|
|
25
|
-
* the
|
|
61
|
+
* Keeps structured user prompts alive across chat navigation. Each composer
|
|
62
|
+
* presents only the prompt that belongs to its chat.
|
|
26
63
|
*/
|
|
27
64
|
export function AskUserPromptProvider({ children }: { children: ReactNode }) {
|
|
28
65
|
const [queue, setQueue] = useState<AskUserPromptRequest[]>([]);
|
|
@@ -38,10 +75,12 @@ export function AskUserPromptProvider({ children }: { children: ReactNode }) {
|
|
|
38
75
|
) => {
|
|
39
76
|
setQueue((previous) => {
|
|
40
77
|
const known = new Set(previous.map(({ requestId }) => requestId));
|
|
41
|
-
const additions = requests
|
|
42
|
-
(
|
|
43
|
-
|
|
44
|
-
|
|
78
|
+
const additions = requests
|
|
79
|
+
.filter(
|
|
80
|
+
({ requestId }) =>
|
|
81
|
+
!known.has(requestId) && !answeredRequestIds.current.has(requestId),
|
|
82
|
+
)
|
|
83
|
+
.map(bindPromptToActiveChat);
|
|
45
84
|
if (additions.length === 0) return previous;
|
|
46
85
|
return placement === 'prepend'
|
|
47
86
|
? [...additions, ...previous]
|
|
@@ -65,25 +104,22 @@ export function AskUserPromptProvider({ children }: { children: ReactNode }) {
|
|
|
65
104
|
};
|
|
66
105
|
}, []);
|
|
67
106
|
|
|
68
|
-
const current = queue[0] ?? null;
|
|
69
107
|
const answer = useCallback(
|
|
70
|
-
(answers: Record<string, AnswerValue>) => {
|
|
71
|
-
if (!current) return;
|
|
108
|
+
(requestId: string, answers: Record<string, AnswerValue>) => {
|
|
72
109
|
const bridge = getElectronBridge();
|
|
73
|
-
answeredRequestIds.current.add(
|
|
74
|
-
setQueue((previous) => previous.
|
|
75
|
-
void bridge?.askUser?.answer({ requestId
|
|
110
|
+
answeredRequestIds.current.add(requestId);
|
|
111
|
+
setQueue((previous) => previous.filter((request) => request.requestId !== requestId));
|
|
112
|
+
void bridge?.askUser?.answer({ requestId, answers });
|
|
76
113
|
},
|
|
77
|
-
[
|
|
114
|
+
[],
|
|
78
115
|
);
|
|
79
116
|
|
|
80
117
|
const value = useMemo(
|
|
81
118
|
() => ({
|
|
82
|
-
|
|
83
|
-
pendingCount: Math.max(0, queue.length - 1),
|
|
119
|
+
queue,
|
|
84
120
|
answer,
|
|
85
121
|
}),
|
|
86
|
-
[answer,
|
|
122
|
+
[answer, queue],
|
|
87
123
|
);
|
|
88
124
|
|
|
89
125
|
return (
|
|
@@ -93,7 +129,20 @@ export function AskUserPromptProvider({ children }: { children: ReactNode }) {
|
|
|
93
129
|
);
|
|
94
130
|
}
|
|
95
131
|
|
|
96
|
-
/** Returns the structured prompt
|
|
97
|
-
export function useAskUserPrompt():
|
|
98
|
-
|
|
132
|
+
/** Returns the structured prompt for this composer’s chat, if one is waiting. */
|
|
133
|
+
export function useAskUserPrompt(chatId?: string | null): AskUserPromptView | null {
|
|
134
|
+
const context = useContext(AskUserPromptContext);
|
|
135
|
+
return useMemo(() => {
|
|
136
|
+
if (!context) return null;
|
|
137
|
+
const mine = context.queue.filter((request) => promptVisibleInChat(request, chatId));
|
|
138
|
+
const current = mine[0] ?? null;
|
|
139
|
+
return {
|
|
140
|
+
current,
|
|
141
|
+
pendingCount: Math.max(0, mine.length - 1),
|
|
142
|
+
answer: (answers) => {
|
|
143
|
+
if (!current) return;
|
|
144
|
+
context.answer(current.requestId, answers);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}, [chatId, context]);
|
|
99
148
|
}
|
|
@@ -139,7 +139,7 @@ function Footer({
|
|
|
139
139
|
className="inline-flex items-center gap-1 rounded-agent-md px-2.5 py-1.5 text-xs font-medium text-agent-muted-foreground transition hover:bg-agent-muted hover:text-agent-foreground"
|
|
140
140
|
>
|
|
141
141
|
<LuWand size={12} />
|
|
142
|
-
|
|
142
|
+
交给AI决定
|
|
143
143
|
</button>
|
|
144
144
|
)}
|
|
145
145
|
</div>
|
|
@@ -588,9 +588,11 @@ export function AskUserQuestionMenu({
|
|
|
588
588
|
{bottomHint ? ` · ${bottomHint}` : ''}
|
|
589
589
|
</p>
|
|
590
590
|
</div>
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
591
|
+
{current.type === 'password' || current.multiSelect ? (
|
|
592
|
+
<span className="rounded-full border border-agent-border px-2 py-0.5 text-[10px] font-medium text-agent-muted-foreground">
|
|
593
|
+
{current.type === 'password' ? '密码输入' : '可多选'}
|
|
594
|
+
</span>
|
|
595
|
+
) : null}
|
|
594
596
|
</div>
|
|
595
597
|
|
|
596
598
|
{current.type === 'select' ? (
|
|
@@ -162,6 +162,8 @@ export interface ChatInputProps {
|
|
|
162
162
|
skills?: SkillItem[];
|
|
163
163
|
/** MCP tools for slash command autocomplete and chip rendering. */
|
|
164
164
|
mcpTools?: McpToolItem[];
|
|
165
|
+
/** Chat this composer belongs to. Ask-user cards for other chats stay hidden. */
|
|
166
|
+
chatId?: string | null;
|
|
165
167
|
}
|
|
166
168
|
|
|
167
169
|
export type MentionReference = {
|
|
@@ -677,6 +679,84 @@ function getMentionRanges(
|
|
|
677
679
|
return ranges;
|
|
678
680
|
}
|
|
679
681
|
|
|
682
|
+
/** 截图粘贴常见的空名 / `image.png`,同一秒再贴会按文件名被去重掉。 */
|
|
683
|
+
const GENERIC_CLIPBOARD_IMAGE_NAME =
|
|
684
|
+
/^(image|clipboard|pasted-image|blob)(\.[a-z0-9]+)?$/i;
|
|
685
|
+
|
|
686
|
+
const IMAGE_MIME_EXT: Record<string, string> = {
|
|
687
|
+
'image/png': 'png',
|
|
688
|
+
'image/jpeg': 'jpg',
|
|
689
|
+
'image/gif': 'gif',
|
|
690
|
+
'image/webp': 'webp',
|
|
691
|
+
'image/bmp': 'bmp',
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
function clipboardFiles(data: DataTransfer | null): File[] {
|
|
695
|
+
if (!data) return [];
|
|
696
|
+
const fromItems: File[] = [];
|
|
697
|
+
for (const item of Array.from(data.items ?? [])) {
|
|
698
|
+
if (item.kind !== 'file') continue;
|
|
699
|
+
const file = item.getAsFile();
|
|
700
|
+
if (file) fromItems.push(file);
|
|
701
|
+
}
|
|
702
|
+
if (fromItems.length > 0) return fromItems;
|
|
703
|
+
return Array.from(data.files ?? []);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function namePastedImage(file: File, serial: number, now: Date): File {
|
|
707
|
+
if (!file.type.startsWith('image/')) return file;
|
|
708
|
+
if (file.name.trim() && !GENERIC_CLIPBOARD_IMAGE_NAME.test(file.name.trim())) return file;
|
|
709
|
+
const ext = IMAGE_MIME_EXT[file.type.toLowerCase()] ?? 'png';
|
|
710
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
711
|
+
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
712
|
+
return new File([file], `pasted-${stamp}-${serial}.${ext}`, {
|
|
713
|
+
type: file.type,
|
|
714
|
+
lastModified: file.lastModified,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function AttachmentChip({
|
|
719
|
+
file,
|
|
720
|
+
onRemove,
|
|
721
|
+
}: {
|
|
722
|
+
file: AttachmentFile;
|
|
723
|
+
onRemove: () => void;
|
|
724
|
+
}) {
|
|
725
|
+
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
726
|
+
useEffect(() => {
|
|
727
|
+
const blob = file.file;
|
|
728
|
+
if (!blob?.type.startsWith('image/')) {
|
|
729
|
+
setPreviewUrl(null);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const url = URL.createObjectURL(blob);
|
|
733
|
+
setPreviewUrl(url);
|
|
734
|
+
return () => URL.revokeObjectURL(url);
|
|
735
|
+
}, [file.file]);
|
|
736
|
+
|
|
737
|
+
return (
|
|
738
|
+
<div
|
|
739
|
+
className="group flex items-center gap-1.5 rounded border border-agent-border bg-agent-muted/60 px-2.5 py-1 text-xs text-agent-foreground transition-all duration-150 select-none hover:bg-agent-muted max-w-xs"
|
|
740
|
+
title={file.path || file.name}
|
|
741
|
+
>
|
|
742
|
+
{previewUrl ? (
|
|
743
|
+
<img src={previewUrl} alt="" className="h-8 w-8 shrink-0 rounded object-cover" />
|
|
744
|
+
) : (
|
|
745
|
+
<LuFile className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
|
|
746
|
+
)}
|
|
747
|
+
<span className="max-w-[160px] truncate font-medium">{file.name}</span>
|
|
748
|
+
<button
|
|
749
|
+
type="button"
|
|
750
|
+
onClick={onRemove}
|
|
751
|
+
className="flex h-4 w-4 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/10 hover:text-agent-foreground"
|
|
752
|
+
aria-label={`移除文件 ${file.name}`}
|
|
753
|
+
>
|
|
754
|
+
<LuX className="h-3 w-3" />
|
|
755
|
+
</button>
|
|
756
|
+
</div>
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
680
760
|
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
681
761
|
function ChatInput(
|
|
682
762
|
{
|
|
@@ -709,10 +789,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
709
789
|
onMentionReferencesChange,
|
|
710
790
|
skills: propSkills,
|
|
711
791
|
mcpTools: propMcpTools,
|
|
792
|
+
chatId,
|
|
712
793
|
},
|
|
713
794
|
ref,
|
|
714
795
|
) {
|
|
715
|
-
const askUserPrompt = useAskUserPrompt();
|
|
796
|
+
const askUserPrompt = useAskUserPrompt(chatId);
|
|
716
797
|
const approvalPrompt = useApprovalPrompt();
|
|
717
798
|
const editorRef = useRef<HTMLDivElement>(null);
|
|
718
799
|
const agentMenuRef = useRef<HTMLDivElement>(null);
|
|
@@ -741,6 +822,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
741
822
|
const compositionEndAtRef = useRef<number>(0);
|
|
742
823
|
|
|
743
824
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
825
|
+
const pasteSerialRef = useRef(0);
|
|
744
826
|
// 工具栏"空间不足时优先隐藏快捷键提示"的测量 refs,见下方 useLayoutEffect。
|
|
745
827
|
const toolbarRowRef = useRef<HTMLDivElement>(null);
|
|
746
828
|
const toolbarLeftRef = useRef<HTMLDivElement>(null);
|
|
@@ -1339,7 +1421,19 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1339
1421
|
|
|
1340
1422
|
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
|
1341
1423
|
event.preventDefault();
|
|
1342
|
-
|
|
1424
|
+
const data = event.clipboardData;
|
|
1425
|
+
const pasted = !disabled && allowFileAttach ? clipboardFiles(data) : [];
|
|
1426
|
+
if (pasted.length > 0) {
|
|
1427
|
+
const now = new Date();
|
|
1428
|
+
addFiles(
|
|
1429
|
+
pasted.map((file) => {
|
|
1430
|
+
pasteSerialRef.current += 1;
|
|
1431
|
+
return toAttachment(namePastedImage(file, pasteSerialRef.current, now));
|
|
1432
|
+
}),
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
const text = data?.getData('text/plain') ?? '';
|
|
1436
|
+
if (text) replaceSelection(text);
|
|
1343
1437
|
};
|
|
1344
1438
|
|
|
1345
1439
|
const handleEditorScroll = () => {
|
|
@@ -1791,24 +1885,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1791
1885
|
{actualFiles.length > 0 && (
|
|
1792
1886
|
<div className="flex flex-wrap gap-1.5 px-2.5 pb-1.5 pt-0.5">
|
|
1793
1887
|
{actualFiles.map((file, idx) => (
|
|
1794
|
-
<
|
|
1795
|
-
key={idx}
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
<LuFile className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
|
|
1800
|
-
<span className="truncate max-w-[160px] font-medium">
|
|
1801
|
-
{file.name}
|
|
1802
|
-
</span>
|
|
1803
|
-
<button
|
|
1804
|
-
type="button"
|
|
1805
|
-
onClick={() => handleRemoveFile(idx)}
|
|
1806
|
-
className="flex h-4 w-4 items-center justify-center rounded-full text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-foreground transition-colors"
|
|
1807
|
-
aria-label={`移除文件 ${file.name}`}
|
|
1808
|
-
>
|
|
1809
|
-
<LuX className="h-3 w-3" />
|
|
1810
|
-
</button>
|
|
1811
|
-
</div>
|
|
1888
|
+
<AttachmentChip
|
|
1889
|
+
key={`${file.path || file.name}-${idx}`}
|
|
1890
|
+
file={file}
|
|
1891
|
+
onRemove={() => handleRemoveFile(idx)}
|
|
1892
|
+
/>
|
|
1812
1893
|
))}
|
|
1813
1894
|
</div>
|
|
1814
1895
|
)}
|
|
@@ -1842,7 +1923,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1842
1923
|
onClick={handlePickFiles}
|
|
1843
1924
|
disabled={disabled || isStreaming}
|
|
1844
1925
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
1845
|
-
title="
|
|
1926
|
+
title="上传文件,或在输入框粘贴图片"
|
|
1846
1927
|
aria-label="上传文件"
|
|
1847
1928
|
data-testid="chat-attach"
|
|
1848
1929
|
>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `useChatsAndAgents` — sidebar state (paginated chats + agent catalog + the
|
|
3
3
|
* "agent picked for next new chat" selection) for the Agent shell.
|
|
4
4
|
*
|
|
5
|
-
* Wave 1 of the
|
|
5
|
+
* Wave 1 of the Steerable refactor moved the generic pagination /
|
|
6
6
|
* agent-selection / title-patching machinery into `@steerable/agent-ui/state`
|
|
7
7
|
* as `useChatList<TChat, TAgent>`. This file is now an Electron-flavoured
|
|
8
8
|
* adapter:
|
package/src/lib/attachments.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 会话附件(文件上传)的 renderer 侧助手。
|
|
3
3
|
*
|
|
4
|
-
* ChatInput 只负责「收集」文件——通过隐藏的 `<input type="file" multiple
|
|
5
|
-
*
|
|
4
|
+
* ChatInput 只负责「收集」文件——通过隐藏的 `<input type="file" multiple>`、
|
|
5
|
+
* 拖拽,或在输入框粘贴图片拿到 File,产出 `AttachmentFile`({name, path, file?})交给父组件。
|
|
6
6
|
* 真正把文件落进会话空间(`<userDataDir>/attachments/<chatId>/`)由
|
|
7
7
|
* `saveChatAttachments` 在提交时完成:
|
|
8
8
|
* - Electron:File 对象带真实 `path`,走 `attachments:save` IPC 拷贝;
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* Framework `SSEEvent` (packages/agent-protocol/ts/src/generated/SSEEvent.ts)
|
|
29
29
|
* is the canonical normalisation target. The legacy-envelope mapping lives in
|
|
30
30
|
* `@steerable/agent-ui/state`'s `bridgeLegacySSE` — this file used to keep its
|
|
31
|
-
* own copy; wave 1 of the
|
|
31
|
+
* own copy; wave 1 of the Steerable refactor consolidated them.
|
|
32
32
|
*
|
|
33
33
|
* We do still keep one app-specific guard: if any content has already
|
|
34
34
|
* streamed and the backend ends with `budget_exhausted`, we downgrade that
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (src/preload.ts in the main project) exposes via `contextBridge`.
|
|
2
|
+
* Host-neutral bridge used by the renderer in desktop and browser modes.
|
|
4
3
|
*
|
|
5
4
|
* This file MUST stay a subset / aligned shape with `ElectronAPI` in
|
|
6
5
|
* `../../../src/preload.ts`. We intentionally don't TypeScript-reference that
|
|
@@ -8,13 +7,14 @@
|
|
|
8
7
|
* into the renderer's compile graph. Treat this as a contract surface and
|
|
9
8
|
* extend it lazily as apps/web starts using new bridge methods.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* `window.electron` and `getElectronBridge()` remain compatibility aliases for
|
|
11
|
+
* existing products. New integrations should use `window.steerableHost` and
|
|
12
|
+
* `getHostBridge()`.
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
15
|
import type { SSEEvent } from '@steerable/agent-protocol';
|
|
17
16
|
import { getHttpBridge } from './http-bridge';
|
|
17
|
+
import { getTauriBridge } from './tauri-bridge';
|
|
18
18
|
|
|
19
19
|
export type LocalBackendRequestInput = {
|
|
20
20
|
method: string;
|
|
@@ -61,6 +61,8 @@ export interface AskUserPromptRequest {
|
|
|
61
61
|
requestId: string;
|
|
62
62
|
intro: string;
|
|
63
63
|
questions: Array<Record<string, unknown>>;
|
|
64
|
+
/** 发起提问的对话。缺省时卡片不绑定会话(旧请求)。 */
|
|
65
|
+
chatId?: string;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
/**
|
|
@@ -86,7 +88,7 @@ export interface TerminalSpawnOptions {
|
|
|
86
88
|
rows?: number;
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
export interface
|
|
91
|
+
export interface HostBridge {
|
|
90
92
|
runtime: 'local';
|
|
91
93
|
platform: NodeJS.Platform;
|
|
92
94
|
local?: {
|
|
@@ -249,9 +251,14 @@ export interface ElectronBridge {
|
|
|
249
251
|
// shell 桥接口保持产品中立(3.1)。
|
|
250
252
|
}
|
|
251
253
|
|
|
254
|
+
/** @deprecated Use `HostBridge`. */
|
|
255
|
+
export type ElectronBridge = HostBridge;
|
|
256
|
+
|
|
252
257
|
declare global {
|
|
253
258
|
interface Window {
|
|
259
|
+
steerableHost?: HostBridge;
|
|
254
260
|
electron?: ElectronBridge;
|
|
261
|
+
__TAURI_INTERNALS__?: object;
|
|
255
262
|
/**
|
|
256
263
|
* BS server(src/server/)托管 index.html 时注入的引导信息;存在即代表
|
|
257
264
|
* 当前跑在浏览器-服务器模式,getElectronBridge() 会返回 HTTP 实现。
|
|
@@ -264,13 +271,30 @@ declare global {
|
|
|
264
271
|
}
|
|
265
272
|
}
|
|
266
273
|
|
|
267
|
-
export function
|
|
274
|
+
export function getHostBridge(): HostBridge | null {
|
|
268
275
|
if (typeof window === 'undefined') return null;
|
|
276
|
+
if (window.steerableHost) return window.steerableHost;
|
|
269
277
|
if (window.electron) return window.electron;
|
|
278
|
+
if (window.__TAURI_INTERNALS__ && window.__DEEPPATH_BS__) {
|
|
279
|
+
return getTauriBridge();
|
|
280
|
+
}
|
|
270
281
|
if (window.__DEEPPATH_BS__) return getHttpBridge();
|
|
271
282
|
return null;
|
|
272
283
|
}
|
|
273
284
|
|
|
285
|
+
/** @deprecated Use `getHostBridge`. */
|
|
286
|
+
export const getElectronBridge = getHostBridge;
|
|
287
|
+
|
|
288
|
+
export function isDesktopHost(): boolean {
|
|
289
|
+
if (typeof window === 'undefined') return false;
|
|
290
|
+
return Boolean(
|
|
291
|
+
window.steerableHost ||
|
|
292
|
+
window.electron ||
|
|
293
|
+
(window.__TAURI_INTERNALS__ && window.__DEEPPATH_BS__),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** @deprecated Use `isDesktopHost` when desktop-only behavior is required. */
|
|
274
298
|
export function isElectron(): boolean {
|
|
275
|
-
return
|
|
299
|
+
return getHostBridge() !== null;
|
|
276
300
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tauri desktop adapter.
|
|
3
|
+
*
|
|
4
|
+
* Agent, terminal, approval, attachment, and pack traffic continues through
|
|
5
|
+
* the shared BS HTTP/SSE host. Only native desktop operations are overridden
|
|
6
|
+
* with narrow Tauri commands and events.
|
|
7
|
+
*/
|
|
8
|
+
import { invoke } from '@tauri-apps/api/core';
|
|
9
|
+
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
|
10
|
+
import html2canvas from 'html2canvas';
|
|
11
|
+
import type { HostBridge } from './electron-bridge';
|
|
12
|
+
import { createHttpBridge } from './http-bridge';
|
|
13
|
+
|
|
14
|
+
type VoidCallback = () => void;
|
|
15
|
+
|
|
16
|
+
function createMenuSubscription(event: string): {
|
|
17
|
+
on(callback: VoidCallback): void;
|
|
18
|
+
off(): void;
|
|
19
|
+
} {
|
|
20
|
+
const subscriptions = new Map<VoidCallback, Promise<UnlistenFn>>();
|
|
21
|
+
return {
|
|
22
|
+
on(callback) {
|
|
23
|
+
if (subscriptions.has(callback)) return;
|
|
24
|
+
const subscription = listen(event, () => callback());
|
|
25
|
+
subscriptions.set(callback, subscription);
|
|
26
|
+
},
|
|
27
|
+
off() {
|
|
28
|
+
const active = Array.from(subscriptions.values());
|
|
29
|
+
subscriptions.clear();
|
|
30
|
+
for (const subscription of active) {
|
|
31
|
+
void subscription.then((unlisten) => unlisten());
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createTauriBridge(): HostBridge {
|
|
38
|
+
const bridge = createHttpBridge();
|
|
39
|
+
const newChatMenu = createMenuSubscription('menu:new-chat');
|
|
40
|
+
const terminalMenu = createMenuSubscription('menu:open-terminal');
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
...bridge,
|
|
44
|
+
local: {
|
|
45
|
+
...bridge.local,
|
|
46
|
+
selectDirectory: (options) =>
|
|
47
|
+
invoke('host_select_directory', { options: options ?? {} }),
|
|
48
|
+
saveTextFile: (options) =>
|
|
49
|
+
invoke('host_save_text_file', { options }),
|
|
50
|
+
captureScreenshot: async (rect) => {
|
|
51
|
+
try {
|
|
52
|
+
const x = Math.max(0, (rect?.x ?? 0) + window.scrollX);
|
|
53
|
+
const y = Math.max(0, (rect?.y ?? 0) + window.scrollY);
|
|
54
|
+
const width = Math.max(1, rect?.width ?? window.innerWidth);
|
|
55
|
+
const height = Math.max(1, rect?.height ?? window.innerHeight);
|
|
56
|
+
const canvas = await html2canvas(document.body, {
|
|
57
|
+
x,
|
|
58
|
+
y,
|
|
59
|
+
width,
|
|
60
|
+
height,
|
|
61
|
+
scale: window.devicePixelRatio,
|
|
62
|
+
useCORS: true,
|
|
63
|
+
logging: false,
|
|
64
|
+
});
|
|
65
|
+
const pngBase64 = canvas.toDataURL('image/png').split(',', 2)[1];
|
|
66
|
+
if (!pngBase64) {
|
|
67
|
+
return { success: false, error: 'Failed to encode screenshot' };
|
|
68
|
+
}
|
|
69
|
+
return await invoke('host_capture_screenshot', {
|
|
70
|
+
image: {
|
|
71
|
+
pngBase64,
|
|
72
|
+
width: canvas.width,
|
|
73
|
+
height: canvas.height,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return {
|
|
78
|
+
success: false,
|
|
79
|
+
error: error instanceof Error ? error.message : String(error),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
onMenuNewChat: newChatMenu.on,
|
|
85
|
+
offMenuNewChat: newChatMenu.off,
|
|
86
|
+
onMenuOpenTerminal: terminalMenu.on,
|
|
87
|
+
offMenuOpenTerminal: terminalMenu.off,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let singleton: HostBridge | null = null;
|
|
92
|
+
|
|
93
|
+
export function getTauriBridge(): HostBridge {
|
|
94
|
+
singleton ??= createTauriBridge();
|
|
95
|
+
return singleton;
|
|
96
|
+
}
|