@linxin666/dsh-remote-web-ui 0.1.1
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/LICENSE +28 -0
- package/README.md +346 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +2817 -0
- package/lib/index.js +1455 -0
- package/lib/invariant.js +21 -0
- package/lib/types/client/PairFailedNotice.d.ts +12 -0
- package/lib/types/client/PairFailedNotice.d.ts.map +1 -0
- package/lib/types/client/PhoneIcon.d.ts +19 -0
- package/lib/types/client/PhoneIcon.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +78 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/RemoteEntry.d.ts +10 -0
- package/lib/types/client/RemoteEntry.d.ts.map +1 -0
- package/lib/types/client/RemotePanel.d.ts +50 -0
- package/lib/types/client/RemotePanel.d.ts.map +1 -0
- package/lib/types/client/RemoteSettingsCard.d.ts +75 -0
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -0
- package/lib/types/client/deep-link.d.ts +40 -0
- package/lib/types/client/deep-link.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +54 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +144 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/pair-api.d.ts +100 -0
- package/lib/types/client/pair-api.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +119 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/gate.d.ts +58 -0
- package/lib/types/gate.d.ts.map +1 -0
- package/lib/types/index.d.ts +78 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/lan.d.ts +15 -0
- package/lib/types/lan.d.ts.map +1 -0
- package/lib/types/mobile/api.d.ts +64 -0
- package/lib/types/mobile/api.d.ts.map +1 -0
- package/lib/types/mobile/index.d.ts +10 -0
- package/lib/types/mobile/index.d.ts.map +1 -0
- package/lib/types/mobile/messages.d.ts +103 -0
- package/lib/types/mobile/messages.d.ts.map +1 -0
- package/lib/types/mobile/messages.test.d.ts +2 -0
- package/lib/types/mobile/messages.test.d.ts.map +1 -0
- package/lib/types/mobile/mobile-styles.d.ts +6 -0
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -0
- package/lib/types/mobile/mobile-theme.d.ts +22 -0
- package/lib/types/mobile/mobile-theme.d.ts.map +1 -0
- package/lib/types/mobile/mobile-theme.test.d.ts +2 -0
- package/lib/types/mobile/mobile-theme.test.d.ts.map +1 -0
- package/lib/types/mobile/mux.d.ts +52 -0
- package/lib/types/mobile/mux.d.ts.map +1 -0
- package/lib/types/mobile/rpc.d.ts +38 -0
- package/lib/types/mobile/rpc.d.ts.map +1 -0
- package/lib/types/mobile/theme-toggle.d.ts +12 -0
- package/lib/types/mobile/theme-toggle.d.ts.map +1 -0
- package/lib/types/mobile/views/App.d.ts +59 -0
- package/lib/types/mobile/views/App.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.d.ts +39 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.test.d.ts +2 -0
- package/lib/types/mobile/views/ChatView.test.d.ts.map +1 -0
- package/lib/types/mobile/views/SessionListView.d.ts +27 -0
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -0
- package/lib/types/mobile/views/SessionListView.test.d.ts +2 -0
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +1 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts +19 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -0
- package/lib/types/mobile-api.d.ts +41 -0
- package/lib/types/mobile-api.d.ts.map +1 -0
- package/lib/types/mobile-routes.d.ts +16 -0
- package/lib/types/mobile-routes.d.ts.map +1 -0
- package/lib/types/pairing.d.ts +215 -0
- package/lib/types/pairing.d.ts.map +1 -0
- package/lib/types/routes.d.ts +58 -0
- package/lib/types/routes.d.ts.map +1 -0
- package/lib/types/tunnel.d.ts +99 -0
- package/lib/types/tunnel.d.ts.map +1 -0
- package/package.json +104 -0
- package/src/client/PairFailedNotice.tsx +33 -0
- package/src/client/PhoneIcon.tsx +31 -0
- package/src/client/PluginSettingsCard.tsx +207 -0
- package/src/client/RemoteEntry.tsx +202 -0
- package/src/client/RemotePanel.tsx +186 -0
- package/src/client/RemoteSettingsCard.tsx +220 -0
- package/src/client/deep-link.ts +137 -0
- package/src/client/index.ts +167 -0
- package/src/client/locales.ts +149 -0
- package/src/client/pair-api.ts +176 -0
- package/src/client/remote.module.css +422 -0
- package/src/client/settings-card.module.css +296 -0
- package/src/client/settings-form.ts +294 -0
- package/src/css-modules.d.ts +6 -0
- package/src/gate.ts +121 -0
- package/src/index.ts +299 -0
- package/src/invariant.ts +32 -0
- package/src/lan.ts +21 -0
- package/src/mobile/api.ts +106 -0
- package/src/mobile/index.tsx +27 -0
- package/src/mobile/messages.test.ts +222 -0
- package/src/mobile/messages.ts +555 -0
- package/src/mobile/mobile-styles.ts +853 -0
- package/src/mobile/mobile-theme.test.ts +84 -0
- package/src/mobile/mobile-theme.ts +76 -0
- package/src/mobile/mux.ts +126 -0
- package/src/mobile/rpc.ts +90 -0
- package/src/mobile/theme-toggle.tsx +47 -0
- package/src/mobile/views/App.tsx +152 -0
- package/src/mobile/views/ChatView.test.tsx +243 -0
- package/src/mobile/views/ChatView.tsx +650 -0
- package/src/mobile/views/SessionListView.test.tsx +139 -0
- package/src/mobile/views/SessionListView.tsx +186 -0
- package/src/mobile/views/WorkspaceView.tsx +108 -0
- package/src/mobile-api.ts +271 -0
- package/src/mobile-routes.ts +77 -0
- package/src/pairing.ts +390 -0
- package/src/routes.ts +346 -0
- package/src/tunnel.ts +264 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat level: one session. Loads the history tail page on open, appends
|
|
3
|
+
* pages upward (loadOlder), folds live mux frames in as they arrive, and
|
|
4
|
+
* sends prompts through session.prompt.
|
|
5
|
+
*
|
|
6
|
+
* Rendering mirrors the desktop web UI's fold discipline on a small screen:
|
|
7
|
+
* - reasoning text hides behind a collapsed "深度思考" disclosure,
|
|
8
|
+
* - tool calls behind a collapsed tool disclosure (name + arguments),
|
|
9
|
+
* - very long assistant text collapses with an explicit expand toggle,
|
|
10
|
+
* - a toolbar above the composer carries the model (+ thinking effort) and
|
|
11
|
+
* permission pickers, both as bottom sheets.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
15
|
+
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events'
|
|
16
|
+
import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
17
|
+
import { loadHistory, prompt, type SessionView } from './App.tsx'
|
|
18
|
+
import { errorText, formatTime, staleHostHint } from './App.tsx'
|
|
19
|
+
import { models, selectModel, sendCommand } from '../api.ts'
|
|
20
|
+
import { foldEvents, type RenderMessage, type ToolCallInfo, type WireEvent } from '../messages.ts'
|
|
21
|
+
import { MuxClient } from '../mux.ts'
|
|
22
|
+
import { ThemeToggle } from '../theme-toggle.tsx'
|
|
23
|
+
|
|
24
|
+
/** Props for the chat view. */
|
|
25
|
+
export interface ChatViewProps {
|
|
26
|
+
session: SessionView
|
|
27
|
+
/** The page-lifetime mux client (undefined before the first effect tick). */
|
|
28
|
+
mux?: MuxClient | undefined
|
|
29
|
+
onBack(): void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Extract the raw event from one history entry (the fold consumes events only). */
|
|
33
|
+
function eventOf(entry: { event: WireEvent }): WireEvent {
|
|
34
|
+
return entry.event
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Defensive runtime guard for projection payloads. */
|
|
38
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One switchable permission preset (the `permissions` projection shape). */
|
|
43
|
+
export interface PermissionOption {
|
|
44
|
+
value: string
|
|
45
|
+
name: string
|
|
46
|
+
description?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The `permissions` projection value: options + the effective current value. */
|
|
50
|
+
export interface PermissionSelectValue {
|
|
51
|
+
options: PermissionOption[]
|
|
52
|
+
currentValue: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Parse the wire `permissions` projection defensively; undefined when absent. */
|
|
56
|
+
function parsePermissionSelect(value: unknown): PermissionSelectValue | undefined {
|
|
57
|
+
if (!isRecord(value)) return undefined
|
|
58
|
+
const rawOptions = Array.isArray(value['options']) ? value['options'] : []
|
|
59
|
+
const options: PermissionOption[] = []
|
|
60
|
+
for (const raw of rawOptions) {
|
|
61
|
+
if (!isRecord(raw)) continue
|
|
62
|
+
const optionValue = typeof raw['value'] === 'string' ? raw['value'] : undefined
|
|
63
|
+
const name = typeof raw['name'] === 'string' ? raw['name'] : undefined
|
|
64
|
+
if (optionValue === undefined || name === undefined) continue
|
|
65
|
+
options.push({
|
|
66
|
+
value: optionValue,
|
|
67
|
+
name,
|
|
68
|
+
...(typeof raw['description'] === 'string' ? { description: raw['description'] } : {}),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
const currentValue = typeof value['currentValue'] === 'string' ? value['currentValue'] : undefined
|
|
72
|
+
if (currentValue === undefined || options.length === 0) return undefined
|
|
73
|
+
return { options, currentValue }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One display-name transform for kebab-case machine names (web-UI parity). */
|
|
77
|
+
function displayName(name: string): string {
|
|
78
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
|
|
79
|
+
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** First non-empty line of reasoning text (the collapsed summary). */
|
|
83
|
+
function firstMeaningfulLine(text: string): string {
|
|
84
|
+
const trimmed = text.trim()
|
|
85
|
+
if (trimmed === '') return ''
|
|
86
|
+
const newline = trimmed.indexOf('\n')
|
|
87
|
+
return newline === -1 ? trimmed : trimmed.slice(0, newline)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Render one session's chat.
|
|
92
|
+
* @param props - the session, the mux client, and the back action.
|
|
93
|
+
* @returns the chat surface.
|
|
94
|
+
*/
|
|
95
|
+
export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
96
|
+
const [messages, setMessages] = useState<RenderMessage[]>([])
|
|
97
|
+
const [hasOlder, setHasOlder] = useState(false)
|
|
98
|
+
const [loading, setLoading] = useState(true)
|
|
99
|
+
const [error, setError] = useState<string | undefined>(undefined)
|
|
100
|
+
const [input, setInput] = useState('')
|
|
101
|
+
const [sending, setSending] = useState(false)
|
|
102
|
+
const scrollRef = useRef<HTMLDivElement | undefined>(undefined)
|
|
103
|
+
const pendingRef = useRef(false)
|
|
104
|
+
|
|
105
|
+
/** The session's permission select (absent = capability not composed). */
|
|
106
|
+
const [permissions, setPermissions] = useState<PermissionSelectValue | undefined>(undefined)
|
|
107
|
+
/** The current model selection for the toolbar chip (best-effort label). */
|
|
108
|
+
const [currentModel, setCurrentModel] = useState<{ provider: string; model: string; reasoningEffort?: string } | undefined>(undefined)
|
|
109
|
+
/** Which bottom sheet is open. */
|
|
110
|
+
const [sheet, setSheet] = useState<'model' | 'permission' | null>(null)
|
|
111
|
+
|
|
112
|
+
// Tail page on open (content loads only when the session is opened).
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
let cancelled = false
|
|
115
|
+
setLoading(true)
|
|
116
|
+
setError(undefined)
|
|
117
|
+
setMessages([])
|
|
118
|
+
void loadHistory(session.sessionId).then(
|
|
119
|
+
(page) => {
|
|
120
|
+
if (cancelled) return
|
|
121
|
+
setMessages(foldEvents(page.events.map(eventOf)))
|
|
122
|
+
setHasOlder(page.hasMore)
|
|
123
|
+
setLoading(false)
|
|
124
|
+
// The history-tail projection baseline seeds the permission picker.
|
|
125
|
+
// The `permissions` key is declared by the deployment's permission
|
|
126
|
+
// plugin (augmentation), so the base SDK map is indexed loosely.
|
|
127
|
+
const projections = page.projections?.values as Record<string, unknown> | undefined
|
|
128
|
+
setPermissions(parsePermissionSelect(projections?.['permissions']))
|
|
129
|
+
scrollToBottom()
|
|
130
|
+
},
|
|
131
|
+
(reason: unknown) => {
|
|
132
|
+
if (cancelled) return
|
|
133
|
+
setError(errorText(reason))
|
|
134
|
+
setLoading(false)
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
// Best-effort current-model label for the toolbar chip; the sheet
|
|
138
|
+
// always re-reads a fresh directory on open.
|
|
139
|
+
void models(session.sessionId).then(
|
|
140
|
+
(directory) => {
|
|
141
|
+
if (!cancelled) setCurrentModel(directory.current)
|
|
142
|
+
},
|
|
143
|
+
() => { /* chip falls back to a plain label */ },
|
|
144
|
+
)
|
|
145
|
+
return () => { cancelled = true }
|
|
146
|
+
}, [session.sessionId])
|
|
147
|
+
|
|
148
|
+
// Live frames: fold session events for this session in as they arrive.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (mux === undefined) return
|
|
151
|
+
return mux.onFrame((frame: MuxFrame) => {
|
|
152
|
+
if (frame.type === 'session/event') {
|
|
153
|
+
if (frame.sessionId !== session.sessionId) return
|
|
154
|
+
setMessages(previous => foldEvents([frame.event as WireEvent], previous))
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
// Live projection pushes keep the permission picker current.
|
|
158
|
+
if (frame.type === 'session/projection'
|
|
159
|
+
&& frame.sessionId === session.sessionId
|
|
160
|
+
&& frame.key === 'permissions') {
|
|
161
|
+
setPermissions(parsePermissionSelect(frame.value))
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
}, [mux, session.sessionId])
|
|
165
|
+
|
|
166
|
+
// Keep the newest content visible while streaming.
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (messages.some(message => message.pending === true)) scrollToBottom()
|
|
169
|
+
}, [messages])
|
|
170
|
+
|
|
171
|
+
const scrollToBottom = useCallback(() => {
|
|
172
|
+
const el = scrollRef.current
|
|
173
|
+
if (el === undefined) return
|
|
174
|
+
el.scrollTop = el.scrollHeight
|
|
175
|
+
}, [])
|
|
176
|
+
|
|
177
|
+
/** Load one older page and prepend it. The fold is directional (incremental
|
|
178
|
+
* tails only), so the older page folds standalone and concatenates ahead —
|
|
179
|
+
* host page boundaries never cut a message, so the seam is exact. */
|
|
180
|
+
const loadOlder = useCallback(() => {
|
|
181
|
+
if (pendingRef.current) return
|
|
182
|
+
pendingRef.current = true
|
|
183
|
+
setLoading(true)
|
|
184
|
+
const first = messages[0]
|
|
185
|
+
if (first === undefined) {
|
|
186
|
+
pendingRef.current = false
|
|
187
|
+
setLoading(false)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
void loadHistory(session.sessionId, first.seq).then(
|
|
191
|
+
(page) => {
|
|
192
|
+
pendingRef.current = false
|
|
193
|
+
setLoading(false)
|
|
194
|
+
const older = foldEvents(page.events.map(eventOf))
|
|
195
|
+
setMessages(previous => [...older, ...previous])
|
|
196
|
+
setHasOlder(page.hasMore)
|
|
197
|
+
},
|
|
198
|
+
(reason: unknown) => {
|
|
199
|
+
pendingRef.current = false
|
|
200
|
+
setLoading(false)
|
|
201
|
+
setError(errorText(reason))
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
}, [session.sessionId, messages])
|
|
205
|
+
|
|
206
|
+
/** Send the drafted prompt (the echoed user/message arrives over mux). */
|
|
207
|
+
const send = useCallback(() => {
|
|
208
|
+
const text = input.trim()
|
|
209
|
+
if (text === '' || sending) return
|
|
210
|
+
setSending(true)
|
|
211
|
+
void prompt(session.sessionId, text).then(
|
|
212
|
+
() => {
|
|
213
|
+
setSending(false)
|
|
214
|
+
setInput('')
|
|
215
|
+
},
|
|
216
|
+
(reason: unknown) => {
|
|
217
|
+
setSending(false)
|
|
218
|
+
setError(errorText(reason))
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
}, [input, sending, session.sessionId])
|
|
222
|
+
|
|
223
|
+
const modelLabel = currentModel?.model ?? '模型'
|
|
224
|
+
const permissionLabel = permissions === undefined
|
|
225
|
+
? undefined
|
|
226
|
+
: permissions.options.find(option => option.value === permissions.currentValue)?.name
|
|
227
|
+
?? displayName(permissions.currentValue)
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div className="chat">
|
|
231
|
+
<header className="mobile-header">
|
|
232
|
+
<button type="button" className="mobile-back" aria-label="返回" onClick={onBack}>‹</button>
|
|
233
|
+
<h1 className="mobile-title mobile-titleInline">{session.title}</h1>
|
|
234
|
+
<ThemeToggle />
|
|
235
|
+
</header>
|
|
236
|
+
{error !== undefined && <p className="mobile-error mobile-pad">{error}</p>}
|
|
237
|
+
<div className="chat-scroll" ref={ref => { scrollRef.current = ref ?? undefined }}>
|
|
238
|
+
{hasOlder && (
|
|
239
|
+
<button type="button" className="chat-load-older" disabled={loading} onClick={() => { void loadOlder() }}>
|
|
240
|
+
{loading ? '加载中…' : '加载更早的消息'}
|
|
241
|
+
</button>
|
|
242
|
+
)}
|
|
243
|
+
{messages.map(message => <MessageRow key={message.id} message={message} />)}
|
|
244
|
+
{loading && messages.length === 0 && <p className="chat-typing">加载中…</p>}
|
|
245
|
+
{!loading && messages.length === 0 && <p className="chat-typing">还没有消息,发一句话开始吧</p>}
|
|
246
|
+
</div>
|
|
247
|
+
<div className="chat-tools">
|
|
248
|
+
<button type="button" className="chat-chip" onClick={() => { setSheet('model') }} aria-haspopup="dialog">
|
|
249
|
+
<span className="chat-chip-label">模型</span>
|
|
250
|
+
<span className="chat-chip-value">{modelLabel}</span>
|
|
251
|
+
<span className="chat-chip-chevron" aria-hidden>›</span>
|
|
252
|
+
</button>
|
|
253
|
+
{permissionLabel !== undefined && (
|
|
254
|
+
<button type="button" className="chat-chip" onClick={() => { setSheet('permission') }} aria-haspopup="dialog">
|
|
255
|
+
<span className="chat-chip-label">权限</span>
|
|
256
|
+
<span className="chat-chip-value">{permissionLabel}</span>
|
|
257
|
+
<span className="chat-chip-chevron" aria-hidden>›</span>
|
|
258
|
+
</button>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
<div className="chat-inputbar">
|
|
262
|
+
<textarea
|
|
263
|
+
className="chat-input"
|
|
264
|
+
rows={1}
|
|
265
|
+
value={input}
|
|
266
|
+
placeholder="输入消息,Enter 发送…"
|
|
267
|
+
enterKeyHint="send"
|
|
268
|
+
onChange={(event) => { setInput(event.target.value) }}
|
|
269
|
+
onKeyDown={(event) => {
|
|
270
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
271
|
+
event.preventDefault()
|
|
272
|
+
void send()
|
|
273
|
+
}
|
|
274
|
+
}}
|
|
275
|
+
/>
|
|
276
|
+
<button type="button" className="chat-send" disabled={sending || input.trim() === ''} onClick={() => { void send() }}>
|
|
277
|
+
{sending ? '发送中…' : '发送'}
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
{sheet === 'model' && (
|
|
281
|
+
<ModelSheet
|
|
282
|
+
sessionId={session.sessionId}
|
|
283
|
+
current={currentModel}
|
|
284
|
+
onCurrent={(selection) => { setCurrentModel(selection) }}
|
|
285
|
+
onClose={() => { setSheet(null) }}
|
|
286
|
+
/>
|
|
287
|
+
)}
|
|
288
|
+
{sheet === 'permission' && permissions !== undefined && (
|
|
289
|
+
<PermissionSheet
|
|
290
|
+
sessionId={session.sessionId}
|
|
291
|
+
value={permissions}
|
|
292
|
+
onChanged={(value) => {
|
|
293
|
+
setPermissions(previous => previous === undefined ? previous : { ...previous, currentValue: value })
|
|
294
|
+
}}
|
|
295
|
+
onClose={() => { setSheet(null) }}
|
|
296
|
+
/>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* ── message rows ─────────────────────────────────────────────────────── */
|
|
303
|
+
|
|
304
|
+
/** One rendered message row (user bubble or assistant bubble with folds). */
|
|
305
|
+
function MessageRow({ message }: { message: RenderMessage }) {
|
|
306
|
+
return (
|
|
307
|
+
<div className={`chat-msg chat-msg-${message.kind}${message.pending === true ? ' chat-msg-pending' : ''}${message.failed === true ? ' chat-msg-failed' : ''}`}>
|
|
308
|
+
{message.kind === 'assistant' && message.reasoning !== undefined && message.reasoning !== '' && (
|
|
309
|
+
<ReasoningDisclosure text={message.reasoning} pending={message.pending === true} />
|
|
310
|
+
)}
|
|
311
|
+
{message.kind === 'assistant' && message.tools !== undefined && message.tools.length > 0 && (
|
|
312
|
+
<ToolDisclosure tools={message.tools} />
|
|
313
|
+
)}
|
|
314
|
+
<CollapsibleText text={message.text} />
|
|
315
|
+
{message.failed === true && <span className="chat-msg-failtag">本次回复失败</span>}
|
|
316
|
+
<span className="chat-msg-time">{formatTime(message.time)}</span>
|
|
317
|
+
</div>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Collapsed-by-default reasoning disclosure (web-UI Think-row parity). */
|
|
322
|
+
function ReasoningDisclosure({ text, pending }: { text: string; pending: boolean }) {
|
|
323
|
+
const [open, setOpen] = useState(false)
|
|
324
|
+
const summary = pending ? lastLine(text) : firstMeaningfulLine(text)
|
|
325
|
+
return (
|
|
326
|
+
<div className={`chat-disclosure chat-reasoning${open ? ' chat-disclosure-open' : ''}`} data-pending={pending || undefined}>
|
|
327
|
+
<button
|
|
328
|
+
type="button"
|
|
329
|
+
className="chat-disclosure-head"
|
|
330
|
+
aria-expanded={open}
|
|
331
|
+
onClick={() => { setOpen(value => !value) }}
|
|
332
|
+
>
|
|
333
|
+
<span className="chat-disclosure-caret" aria-hidden>›</span>
|
|
334
|
+
<span className="chat-disclosure-label">{pending ? '思考中…' : '深度思考'}</span>
|
|
335
|
+
{!open && <span className="chat-disclosure-summary">{summary}</span>}
|
|
336
|
+
</button>
|
|
337
|
+
{open && <div className="chat-disclosure-body">{text}</div>}
|
|
338
|
+
</div>
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Collapsed-by-default tool-call disclosure: summary row + expandable details. */
|
|
343
|
+
function ToolDisclosure({ tools }: { tools: ToolCallInfo[] }) {
|
|
344
|
+
const [open, setOpen] = useState(false)
|
|
345
|
+
const names = [...new Set(tools.map(tool => tool.name))].join(' / ')
|
|
346
|
+
return (
|
|
347
|
+
<div className={`chat-disclosure chat-tools${open ? ' chat-disclosure-open' : ''}`}>
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
className="chat-disclosure-head"
|
|
351
|
+
aria-expanded={open}
|
|
352
|
+
onClick={() => { setOpen(value => !value) }}
|
|
353
|
+
>
|
|
354
|
+
<span className="chat-disclosure-caret" aria-hidden>›</span>
|
|
355
|
+
<span className="chat-disclosure-label">工具</span>
|
|
356
|
+
{!open && <span className="chat-disclosure-summary">{names}</span>}
|
|
357
|
+
<span className="chat-disclosure-count">{tools.length} 次</span>
|
|
358
|
+
</button>
|
|
359
|
+
{open && (
|
|
360
|
+
<div className="chat-disclosure-body chat-tools-body">
|
|
361
|
+
{tools.map((tool, index) => (
|
|
362
|
+
<div className="chat-tool-item" key={`${tool.callId}-${index}`}>
|
|
363
|
+
<span className="chat-tool-name">{tool.name}</span>
|
|
364
|
+
{tool.arguments !== undefined && <pre className="chat-tool-args">{tool.arguments}</pre>}
|
|
365
|
+
</div>
|
|
366
|
+
))}
|
|
367
|
+
</div>
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Long assistant text collapses behind an explicit expand toggle. */
|
|
374
|
+
function CollapsibleText({ text }: { text: string }) {
|
|
375
|
+
const [open, setOpen] = useState(false)
|
|
376
|
+
if (text.length <= LONG_TEXT_LIMIT) {
|
|
377
|
+
return <span className="chat-msg-text">{text}</span>
|
|
378
|
+
}
|
|
379
|
+
const shown = open ? text : text.slice(0, LONG_TEXT_PREVIEW)
|
|
380
|
+
return (
|
|
381
|
+
<span className="chat-msg-text">
|
|
382
|
+
{shown}{!open ? '…' : ''}
|
|
383
|
+
<button type="button" className="chat-msg-toggle" onClick={() => { setOpen(value => !value) }}>
|
|
384
|
+
{open ? '收起' : `展开全文(${text.length} 字)`}
|
|
385
|
+
</button>
|
|
386
|
+
</span>
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const LONG_TEXT_LIMIT = 1600
|
|
391
|
+
const LONG_TEXT_PREVIEW = 800
|
|
392
|
+
|
|
393
|
+
/** Latest non-empty line of a streaming reasoning buffer. */
|
|
394
|
+
function lastLine(text: string): string {
|
|
395
|
+
const trimmed = text.trimEnd()
|
|
396
|
+
if (trimmed === '') return ''
|
|
397
|
+
const newline = trimmed.lastIndexOf('\n')
|
|
398
|
+
const line = newline === -1 ? trimmed : trimmed.slice(newline + 1)
|
|
399
|
+
return line.trim() === '' ? '' : line
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/* ── bottom sheets ───────────────────────────────────────────────────── */
|
|
403
|
+
|
|
404
|
+
/** Shared bottom-sheet chrome (backdrop + slide-up panel). */
|
|
405
|
+
function Sheet({ title, onClose, children }: { title: string; onClose(): void; children: ReactNode }) {
|
|
406
|
+
return (
|
|
407
|
+
<div className="sheet-backdrop" onClick={onClose}>
|
|
408
|
+
<div
|
|
409
|
+
className="sheet"
|
|
410
|
+
role="dialog"
|
|
411
|
+
aria-modal="true"
|
|
412
|
+
aria-label={title}
|
|
413
|
+
onClick={(event) => { event.stopPropagation() }}
|
|
414
|
+
>
|
|
415
|
+
<div className="sheet-handle" aria-hidden />
|
|
416
|
+
<div className="sheet-title">{title}</div>
|
|
417
|
+
<div className="sheet-body">{children}</div>
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** The model + thinking-effort picker (fresh advisory directory per open). */
|
|
424
|
+
function ModelSheet({ sessionId, current, onCurrent, onClose }: {
|
|
425
|
+
sessionId: string
|
|
426
|
+
current: { provider: string; model: string; reasoningEffort?: string } | undefined
|
|
427
|
+
onCurrent(selection: { provider: string; model: string; reasoningEffort?: string }): void
|
|
428
|
+
onClose(): void
|
|
429
|
+
}) {
|
|
430
|
+
const [state, setState] = useState<{ status: 'loading' } | { status: 'error'; message: string } | { status: 'ready'; data: SessionModels }>({ status: 'loading' })
|
|
431
|
+
const [busy, setBusy] = useState(false)
|
|
432
|
+
const [error, setError] = useState<string | undefined>(undefined)
|
|
433
|
+
|
|
434
|
+
const load = useCallback(() => {
|
|
435
|
+
setState({ status: 'loading' })
|
|
436
|
+
void models(sessionId).then(
|
|
437
|
+
data => { setState({ status: 'ready', data }) },
|
|
438
|
+
(reason: unknown) => { setState({ status: 'error', message: errorText(reason) }) },
|
|
439
|
+
)
|
|
440
|
+
}, [sessionId])
|
|
441
|
+
|
|
442
|
+
useEffect(() => { load() }, [load])
|
|
443
|
+
|
|
444
|
+
/** Select model/effort and close on success (one-shot action per sheet). */
|
|
445
|
+
const apply = useCallback((selection: { provider: string; model: string; reasoningEffort?: string }) => {
|
|
446
|
+
if (busy) return
|
|
447
|
+
setBusy(true)
|
|
448
|
+
setError(undefined)
|
|
449
|
+
void selectModel(sessionId, selection).then(
|
|
450
|
+
(result) => {
|
|
451
|
+
setBusy(false)
|
|
452
|
+
onCurrent(result.selected)
|
|
453
|
+
onClose()
|
|
454
|
+
},
|
|
455
|
+
(reason: unknown) => {
|
|
456
|
+
setBusy(false)
|
|
457
|
+
setError(errorText(reason))
|
|
458
|
+
},
|
|
459
|
+
)
|
|
460
|
+
}, [busy, sessionId, onCurrent, onClose])
|
|
461
|
+
|
|
462
|
+
if (state.status === 'loading') {
|
|
463
|
+
return (
|
|
464
|
+
<Sheet title="模型与思考强度" onClose={onClose}>
|
|
465
|
+
<div className="sheet-status">正在加载模型目录…</div>
|
|
466
|
+
</Sheet>
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
if (state.status === 'error') {
|
|
470
|
+
return (
|
|
471
|
+
<Sheet title="模型与思考强度" onClose={onClose}>
|
|
472
|
+
<div className="sheet-status sheet-status-error">
|
|
473
|
+
<span>{state.message}</span>
|
|
474
|
+
{staleHostHint(state.message) !== undefined && <span className="sheet-hint">{staleHostHint(state.message)}</span>}
|
|
475
|
+
<button type="button" className="chat-load-older" onClick={load}>重试</button>
|
|
476
|
+
</div>
|
|
477
|
+
</Sheet>
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const { data } = state
|
|
482
|
+
const selected = current ?? data.current
|
|
483
|
+
const choices = data.groups.flatMap(group => group.models.map(model => ({ group, model })))
|
|
484
|
+
const currentChoice = choices.find(choice => choice.group.id === selected.provider && choice.model.id === selected.model)
|
|
485
|
+
const reasoning = currentChoice?.model.reasoning
|
|
486
|
+
const effectiveEffort = selected.reasoningEffort ?? reasoning?.defaultEffort
|
|
487
|
+
const effortChoices = reasoning === undefined
|
|
488
|
+
? []
|
|
489
|
+
: [
|
|
490
|
+
...(reasoning.defaultEffort === undefined
|
|
491
|
+
? [{ key: 'provider-default', effort: undefined as string | undefined, label: '跟随模型默认' }]
|
|
492
|
+
: []),
|
|
493
|
+
...reasoning.efforts.map(effort => ({
|
|
494
|
+
key: `effort:${effort.id}`,
|
|
495
|
+
effort: effort.id as string | undefined,
|
|
496
|
+
label: effort.name,
|
|
497
|
+
description: effort.description,
|
|
498
|
+
})),
|
|
499
|
+
]
|
|
500
|
+
|
|
501
|
+
return (
|
|
502
|
+
<Sheet title="模型与思考强度" onClose={onClose}>
|
|
503
|
+
{error !== undefined && <p className="sheet-error">{error}</p>}
|
|
504
|
+
{error !== undefined && staleHostHint(error) !== undefined && <p className="sheet-hint">{staleHostHint(error)}</p>}
|
|
505
|
+
{data.failures.map(failure => (
|
|
506
|
+
<p className="sheet-error" key={failure.id}>{failure.name}: {failure.message}</p>
|
|
507
|
+
))}
|
|
508
|
+
{data.groups.length === 0 && choices.length === 0 && (
|
|
509
|
+
<div className="sheet-status">没有可用的模型</div>
|
|
510
|
+
)}
|
|
511
|
+
{data.groups.map(group => (
|
|
512
|
+
<div className="sheet-section" key={group.id}>
|
|
513
|
+
<div className="sheet-section-title">{group.name}</div>
|
|
514
|
+
{group.models.map(model => {
|
|
515
|
+
const isSelected = selected.provider === group.id && selected.model === model.id
|
|
516
|
+
return (
|
|
517
|
+
<button
|
|
518
|
+
type="button"
|
|
519
|
+
key={model.id}
|
|
520
|
+
className={`sheet-option${isSelected ? ' sheet-option-selected' : ''}`}
|
|
521
|
+
disabled={busy}
|
|
522
|
+
onClick={() => {
|
|
523
|
+
apply({
|
|
524
|
+
provider: group.id,
|
|
525
|
+
model: model.id,
|
|
526
|
+
...(model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: model.reasoning.defaultEffort }),
|
|
527
|
+
})
|
|
528
|
+
}}
|
|
529
|
+
>
|
|
530
|
+
<span className="sheet-option-copy">
|
|
531
|
+
<span className="sheet-option-title">{model.name}</span>
|
|
532
|
+
{model.description !== undefined && <span className="sheet-option-desc">{model.description}</span>}
|
|
533
|
+
</span>
|
|
534
|
+
{isSelected && <span className="sheet-option-check" aria-hidden>√</span>}
|
|
535
|
+
</button>
|
|
536
|
+
)
|
|
537
|
+
})}
|
|
538
|
+
</div>
|
|
539
|
+
))}
|
|
540
|
+
{effortChoices.length > 0 && (
|
|
541
|
+
<div className="sheet-section">
|
|
542
|
+
<div className="sheet-section-title">思考强度</div>
|
|
543
|
+
{effortChoices.map(choice => {
|
|
544
|
+
const isSelected = effectiveEffort === choice.effort
|
|
545
|
+
return (
|
|
546
|
+
<button
|
|
547
|
+
type="button"
|
|
548
|
+
key={choice.key}
|
|
549
|
+
className={`sheet-option${isSelected ? ' sheet-option-selected' : ''}`}
|
|
550
|
+
disabled={busy}
|
|
551
|
+
onClick={() => { apply({ provider: selected.provider, model: selected.model, ...(choice.effort !== undefined ? { reasoningEffort: choice.effort } : {}) }) }}
|
|
552
|
+
>
|
|
553
|
+
<span className="sheet-option-copy">
|
|
554
|
+
<span className="sheet-option-title">{choice.label}</span>
|
|
555
|
+
</span>
|
|
556
|
+
{isSelected && <span className="sheet-option-check" aria-hidden>√</span>}
|
|
557
|
+
</button>
|
|
558
|
+
)
|
|
559
|
+
})}
|
|
560
|
+
</div>
|
|
561
|
+
)}
|
|
562
|
+
</Sheet>
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** The permission-preset picker; full access needs an explicit confirm. */
|
|
567
|
+
function PermissionSheet({ sessionId, value, onChanged, onClose }: {
|
|
568
|
+
sessionId: string
|
|
569
|
+
value: PermissionSelectValue
|
|
570
|
+
onChanged(currentValue: string): void
|
|
571
|
+
onClose(): void
|
|
572
|
+
}) {
|
|
573
|
+
const [confirming, setConfirming] = useState<string | null>(null)
|
|
574
|
+
const [busy, setBusy] = useState(false)
|
|
575
|
+
const [error, setError] = useState<string | undefined>(undefined)
|
|
576
|
+
|
|
577
|
+
/** Submit `/permission <value>` as a slash command (mode-agnostic). */
|
|
578
|
+
const submit = useCallback((next: string) => {
|
|
579
|
+
if (busy) return
|
|
580
|
+
setBusy(true)
|
|
581
|
+
setError(undefined)
|
|
582
|
+
void sendCommand(sessionId, `/permission ${next}`).then(
|
|
583
|
+
() => {
|
|
584
|
+
setBusy(false)
|
|
585
|
+
setConfirming(null)
|
|
586
|
+
onChanged(next)
|
|
587
|
+
onClose()
|
|
588
|
+
},
|
|
589
|
+
(reason: unknown) => {
|
|
590
|
+
setBusy(false)
|
|
591
|
+
setConfirming(null)
|
|
592
|
+
setError(errorText(reason))
|
|
593
|
+
},
|
|
594
|
+
)
|
|
595
|
+
}, [busy, sessionId, onChanged, onClose])
|
|
596
|
+
|
|
597
|
+
const choose = (next: string): void => {
|
|
598
|
+
if (next === value.currentValue) {
|
|
599
|
+
onClose()
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
if (next === 'danger-full-access') {
|
|
603
|
+
setConfirming(next)
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
submit(next)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (confirming !== null) {
|
|
610
|
+
return (
|
|
611
|
+
<Sheet title="确认完全权限" onClose={() => { setConfirming(null) }}>
|
|
612
|
+
<p className="sheet-confirm-desc">
|
|
613
|
+
开启完全权限后,远程会话可以在工作区内执行任意操作(包括运行命令、修改所有文件与访问凭证)。
|
|
614
|
+
仅在您信任当前设备和网络时开启。
|
|
615
|
+
</p>
|
|
616
|
+
{error !== undefined && <p className="sheet-error">{error}</p>}
|
|
617
|
+
<div className="sheet-confirm-actions">
|
|
618
|
+
<button type="button" className="mobile-button" disabled={busy} onClick={() => { setConfirming(null) }}>取消</button>
|
|
619
|
+
<button type="button" className="sheet-confirm-danger" disabled={busy} onClick={() => { submit(confirming) }}>
|
|
620
|
+
{busy ? '提交中…' : '确认开启'}
|
|
621
|
+
</button>
|
|
622
|
+
</div>
|
|
623
|
+
</Sheet>
|
|
624
|
+
)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return (
|
|
628
|
+
<Sheet title="权限" onClose={onClose}>
|
|
629
|
+
{error !== undefined && <p className="sheet-error">{error}</p>}
|
|
630
|
+
{value.options.map(option => {
|
|
631
|
+
const isSelected = option.value === value.currentValue
|
|
632
|
+
return (
|
|
633
|
+
<button
|
|
634
|
+
type="button"
|
|
635
|
+
key={option.value}
|
|
636
|
+
className={`sheet-option${isSelected ? ' sheet-option-selected' : ''}`}
|
|
637
|
+
disabled={busy}
|
|
638
|
+
onClick={() => { choose(option.value) }}
|
|
639
|
+
>
|
|
640
|
+
<span className="sheet-option-copy">
|
|
641
|
+
<span className="sheet-option-title">{option.name}</span>
|
|
642
|
+
{option.description !== undefined && <span className="sheet-option-desc">{option.description}</span>}
|
|
643
|
+
</span>
|
|
644
|
+
{isSelected && <span className="sheet-option-check" aria-hidden>√</span>}
|
|
645
|
+
</button>
|
|
646
|
+
)
|
|
647
|
+
})}
|
|
648
|
+
</Sheet>
|
|
649
|
+
)
|
|
650
|
+
}
|