@steerable/agent-shell-web 0.6.57 → 0.6.59
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/components/AgentSidebar.tsx +11 -2
- package/src/components/SidebarAccount.tsx +161 -0
- package/src/components/TerminalView.tsx +15 -12
- package/src/components/chat/ChatInput.tsx +10 -5
- package/src/components/chat/MessageList.tsx +196 -160
- package/src/lib/browser-dev-host-mock.ts +13 -0
- package/src/lib/host-paste.ts +38 -7
- package/src/lib/local-api.ts +25 -1
- package/src/styles/index.css +27 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.59",
|
|
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",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"react-router-dom": "^6.30.0",
|
|
46
46
|
"rehype-raw": "^7.0.0",
|
|
47
47
|
"remark-gfm": "^4.0.1",
|
|
48
|
-
"@steerable/agent-
|
|
49
|
-
"@steerable/agent-
|
|
48
|
+
"@steerable/agent-protocol": "0.6.59",
|
|
49
|
+
"@steerable/agent-ui": "0.6.59"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@tailwindcss/vite": "^4.3.0",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"typescript": "^5.8.3",
|
|
60
60
|
"vite": "^5.4.21",
|
|
61
61
|
"vitest": "^3.2.4",
|
|
62
|
-
"@steerable/pack-sdk": "0.6.
|
|
62
|
+
"@steerable/pack-sdk": "0.6.59"
|
|
63
63
|
},
|
|
64
64
|
"scripts": {
|
|
65
65
|
"gen": "echo agent-shell-web",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
* │ 今天 │
|
|
62
62
|
* │ ...(无项目对话,按日期分组) │ ← 无项目排在项目分组之后
|
|
63
63
|
* ├─────────────────────────────────┤
|
|
64
|
+
* │ DeepSeek 可用 ↻ │ ← DeepSeek / Kimi 时显示供应商账户余额
|
|
64
65
|
* │ ⚙ 设置 v0.2.2 │ ← /settings;右侧是当前版本,检查更新在设置页
|
|
65
66
|
* └─────────────────────────────────┘
|
|
66
67
|
*
|
|
@@ -116,6 +117,7 @@ import {
|
|
|
116
117
|
SidebarVersionLabel,
|
|
117
118
|
useAppRelease,
|
|
118
119
|
} from "@/components/SidebarRelease";
|
|
120
|
+
import { SidebarAccount } from "@/components/SidebarAccount";
|
|
119
121
|
import { CreateProjectModal } from "@/components/CreateProjectModal";
|
|
120
122
|
import { t } from "@/i18n";
|
|
121
123
|
|
|
@@ -881,7 +883,9 @@ export function AgentSidebar({
|
|
|
881
883
|
};
|
|
882
884
|
}, [bridge, handleOpenNewChat]);
|
|
883
885
|
|
|
884
|
-
// Auto-load next page when the chat scroller approaches the bottom.
|
|
886
|
+
// Auto-load next page when the chat scroller approaches the bottom. Project
|
|
887
|
+
// groups need the complete list: otherwise an older project chat outside the
|
|
888
|
+
// first page makes its project incorrectly render as empty.
|
|
885
889
|
// Throttled by `isLoadingMoreChats` inside the hook.
|
|
886
890
|
useEffect(() => {
|
|
887
891
|
const container = chatScrollRef.current;
|
|
@@ -892,7 +896,9 @@ export function AgentSidebar({
|
|
|
892
896
|
const nearBottom =
|
|
893
897
|
container.scrollTop + container.clientHeight >=
|
|
894
898
|
container.scrollHeight - 60;
|
|
895
|
-
|
|
899
|
+
const needsCompleteProjectGroups =
|
|
900
|
+
showProjectsChrome && projects.length > 0;
|
|
901
|
+
if (nearBottom || needsCompleteProjectGroups) void loadMoreChats();
|
|
896
902
|
};
|
|
897
903
|
container.addEventListener("scroll", maybeLoadMore, { passive: true });
|
|
898
904
|
const tickId = window.requestAnimationFrame(maybeLoadMore);
|
|
@@ -906,6 +912,8 @@ export function AgentSidebar({
|
|
|
906
912
|
loadMoreChats,
|
|
907
913
|
isRecentsExpanded,
|
|
908
914
|
normalizedChats.length,
|
|
915
|
+
projects.length,
|
|
916
|
+
showProjectsChrome,
|
|
909
917
|
]);
|
|
910
918
|
|
|
911
919
|
const hasElectron = hasHostBridge();
|
|
@@ -1243,6 +1251,7 @@ export function AgentSidebar({
|
|
|
1243
1251
|
|
|
1244
1252
|
{/* ───── Footer: 设置 ───── */}
|
|
1245
1253
|
<div className="flex-shrink-0 border-t border-agent-border/40 px-2.5 py-1.5">
|
|
1254
|
+
<SidebarAccount />
|
|
1246
1255
|
{hasGeneralSettingsChrome() && (
|
|
1247
1256
|
<button
|
|
1248
1257
|
type="button"
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 侧栏底部的供应商账户余额。只在当前模型是 DeepSeek 或 Kimi,
|
|
3
|
+
* 且本机已经保存了密钥时出现。数字来自供应商余额接口,不是本地估算。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
7
|
+
import { LuGauge, LuLoaderCircle, LuRefreshCw } from 'react-icons/lu';
|
|
8
|
+
import { t } from '@/i18n';
|
|
9
|
+
import { hasHostBridge } from '@/lib/host-bridge';
|
|
10
|
+
import { getLlmAccount, LLM_SETTINGS_CHANGED_EVENT, type LlmAccount } from '@/lib/local-api';
|
|
11
|
+
|
|
12
|
+
const POLL_MS = 60_000;
|
|
13
|
+
|
|
14
|
+
function formatMoney(amount: string, currency: string | null): string {
|
|
15
|
+
const code = (currency ?? '').toUpperCase();
|
|
16
|
+
if (code === 'CNY') return `¥${amount}`;
|
|
17
|
+
if (code === 'USD') return `$${amount}`;
|
|
18
|
+
return code ? `${code} ${amount}` : amount;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function numericMoney(amount: string | null): number | null {
|
|
22
|
+
if (amount === null) return null;
|
|
23
|
+
const value = Number(amount);
|
|
24
|
+
return Number.isFinite(value) ? value : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function breakdown(account: LlmAccount): string {
|
|
28
|
+
if (account.status !== 'ready') return '';
|
|
29
|
+
const currency = account.currency;
|
|
30
|
+
const total = numericMoney(account.total);
|
|
31
|
+
const granted = numericMoney(account.granted);
|
|
32
|
+
const toppedUp = numericMoney(account.toppedUp);
|
|
33
|
+
const hasGrantedBalance = granted !== null && granted > 0;
|
|
34
|
+
const toppedUpAddsInformation =
|
|
35
|
+
toppedUp !== null && total !== null && Math.abs(toppedUp - total) > 0.000001;
|
|
36
|
+
if (!hasGrantedBalance && !toppedUpAddsInformation) return '';
|
|
37
|
+
|
|
38
|
+
const parts: string[] = [];
|
|
39
|
+
if (account.provider === 'deepseek') {
|
|
40
|
+
if (hasGrantedBalance && account.granted) {
|
|
41
|
+
parts.push(t('Granted {amount}', { amount: formatMoney(account.granted, currency) }));
|
|
42
|
+
}
|
|
43
|
+
if (account.toppedUp) {
|
|
44
|
+
parts.push(t('Topped up {amount}', { amount: formatMoney(account.toppedUp, currency) }));
|
|
45
|
+
}
|
|
46
|
+
} else if (account.provider === 'moonshot') {
|
|
47
|
+
if (hasGrantedBalance && account.granted) {
|
|
48
|
+
parts.push(t('Voucher {amount}', { amount: formatMoney(account.granted, currency) }));
|
|
49
|
+
}
|
|
50
|
+
if (account.toppedUp) {
|
|
51
|
+
parts.push(t('Cash {amount}', { amount: formatMoney(account.toppedUp, currency) }));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return parts.join(' · ');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function SidebarAccount() {
|
|
58
|
+
const [account, setAccount] = useState<LlmAccount | null>(null);
|
|
59
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
60
|
+
const requestId = useRef(0);
|
|
61
|
+
|
|
62
|
+
const load = useCallback(async (refresh: boolean) => {
|
|
63
|
+
if (!hasHostBridge()) return;
|
|
64
|
+
const id = ++requestId.current;
|
|
65
|
+
setRefreshing(true);
|
|
66
|
+
try {
|
|
67
|
+
const next = await getLlmAccount(refresh);
|
|
68
|
+
if (id === requestId.current) setAccount(next);
|
|
69
|
+
} catch {
|
|
70
|
+
// 本机接口暂时不可达时保留上一次成功的余额。
|
|
71
|
+
} finally {
|
|
72
|
+
if (id === requestId.current) setRefreshing(false);
|
|
73
|
+
}
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!hasHostBridge()) return;
|
|
78
|
+
void load(false);
|
|
79
|
+
const timer = window.setInterval(() => void load(false), POLL_MS);
|
|
80
|
+
const onFocus = () => void load(false);
|
|
81
|
+
const onSettings = () => void load(true);
|
|
82
|
+
window.addEventListener('focus', onFocus);
|
|
83
|
+
window.addEventListener(LLM_SETTINGS_CHANGED_EVENT, onSettings);
|
|
84
|
+
return () => {
|
|
85
|
+
window.clearInterval(timer);
|
|
86
|
+
window.removeEventListener('focus', onFocus);
|
|
87
|
+
window.removeEventListener(LLM_SETTINGS_CHANGED_EVENT, onSettings);
|
|
88
|
+
};
|
|
89
|
+
}, [load]);
|
|
90
|
+
|
|
91
|
+
if (!account || (account.status !== 'ready' && account.status !== 'failed')) return null;
|
|
92
|
+
|
|
93
|
+
const detail = breakdown(account);
|
|
94
|
+
const availableLabel = account.available === true
|
|
95
|
+
? t('Available')
|
|
96
|
+
: account.available === false
|
|
97
|
+
? t('Unavailable')
|
|
98
|
+
: null;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div
|
|
102
|
+
data-testid="sidebar-account"
|
|
103
|
+
className="mb-1.5 px-1 py-1"
|
|
104
|
+
aria-label={t('Account balance')}
|
|
105
|
+
>
|
|
106
|
+
<div className="flex items-center gap-1.5">
|
|
107
|
+
<div className="flex min-w-0 flex-1 items-center gap-1.5 text-[11px] text-agent-foreground">
|
|
108
|
+
<LuGauge className="h-3 w-3 shrink-0 text-agent-muted-foreground" />
|
|
109
|
+
<span className="truncate font-medium">{account.label}</span>
|
|
110
|
+
</div>
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
data-testid="sidebar-account-refresh"
|
|
114
|
+
title={t('Refresh balance')}
|
|
115
|
+
aria-label={t('Refresh balance')}
|
|
116
|
+
disabled={refreshing}
|
|
117
|
+
onClick={() => void load(true)}
|
|
118
|
+
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground/70 transition-colors hover:bg-agent-foreground/10 hover:text-agent-foreground disabled:opacity-50"
|
|
119
|
+
>
|
|
120
|
+
{refreshing ? (
|
|
121
|
+
<LuLoaderCircle className="h-3 w-3 animate-spin" />
|
|
122
|
+
) : (
|
|
123
|
+
<LuRefreshCw className="h-3 w-3" />
|
|
124
|
+
)}
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
{account.status === 'ready' && account.total ? (
|
|
128
|
+
<>
|
|
129
|
+
<div className="mt-1.5 flex items-center justify-between gap-2 px-0.5 text-xs">
|
|
130
|
+
<span className="shrink-0 text-agent-foreground">{t('Account balance')}</span>
|
|
131
|
+
<div className="flex min-w-0 items-center justify-end gap-1">
|
|
132
|
+
<span
|
|
133
|
+
data-testid="sidebar-account-total"
|
|
134
|
+
className="truncate font-medium tabular-nums text-agent-foreground"
|
|
135
|
+
>
|
|
136
|
+
{formatMoney(account.total, account.currency)}
|
|
137
|
+
</span>
|
|
138
|
+
{availableLabel ? (
|
|
139
|
+
<span
|
|
140
|
+
className={`shrink-0 text-[10px] ${account.available ? 'text-emerald-600' : 'text-amber-600'}`}
|
|
141
|
+
>
|
|
142
|
+
{availableLabel}
|
|
143
|
+
</span>
|
|
144
|
+
) : null}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
{detail ? (
|
|
148
|
+
<p
|
|
149
|
+
className="mt-1 truncate px-0.5 text-[9px] leading-none text-agent-muted-foreground"
|
|
150
|
+
title={detail}
|
|
151
|
+
>
|
|
152
|
+
{detail}
|
|
153
|
+
</p>
|
|
154
|
+
) : null}
|
|
155
|
+
</>
|
|
156
|
+
) : (
|
|
157
|
+
<p className="mt-1 text-[10px] text-agent-muted-foreground">{t('Balance unavailable')}</p>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -85,6 +85,18 @@ export function TerminalView({
|
|
|
85
85
|
// Cmd+V / Ctrl+Shift+V → 从剪贴板读出来 paste 到 PTY
|
|
86
86
|
// Cmd/Ctrl+A → 全选
|
|
87
87
|
const isMac = navigator.platform.toUpperCase().includes('MAC');
|
|
88
|
+
// WKWebView's navigator.clipboard.readText() is empty or prompts; the
|
|
89
|
+
// desktop host reads the OS pasteboard directly.
|
|
90
|
+
const pasteFromClipboard = () => {
|
|
91
|
+
const readText = bridge?.readClipboardText
|
|
92
|
+
? () => bridge.readClipboardText!()
|
|
93
|
+
: () => navigator.clipboard.readText();
|
|
94
|
+
void readText()
|
|
95
|
+
.then((text) => {
|
|
96
|
+
if (text) term.paste(text);
|
|
97
|
+
})
|
|
98
|
+
.catch(() => {});
|
|
99
|
+
};
|
|
88
100
|
term.attachCustomKeyEventHandler((event) => {
|
|
89
101
|
if (event.type !== 'keydown') return true;
|
|
90
102
|
const mod = isMac ? event.metaKey : event.ctrlKey;
|
|
@@ -103,12 +115,8 @@ export function TerminalView({
|
|
|
103
115
|
(isMac && mod && key === 'v') ||
|
|
104
116
|
(!isMac && event.ctrlKey && event.shiftKey && key === 'v')
|
|
105
117
|
) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
.then((text) => {
|
|
109
|
-
if (text) term.paste(text);
|
|
110
|
-
})
|
|
111
|
-
.catch(() => {});
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
pasteFromClipboard();
|
|
112
120
|
return false;
|
|
113
121
|
}
|
|
114
122
|
if (mod && key === 'a' && !event.shiftKey) {
|
|
@@ -125,12 +133,7 @@ export function TerminalView({
|
|
|
125
133
|
void navigator.clipboard.writeText(sel).catch(() => {});
|
|
126
134
|
term.clearSelection();
|
|
127
135
|
} else {
|
|
128
|
-
|
|
129
|
-
.readText()
|
|
130
|
-
.then((text) => {
|
|
131
|
-
if (text) term.paste(text);
|
|
132
|
-
})
|
|
133
|
-
.catch(() => {});
|
|
136
|
+
pasteFromClipboard();
|
|
134
137
|
}
|
|
135
138
|
};
|
|
136
139
|
containerRef.current.addEventListener('contextmenu', onContextMenu);
|
|
@@ -85,7 +85,10 @@ export type { ExecPolicy, McpToolItem, SkillItem };
|
|
|
85
85
|
* - Compact-mode responsive layout.
|
|
86
86
|
*/
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
// Empty field should read as two lines of space. Two lines of text measure
|
|
89
|
+
// 40px (`pt-2` + two 16px line boxes) and still look like one row, so the
|
|
90
|
+
// floor is one line box taller.
|
|
91
|
+
const MIN_HEIGHT_PX = 56;
|
|
89
92
|
const MAX_HEIGHT_PX = 220;
|
|
90
93
|
const MENTION_PATTERN = /(@[^\s@]+)/g;
|
|
91
94
|
const MAX_MENTION_SUGGESTIONS = 8;
|
|
@@ -1571,12 +1574,14 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1571
1574
|
};
|
|
1572
1575
|
|
|
1573
1576
|
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
1574
|
-
|
|
1575
|
-
|
|
1577
|
+
// Cmd/Ctrl+V never reaches a paste event when the app menu owns the
|
|
1578
|
+
// shortcut, and WKWebView leaves that event's clipboard empty. Read the
|
|
1579
|
+
// OS pasteboard instead. Match `code` too: with an IME, `key` may not be "v".
|
|
1580
|
+
const pasteShortcut =
|
|
1576
1581
|
(event.metaKey || event.ctrlKey) &&
|
|
1577
1582
|
!event.altKey &&
|
|
1578
|
-
event.key.toLowerCase() === 'v'
|
|
1579
|
-
) {
|
|
1583
|
+
(event.key.toLowerCase() === 'v' || event.key === '\u0016' || event.code === 'KeyV');
|
|
1584
|
+
if (hostClipboardAvailable() && pasteShortcut) {
|
|
1580
1585
|
event.preventDefault();
|
|
1581
1586
|
requestHostPaste(event.currentTarget);
|
|
1582
1587
|
return;
|
|
@@ -42,10 +42,9 @@ import type { LlmSpeedSnapshot } from './process-status';
|
|
|
42
42
|
* - **No per-chat scroll memory across tabs.** Switching chats remounts
|
|
43
43
|
* this list (parent passes a new `key`), so we don't need the
|
|
44
44
|
* `chatScrollMemoryRef` machinery.
|
|
45
|
-
* - **
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* to a later phase.
|
|
45
|
+
* - **Sticky user-message group per turn.** Grouped turns pin user prompt
|
|
46
|
+
* cards at the top with `sticky top-0 z-10 bg-agent-canvas/95 backdrop-blur-xs`
|
|
47
|
+
* until the next turn scrolls into view and naturally pushes it off.
|
|
49
48
|
* - **No load-more on scroll-up.** Local-backend currently hydrates the
|
|
50
49
|
* last 200 messages on mount (see `AgentPage.tsx`). Pagination is a
|
|
51
50
|
* phase-2c follow-up.
|
|
@@ -247,41 +246,72 @@ export function MessageList({
|
|
|
247
246
|
scrollToBottom('auto');
|
|
248
247
|
}, [lastMessageId, lastContentLen, lastTimelineSig, suggestedSig, scrollToBottom]);
|
|
249
248
|
|
|
250
|
-
// Group consecutive
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
249
|
+
// Group consecutive messages into conversation turns.
|
|
250
|
+
// Each turn has an optional user group (1+ consecutive user messages)
|
|
251
|
+
// and zero or more subsequent assistant messages.
|
|
252
|
+
// Grouping into turn containers allows the user prompt to be sticky at
|
|
253
|
+
// the top of the viewport (`sticky top-0`) while scrolling through long
|
|
254
|
+
// assistant reasoning/tools/markdown in that turn, until the next turn
|
|
255
|
+
// scrolls into view and naturally pushes it off.
|
|
256
|
+
type UserGroupItem = { messages: ChatMessage[]; key: string };
|
|
257
|
+
type AssistantItem = { message: ChatMessage; originalIndex: number };
|
|
258
|
+
type ConversationTurn = {
|
|
259
|
+
key: string;
|
|
260
|
+
userGroup?: UserGroupItem;
|
|
261
|
+
assistants: AssistantItem[];
|
|
262
|
+
};
|
|
256
263
|
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
let
|
|
264
|
+
const conversationTurns = useMemo<ConversationTurn[]>(() => {
|
|
265
|
+
const turns: ConversationTurn[] = [];
|
|
266
|
+
let currentTurn: ConversationTurn | null = null;
|
|
267
|
+
let pendingUserGroup: ChatMessage[] = [];
|
|
260
268
|
|
|
261
269
|
for (let i = 0; i < visibleMessages.length; i++) {
|
|
262
270
|
const msg = visibleMessages[i];
|
|
263
271
|
if (msg.role === 'user') {
|
|
264
|
-
|
|
272
|
+
if (currentTurn && currentTurn.assistants.length > 0) {
|
|
273
|
+
// Previous turn already has assistant replies; finalize it
|
|
274
|
+
turns.push(currentTurn);
|
|
275
|
+
currentTurn = null;
|
|
276
|
+
}
|
|
277
|
+
pendingUserGroup.push(msg);
|
|
265
278
|
} else {
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
279
|
+
if (!currentTurn) {
|
|
280
|
+
currentTurn = {
|
|
281
|
+
key: pendingUserGroup.length > 0
|
|
282
|
+
? `turn-${pendingUserGroup[0].id}`
|
|
283
|
+
: `turn-head-${msg.id}`,
|
|
284
|
+
userGroup: pendingUserGroup.length > 0
|
|
285
|
+
? {
|
|
286
|
+
messages: pendingUserGroup,
|
|
287
|
+
key: `ug-${pendingUserGroup[0].id}`,
|
|
288
|
+
}
|
|
289
|
+
: undefined,
|
|
290
|
+
assistants: [],
|
|
291
|
+
};
|
|
292
|
+
pendingUserGroup = [];
|
|
273
293
|
}
|
|
274
|
-
|
|
294
|
+
currentTurn.assistants.push({ message: msg, originalIndex: i });
|
|
275
295
|
}
|
|
276
296
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
297
|
+
|
|
298
|
+
if (pendingUserGroup.length > 0) {
|
|
299
|
+
if (currentTurn) {
|
|
300
|
+
turns.push(currentTurn);
|
|
301
|
+
}
|
|
302
|
+
turns.push({
|
|
303
|
+
key: `turn-${pendingUserGroup[0].id}`,
|
|
304
|
+
userGroup: {
|
|
305
|
+
messages: pendingUserGroup,
|
|
306
|
+
key: `ug-${pendingUserGroup[0].id}`,
|
|
307
|
+
},
|
|
308
|
+
assistants: [],
|
|
282
309
|
});
|
|
310
|
+
} else if (currentTurn) {
|
|
311
|
+
turns.push(currentTurn);
|
|
283
312
|
}
|
|
284
|
-
|
|
313
|
+
|
|
314
|
+
return turns;
|
|
285
315
|
}, [visibleMessages]);
|
|
286
316
|
|
|
287
317
|
return (
|
|
@@ -294,148 +324,154 @@ export function MessageList({
|
|
|
294
324
|
emptyState ?? null
|
|
295
325
|
) : (
|
|
296
326
|
<div className="mx-auto w-full max-w-3xl space-y-4">
|
|
297
|
-
{
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
<
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
327
|
+
{conversationTurns.map((turn) => (
|
|
328
|
+
<div
|
|
329
|
+
key={turn.key}
|
|
330
|
+
className="conversation-turn relative space-y-4"
|
|
331
|
+
data-conversation-turn={turn.key}
|
|
332
|
+
>
|
|
333
|
+
{turn.userGroup && (
|
|
334
|
+
<div className="sticky top-0 z-10 -mx-3 px-3 py-1 bg-agent-canvas/95 backdrop-blur-xs">
|
|
335
|
+
{turn.userGroup.messages.length === 1 ? (
|
|
336
|
+
<UserMessage
|
|
337
|
+
key={turn.userGroup.messages[0].id}
|
|
338
|
+
message={turn.userGroup.messages[0]}
|
|
339
|
+
agents={agents}
|
|
340
|
+
chats={chats}
|
|
341
|
+
/>
|
|
342
|
+
) : (
|
|
343
|
+
<div
|
|
344
|
+
key={turn.userGroup.key}
|
|
345
|
+
className="group/user-group"
|
|
346
|
+
data-message-role="user-group"
|
|
347
|
+
>
|
|
348
|
+
<div className="flex justify-start">
|
|
349
|
+
<div className="mx-auto w-full max-w-[var(--chat-input-box-width)] overflow-hidden rounded-agent-lg border border-agent-border/80 bg-agent-muted/70 shadow-sm">
|
|
350
|
+
{turn.userGroup.messages.map((msg, idx) => (
|
|
351
|
+
<UserMessage
|
|
352
|
+
key={msg.id}
|
|
353
|
+
message={msg}
|
|
354
|
+
agents={agents}
|
|
355
|
+
chats={chats}
|
|
356
|
+
isGrouped
|
|
357
|
+
isAppended={idx > 0}
|
|
358
|
+
/>
|
|
359
|
+
))}
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
328
362
|
</div>
|
|
329
|
-
|
|
363
|
+
)}
|
|
330
364
|
</div>
|
|
331
|
-
)
|
|
332
|
-
}
|
|
365
|
+
)}
|
|
333
366
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
367
|
+
{turn.assistants.map((item) => {
|
|
368
|
+
const message = item.message;
|
|
369
|
+
const isLast = item.originalIndex === visibleMessages.length - 1;
|
|
370
|
+
// Action attribution: prefer the per-message map (set when the
|
|
371
|
+
// backend's `message_id` event reconciles the stream-time queue
|
|
372
|
+
// with the DB id). The framework keeps the in-flight assistant's
|
|
373
|
+
// placeholder id even after the backend emits `message_id`, so
|
|
374
|
+
// the current page cannot always key by DB id until reload.
|
|
375
|
+
// Keep showing `currentTurnActions` on the latest assistant turn
|
|
376
|
+
// after streaming ends; it is cleared when the next turn starts.
|
|
377
|
+
const persistedActions =
|
|
378
|
+
executedActionsByMessageId?.[message.id];
|
|
379
|
+
const isStreamingTail = isStreaming && isLast;
|
|
380
|
+
const isCurrentTurnTail =
|
|
381
|
+
isLast && currentTurnActions !== undefined && currentTurnActions.length > 0;
|
|
382
|
+
const actions = persistedActions
|
|
383
|
+
?? (isStreamingTail || isCurrentTurnTail ? currentTurnActions : undefined);
|
|
384
|
+
const persistedChildren = orchestrationChildrenByMessageId?.[message.id];
|
|
385
|
+
const isChildrenTail =
|
|
386
|
+
isLast && currentTurnChildren !== undefined && currentTurnChildren.length > 0;
|
|
387
|
+
const childList = persistedChildren
|
|
388
|
+
?? (isStreamingTail || isChildrenTail ? currentTurnChildren : undefined);
|
|
355
389
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
390
|
+
let isPlanMode = false;
|
|
391
|
+
if (message.messageMetadata) {
|
|
392
|
+
try {
|
|
393
|
+
const meta = JSON.parse(message.messageMetadata);
|
|
394
|
+
if (meta?.mode === 'plan') {
|
|
395
|
+
isPlanMode = true;
|
|
396
|
+
}
|
|
397
|
+
} catch {}
|
|
398
|
+
} else if (isStreamingTail && mode === 'plan') {
|
|
361
399
|
isPlanMode = true;
|
|
362
400
|
}
|
|
363
|
-
} catch {}
|
|
364
|
-
} else if (isStreamingTail && mode === 'plan') {
|
|
365
|
-
isPlanMode = true;
|
|
366
|
-
}
|
|
367
401
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
402
|
+
const persistedTimeline = timelineByMessageId?.[message.id];
|
|
403
|
+
const isCurrentTimelineTail = isLast && currentTurnTimeline !== undefined;
|
|
404
|
+
const turnTimeline = persistedTimeline
|
|
405
|
+
?? (isStreamingTail || isCurrentTimelineTail ? currentTurnTimeline : undefined);
|
|
372
406
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
407
|
+
const persistedTurnFiles = turnFilesByMessageId?.[message.id];
|
|
408
|
+
const isTurnFilesTail =
|
|
409
|
+
isLast && currentTurnFiles !== undefined && currentTurnFiles.length > 0;
|
|
410
|
+
const turnFiles = persistedTurnFiles
|
|
411
|
+
?? (isTurnFilesTail ? currentTurnFiles : undefined);
|
|
378
412
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
413
|
+
const metadataJson =
|
|
414
|
+
typeof message.messageMetadata === 'string'
|
|
415
|
+
? message.messageMetadata
|
|
416
|
+
: undefined;
|
|
417
|
+
let previousUser: ChatMessage | undefined;
|
|
418
|
+
let previousUserCreatedAt: string | undefined;
|
|
419
|
+
for (let i = item.originalIndex - 1; i >= 0; i -= 1) {
|
|
420
|
+
if (visibleMessages[i].role === 'user') {
|
|
421
|
+
previousUser = visibleMessages[i];
|
|
422
|
+
previousUserCreatedAt = previousUser.createdAt;
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const durationMs = isStreamingTail
|
|
427
|
+
? undefined
|
|
428
|
+
: durationByMessageId?.[message.id]
|
|
429
|
+
?? readPersistedDurationMs(metadataJson)
|
|
430
|
+
?? inferDurationMs(previousUserCreatedAt, message.createdAt);
|
|
431
|
+
const persistedSpeed = llmSpeedByMessageId?.[message.id];
|
|
432
|
+
const isSpeedTail = isLast && currentLlmSpeed !== undefined;
|
|
433
|
+
const llmSpeed =
|
|
434
|
+
persistedSpeed
|
|
435
|
+
?? (isStreamingTail || isSpeedTail ? currentLlmSpeed : undefined);
|
|
402
436
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
437
|
+
return (
|
|
438
|
+
<div key={message.id}>
|
|
439
|
+
<AssistantMessage
|
|
440
|
+
message={message}
|
|
441
|
+
isStreaming={isStreamingTail}
|
|
442
|
+
agents={agents}
|
|
443
|
+
chats={chats}
|
|
444
|
+
chatId={chatId}
|
|
445
|
+
currentAgent={currentAgent}
|
|
446
|
+
executedActions={actions}
|
|
447
|
+
timeline={turnTimeline}
|
|
448
|
+
orchestrationChildren={childList}
|
|
449
|
+
previousUser={previousUser}
|
|
450
|
+
currentRound={isStreamingTail ? currentRound : undefined}
|
|
451
|
+
isPlanMode={isPlanMode}
|
|
452
|
+
onRegenerate={onRegenerate}
|
|
453
|
+
startedAtMs={isStreamingTail ? currentTurnStartedAtMs : undefined}
|
|
454
|
+
durationMs={durationMs}
|
|
455
|
+
llmSpeed={llmSpeed}
|
|
456
|
+
turnFiles={turnFiles}
|
|
457
|
+
onShare={message.id === lastAssistantId ? onShare : undefined}
|
|
458
|
+
onInspectTask={onInspectTask}
|
|
459
|
+
/>
|
|
460
|
+
{!isStreaming &&
|
|
461
|
+
message.id === lastAssistantId &&
|
|
462
|
+
suggestedReplies &&
|
|
463
|
+
suggestedReplies.length > 0 &&
|
|
464
|
+
onSelectSuggestion ? (
|
|
465
|
+
<SuggestedReplies
|
|
466
|
+
suggestions={suggestedReplies}
|
|
467
|
+
onSelect={onSelectSuggestion}
|
|
468
|
+
/>
|
|
469
|
+
) : null}
|
|
470
|
+
</div>
|
|
471
|
+
);
|
|
472
|
+
})}
|
|
473
|
+
</div>
|
|
474
|
+
))}
|
|
439
475
|
{/* W7-1: 中断提示卡在消息列尾部、与最后一条用户消息同列——
|
|
440
476
|
视觉上隶属于被截断的那一轮,而非全局横幅。流式期间不显示
|
|
441
477
|
(进行中的流不是中断)。 */}
|
|
@@ -484,6 +484,19 @@ async function request<T>(input: LocalBackendRequestInput): Promise<T> {
|
|
|
484
484
|
return { success: true, message: 'deleted in browser mock', chatId: deleteChatId } as T;
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
if (method === 'GET' && cleanPath === '/api/v2/llm/account') {
|
|
488
|
+
return {
|
|
489
|
+
status: 'unsupported',
|
|
490
|
+
provider: null,
|
|
491
|
+
label: '',
|
|
492
|
+
available: null,
|
|
493
|
+
currency: null,
|
|
494
|
+
total: null,
|
|
495
|
+
granted: null,
|
|
496
|
+
toppedUp: null,
|
|
497
|
+
} as T;
|
|
498
|
+
}
|
|
499
|
+
|
|
487
500
|
throw new Error(`Browser dev mock does not implement ${method} ${path}`);
|
|
488
501
|
}
|
|
489
502
|
|
package/src/lib/host-paste.ts
CHANGED
|
@@ -42,10 +42,7 @@ export function requestHostPaste(target?: HTMLElement | null): void {
|
|
|
42
42
|
const readText = bridge?.readClipboardText;
|
|
43
43
|
if ((!readClipboard && !readText) || pending) return;
|
|
44
44
|
pending = true;
|
|
45
|
-
|
|
46
|
-
? readClipboard()
|
|
47
|
-
: readText!().then((text) => ({ text, files: [] as HostClipboardFile[] }));
|
|
48
|
-
void read
|
|
45
|
+
void readHostClipboard(readClipboard, readText)
|
|
49
46
|
.then((clip) => {
|
|
50
47
|
if (clip.files.length > 0) insertHostFiles(clip.files, target ?? null);
|
|
51
48
|
if (clip.text) insertHostText(clip.text, target ?? null);
|
|
@@ -58,6 +55,30 @@ export function requestHostPaste(target?: HTMLElement | null): void {
|
|
|
58
55
|
});
|
|
59
56
|
}
|
|
60
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Prefer the rich pasteboard (text, files, screenshots). Desktop capabilities
|
|
60
|
+
* may allow only `host_read_clipboard_text`; a denial of the rich command must
|
|
61
|
+
* still insert plain text.
|
|
62
|
+
*/
|
|
63
|
+
function readHostClipboard(
|
|
64
|
+
readClipboard: (() => Promise<HostClipboard>) | undefined,
|
|
65
|
+
readText: (() => Promise<string>) | undefined,
|
|
66
|
+
): Promise<HostClipboard> {
|
|
67
|
+
const plain = (): Promise<HostClipboard> => {
|
|
68
|
+
if (!readText) return Promise.reject(new Error('clipboard text is unavailable'));
|
|
69
|
+
return readText().then((text) => ({ text, files: [] }));
|
|
70
|
+
};
|
|
71
|
+
if (!readClipboard) return plain();
|
|
72
|
+
return Promise.resolve()
|
|
73
|
+
.then(() => readClipboard())
|
|
74
|
+
.catch((error: unknown) => {
|
|
75
|
+
if (!readText) throw error;
|
|
76
|
+
return plain().catch(() => {
|
|
77
|
+
throw error;
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
61
82
|
export function hostClipboardAvailable(): boolean {
|
|
62
83
|
const bridge = getHostBridge();
|
|
63
84
|
return (
|
|
@@ -67,13 +88,23 @@ export function hostClipboardAvailable(): boolean {
|
|
|
67
88
|
|
|
68
89
|
function isEditable(target: EventTarget | null): target is HTMLElement {
|
|
69
90
|
return (
|
|
70
|
-
target
|
|
71
|
-
target instanceof
|
|
72
|
-
|
|
91
|
+
!isTerminalInput(target) &&
|
|
92
|
+
(target instanceof HTMLInputElement ||
|
|
93
|
+
target instanceof HTMLTextAreaElement ||
|
|
94
|
+
(target instanceof HTMLElement && target.isContentEditable))
|
|
73
95
|
);
|
|
74
96
|
}
|
|
75
97
|
|
|
98
|
+
/**
|
|
99
|
+
* xterm's hidden textarea. The terminal pastes into its PTY itself; text
|
|
100
|
+
* written into this textarea never reaches the shell.
|
|
101
|
+
*/
|
|
102
|
+
function isTerminalInput(target: EventTarget | null): boolean {
|
|
103
|
+
return target instanceof HTMLTextAreaElement && target.closest('.xterm') !== null;
|
|
104
|
+
}
|
|
105
|
+
|
|
76
106
|
function focusedEditable(): HTMLElement | null {
|
|
107
|
+
if (isTerminalInput(document.activeElement)) return null;
|
|
77
108
|
if (isEditable(document.activeElement)) return document.activeElement;
|
|
78
109
|
if (lastEditable?.isConnected) return lastEditable;
|
|
79
110
|
return null;
|
package/src/lib/local-api.ts
CHANGED
|
@@ -736,6 +736,25 @@ export async function getSidecarSandboxPosture() {
|
|
|
736
736
|
});
|
|
737
737
|
}
|
|
738
738
|
|
|
739
|
+
/** 当前 DeepSeek / Kimi 密钥在供应商侧的真实余额。其他厂商为 unsupported。 */
|
|
740
|
+
export interface LlmAccount {
|
|
741
|
+
status: 'ready' | 'unsupported' | 'missing_key' | 'failed';
|
|
742
|
+
provider: 'deepseek' | 'moonshot' | null;
|
|
743
|
+
label: string;
|
|
744
|
+
available: boolean | null;
|
|
745
|
+
currency: string | null;
|
|
746
|
+
total: string | null;
|
|
747
|
+
granted: string | null;
|
|
748
|
+
toppedUp: string | null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export async function getLlmAccount(refresh = false) {
|
|
752
|
+
return bridge().localBackend.request<LlmAccount>({
|
|
753
|
+
method: 'GET',
|
|
754
|
+
path: refresh ? '/api/v2/llm/account?refresh=1' : '/api/v2/llm/account',
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
739
758
|
export async function getLlmSettings() {
|
|
740
759
|
return bridge().localBackend.request<LlmSettings>({
|
|
741
760
|
method: 'GET',
|
|
@@ -743,12 +762,17 @@ export async function getLlmSettings() {
|
|
|
743
762
|
});
|
|
744
763
|
}
|
|
745
764
|
|
|
765
|
+
/** 模型设置保存后通知侧栏重新读取供应商余额。 */
|
|
766
|
+
export const LLM_SETTINGS_CHANGED_EVENT = 'steerable:llm-settings-changed';
|
|
767
|
+
|
|
746
768
|
export async function setLlmSettings(input: LlmSettings) {
|
|
747
|
-
|
|
769
|
+
const saved = await bridge().localBackend.request<LlmSettings>({
|
|
748
770
|
method: 'POST',
|
|
749
771
|
path: '/api/v2/local-settings/llm',
|
|
750
772
|
body: input,
|
|
751
773
|
});
|
|
774
|
+
window.dispatchEvent(new Event(LLM_SETTINGS_CHANGED_EVENT));
|
|
775
|
+
return saved;
|
|
752
776
|
}
|
|
753
777
|
|
|
754
778
|
/* ---------------- LLM link diagnosis ---------------- */
|
package/src/styles/index.css
CHANGED
|
@@ -75,26 +75,44 @@ body {
|
|
|
75
75
|
* ToolExecutionCard header: CJK status labels (已完成 / 失败 / …) wrap at
|
|
76
76
|
* every character when the flex row is squeezed. Keep the trailing status
|
|
77
77
|
* cluster at content width; let the truncated summary take the leftover.
|
|
78
|
-
*
|
|
78
|
+
*
|
|
79
|
+
* WKWebView (Tauri) lays out flex items of a <button> in the border box, so
|
|
80
|
+
* padding-right on that button does not inset 「已完成」. Clear that padding
|
|
81
|
+
* and inset the status with margin. The card also puts the same inset on an
|
|
82
|
+
* inner span; these direct-child rules only match the older button markup.
|
|
79
83
|
*/
|
|
80
|
-
.steerable-tool-execution
|
|
84
|
+
.steerable-tool-execution span.truncate {
|
|
81
85
|
min-width: 0;
|
|
82
86
|
flex: 1 1 0%;
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
.steerable-tool-execution
|
|
89
|
+
.steerable-tool-execution span.ml-auto {
|
|
86
90
|
flex-shrink: 0;
|
|
87
91
|
white-space: nowrap;
|
|
88
92
|
}
|
|
89
93
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
padding: 0
|
|
94
|
+
.steerable-tool-execution > div > button.flex-1 {
|
|
95
|
+
padding-left: 0;
|
|
96
|
+
padding-right: 0;
|
|
97
|
+
padding-inline: 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.steerable-tool-execution > div > button.flex-1 > span.ml-auto {
|
|
101
|
+
margin-inline-end: 0.625rem;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.steerable-tool-execution > div > button.flex-1.pl-2\.5 > :first-child {
|
|
105
|
+
margin-inline-start: 0.625rem;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.steerable-tool-execution > div > button.shrink-0 {
|
|
109
|
+
padding-left: 0;
|
|
110
|
+
padding-right: 0;
|
|
111
|
+
padding-inline: 0;
|
|
94
112
|
}
|
|
95
113
|
|
|
96
|
-
.steerable-tool-execution > button
|
|
97
|
-
|
|
114
|
+
.steerable-tool-execution > div > button.shrink-0 > svg {
|
|
115
|
+
margin-inline: 0.625rem;
|
|
98
116
|
}
|
|
99
117
|
|
|
100
118
|
/*
|