@linxin666/dsh-remote-web-ui 0.3.6 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +77 -468
  3. package/README.zh.md +83 -113
  4. package/lib/client.js +955 -203
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1136 -1466
  7. package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
  8. package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
  9. package/lib/types/client/FooterRemoteEntry.js +1 -1
  10. package/lib/types/client/RemoteEntry.d.ts +7 -4
  11. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  12. package/lib/types/client/RemoteEntry.js +7 -10
  13. package/lib/types/client/RemotePanel.d.ts +2 -2
  14. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  15. package/lib/types/client/RemotePanel.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
  17. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  18. package/lib/types/client/RemoteSettingsCard.js +56 -3
  19. package/lib/types/client/deep-link.d.ts +10 -13
  20. package/lib/types/client/deep-link.d.ts.map +1 -1
  21. package/lib/types/client/deep-link.js +14 -74
  22. package/lib/types/client/index.d.ts +2 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +73 -39
  25. package/lib/types/client/locales.d.ts +26 -12
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +36 -22
  28. package/lib/types/client/mobile-adapt.d.ts +40 -0
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -0
  30. package/lib/types/client/mobile-adapt.js +967 -0
  31. package/lib/types/client/pair-api.d.ts +24 -6
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +9 -14
  34. package/lib/types/client/remote-channel.d.ts +3 -0
  35. package/lib/types/client/remote-channel.d.ts.map +1 -1
  36. package/lib/types/client/remote-channel.js +35 -3
  37. package/lib/types/client/settings-form.d.ts +35 -43
  38. package/lib/types/client/settings-form.d.ts.map +1 -1
  39. package/lib/types/client/settings-form.js +60 -53
  40. package/lib/types/firewall.d.ts +75 -0
  41. package/lib/types/firewall.d.ts.map +1 -0
  42. package/lib/types/firewall.js +184 -0
  43. package/lib/types/gate.d.ts +11 -5
  44. package/lib/types/gate.d.ts.map +1 -1
  45. package/lib/types/gate.js +11 -5
  46. package/lib/types/index.d.ts +39 -10
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +206 -58
  49. package/lib/types/inner-auth.d.ts +32 -0
  50. package/lib/types/inner-auth.d.ts.map +1 -0
  51. package/lib/types/inner-auth.js +72 -0
  52. package/lib/types/lan-bind-plan.d.ts +55 -0
  53. package/lib/types/lan-bind-plan.d.ts.map +1 -0
  54. package/lib/types/lan-bind-plan.js +60 -0
  55. package/lib/types/lan-bind.d.ts +69 -0
  56. package/lib/types/lan-bind.d.ts.map +1 -0
  57. package/lib/types/lan-bind.js +128 -0
  58. package/lib/types/loopback-proxy.d.ts +9 -2
  59. package/lib/types/loopback-proxy.d.ts.map +1 -1
  60. package/lib/types/loopback-proxy.js +24 -2
  61. package/lib/types/pairing.d.ts +15 -3
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/pairing.js +20 -6
  64. package/lib/types/remote-api.d.ts +30 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +55 -42
  67. package/lib/types/remote-channel-boot.d.ts +21 -2
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +65 -3
  70. package/lib/types/remote-channel-rules.d.ts +8 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +9 -2
  73. package/lib/types/remote-methods.d.ts +45 -11
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +56 -27
  76. package/lib/types/remote-presence-pet.d.ts +51 -0
  77. package/lib/types/remote-presence-pet.d.ts.map +1 -0
  78. package/lib/types/remote-presence-pet.js +101 -0
  79. package/lib/types/routes.d.ts +76 -4
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +336 -19
  82. package/lib/types/tunnel.d.ts.map +1 -1
  83. package/lib/types/tunnel.js +7 -1
  84. package/package.json +20 -18
  85. package/src/client/FooterRemoteEntry.tsx +9 -10
  86. package/src/client/RemoteEntry.tsx +13 -14
  87. package/src/client/RemotePanel.tsx +5 -16
  88. package/src/client/RemoteSettingsCard.tsx +72 -13
  89. package/src/client/deep-link.ts +14 -75
  90. package/src/client/index.ts +77 -39
  91. package/src/client/locales.ts +36 -22
  92. package/src/client/mobile-adapt.ts +913 -0
  93. package/src/client/pair-api.ts +26 -15
  94. package/src/client/remote-channel.ts +31 -3
  95. package/src/client/settings-form.ts +78 -92
  96. package/src/dsh-home.ts +1 -1
  97. package/src/firewall.ts +230 -0
  98. package/src/gate.ts +11 -5
  99. package/src/index.ts +235 -65
  100. package/src/inner-auth.ts +85 -0
  101. package/src/lan-bind-plan.ts +81 -0
  102. package/src/lan-bind.ts +145 -0
  103. package/src/loopback-proxy.ts +34 -0
  104. package/src/pairing.persist.test.ts +1 -1
  105. package/src/pairing.ts +20 -6
  106. package/src/remote-api.ts +67 -45
  107. package/src/remote-channel-boot.ts +70 -3
  108. package/src/remote-channel-rules.ts +17 -2
  109. package/src/remote-methods.ts +58 -27
  110. package/src/remote-presence-pet.ts +129 -0
  111. package/src/routes.ts +358 -19
  112. package/src/tunnel.ts +7 -1
  113. package/assets/apple-touch-icon.png +0 -0
  114. package/assets/mobile-icon-192.png +0 -0
  115. package/assets/mobile-icon-512.png +0 -0
  116. package/assets/mobile-service-worker.js +0 -63
  117. package/lib/mobile.js +0 -17658
  118. package/lib/mobile.js.map +0 -1
  119. package/lib/types/mobile/PairRequiredView.d.ts +0 -7
  120. package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
  121. package/lib/types/mobile/PairRequiredView.js +0 -28
  122. package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
  123. package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
  124. package/lib/types/mobile/PairRequiredView.test.js +0 -24
  125. package/lib/types/mobile/api.d.ts +0 -145
  126. package/lib/types/mobile/api.d.ts.map +0 -1
  127. package/lib/types/mobile/api.js +0 -103
  128. package/lib/types/mobile/index.d.ts +0 -10
  129. package/lib/types/mobile/index.d.ts.map +0 -1
  130. package/lib/types/mobile/index.js +0 -38
  131. package/lib/types/mobile/markdown.d.ts +0 -34
  132. package/lib/types/mobile/markdown.d.ts.map +0 -1
  133. package/lib/types/mobile/markdown.js +0 -285
  134. package/lib/types/mobile/markdown.test.d.ts +0 -2
  135. package/lib/types/mobile/markdown.test.d.ts.map +0 -1
  136. package/lib/types/mobile/markdown.test.js +0 -79
  137. package/lib/types/mobile/messages.d.ts +0 -149
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -561
  140. package/lib/types/mobile/messages.test.d.ts +0 -2
  141. package/lib/types/mobile/messages.test.d.ts.map +0 -1
  142. package/lib/types/mobile/messages.test.js +0 -356
  143. package/lib/types/mobile/mobile-styles.d.ts +0 -6
  144. package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
  145. package/lib/types/mobile/mobile-styles.js +0 -1438
  146. package/lib/types/mobile/mobile-theme.d.ts +0 -22
  147. package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
  148. package/lib/types/mobile/mobile-theme.js +0 -70
  149. package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
  150. package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
  151. package/lib/types/mobile/mobile-theme.test.js +0 -71
  152. package/lib/types/mobile/mux.d.ts +0 -128
  153. package/lib/types/mobile/mux.d.ts.map +0 -1
  154. package/lib/types/mobile/mux.js +0 -305
  155. package/lib/types/mobile/mux.test.d.ts +0 -2
  156. package/lib/types/mobile/mux.test.d.ts.map +0 -1
  157. package/lib/types/mobile/mux.test.js +0 -267
  158. package/lib/types/mobile/pairing.d.ts +0 -31
  159. package/lib/types/mobile/pairing.d.ts.map +0 -1
  160. package/lib/types/mobile/pairing.js +0 -62
  161. package/lib/types/mobile/rpc.d.ts +0 -38
  162. package/lib/types/mobile/rpc.d.ts.map +0 -1
  163. package/lib/types/mobile/rpc.js +0 -83
  164. package/lib/types/mobile/theme-toggle.d.ts +0 -12
  165. package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
  166. package/lib/types/mobile/theme-toggle.js +0 -26
  167. package/lib/types/mobile/views/App.d.ts +0 -57
  168. package/lib/types/mobile/views/App.d.ts.map +0 -1
  169. package/lib/types/mobile/views/App.js +0 -150
  170. package/lib/types/mobile/views/App.test.d.ts +0 -2
  171. package/lib/types/mobile/views/App.test.d.ts.map +0 -1
  172. package/lib/types/mobile/views/App.test.js +0 -52
  173. package/lib/types/mobile/views/ChatView.d.ts +0 -63
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -821
  176. package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
  177. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
  178. package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
  179. package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
  180. package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
  181. package/lib/types/mobile/views/ChatView.test.js +0 -718
  182. package/lib/types/mobile/views/SessionListView.d.ts +0 -27
  183. package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
  184. package/lib/types/mobile/views/SessionListView.js +0 -139
  185. package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
  186. package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
  187. package/lib/types/mobile/views/SessionListView.test.js +0 -167
  188. package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
  189. package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
  190. package/lib/types/mobile/views/WorkspaceView.js +0 -93
  191. package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
  192. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
  193. package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
  194. package/lib/types/mobile-api.d.ts +0 -65
  195. package/lib/types/mobile-api.d.ts.map +0 -1
  196. package/lib/types/mobile-api.js +0 -404
  197. package/lib/types/mobile-pending.d.ts +0 -38
  198. package/lib/types/mobile-pending.d.ts.map +0 -1
  199. package/lib/types/mobile-pending.js +0 -59
  200. package/lib/types/mobile-pwa.d.ts +0 -6
  201. package/lib/types/mobile-pwa.d.ts.map +0 -1
  202. package/lib/types/mobile-pwa.js +0 -17
  203. package/lib/types/mobile-routes.d.ts +0 -13
  204. package/lib/types/mobile-routes.d.ts.map +0 -1
  205. package/lib/types/mobile-routes.js +0 -217
  206. package/lib/types/paired-model-catalog.d.ts +0 -17
  207. package/lib/types/paired-model-catalog.d.ts.map +0 -1
  208. package/lib/types/paired-model-catalog.js +0 -311
  209. package/src/mobile/PairRequiredView.test.tsx +0 -28
  210. package/src/mobile/PairRequiredView.tsx +0 -58
  211. package/src/mobile/api.ts +0 -229
  212. package/src/mobile/index.tsx +0 -41
  213. package/src/mobile/markdown.test.ts +0 -91
  214. package/src/mobile/markdown.ts +0 -291
  215. package/src/mobile/messages.test.ts +0 -402
  216. package/src/mobile/messages.ts +0 -685
  217. package/src/mobile/mobile-styles.ts +0 -1439
  218. package/src/mobile/mobile-theme.test.ts +0 -84
  219. package/src/mobile/mobile-theme.ts +0 -76
  220. package/src/mobile/mux.test.ts +0 -314
  221. package/src/mobile/mux.ts +0 -336
  222. package/src/mobile/pairing.ts +0 -77
  223. package/src/mobile/rpc.ts +0 -90
  224. package/src/mobile/theme-toggle.tsx +0 -47
  225. package/src/mobile/views/App.test.tsx +0 -62
  226. package/src/mobile/views/App.tsx +0 -213
  227. package/src/mobile/views/ChatView.stream.test.tsx +0 -268
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1342
  230. package/src/mobile/views/SessionListView.test.tsx +0 -207
  231. package/src/mobile/views/SessionListView.tsx +0 -244
  232. package/src/mobile/views/WorkspaceView.test.tsx +0 -227
  233. package/src/mobile/views/WorkspaceView.tsx +0 -245
  234. package/src/mobile-api.ts +0 -418
  235. package/src/mobile-pending.ts +0 -88
  236. package/src/mobile-pwa.ts +0 -19
  237. package/src/mobile-routes.ts +0 -231
  238. package/src/paired-model-catalog.ts +0 -272
@@ -1,1342 +0,0 @@
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 { memo, useCallback, useEffect, useMemo, 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 { fetchMobilePreferences, models, selectModel, sendCommand, cancelSession, fetchPending, respondApproval, respondQuestion } from '../api.ts'
20
- import type { PendingApproval, PendingQuestionItem } from '../api.ts'
21
- import { EventFolder, foldEvents, type RenderMessage, type ToolCallInfo, type WireEvent } from '../messages.ts'
22
- import { renderMarkdown } from '../markdown.ts'
23
- import { MuxClient } from '../mux.ts'
24
- import { ThemeToggle } from '../theme-toggle.tsx'
25
-
26
- /** Props for the chat view. */
27
- export interface ChatViewProps {
28
- session: SessionView
29
- /** The page-lifetime mux client (undefined before the first effect tick). */
30
- mux?: MuxClient | undefined
31
- onBack(): void
32
- }
33
-
34
- /**
35
- * Hard cap on live events buffered while the initial history tail page is in
36
- * flight. Beyond this the oldest buffered event is dropped and a follow-up
37
- * history tail re-pull closes the seam.
38
- */
39
- export const MAX_TAIL_BUFFER_EVENTS = 500
40
-
41
- /**
42
- * Cadence for folding buffered live events into the message list. Streaming
43
- * chunks can arrive at a far higher rate; flushing on this cadence caps fold
44
- * and re-render work per second without visible lag, and keeps working in
45
- * background tabs where requestAnimationFrame is paused.
46
- */
47
- export const FOLD_FLUSH_MS = 50
48
-
49
- /** localStorage key for the tool-call display toggle (persisted on the /m origin). */
50
- const SHOW_TOOL_CALLS_KEY = 'dsh.mobile.showToolCalls'
51
- /** localStorage key for the injected-system-message display toggle. */
52
- const SHOW_SYSTEM_MESSAGES_KEY = 'dsh.mobile.showSystemMessages'
53
-
54
- /** Read a boolean from localStorage defensively; falls back to the default. */
55
- function readStoredBoolean(key: string, fallback: boolean): boolean {
56
- try {
57
- const raw = localStorage.getItem(key)
58
- if (raw === null) return fallback
59
- return raw === '1' || raw.toLowerCase() === 'true'
60
- } catch {
61
- return fallback
62
- }
63
- }
64
-
65
- /** Persist a boolean toggle; storage failures are ignored (feature stays non-persistent). */
66
- function writeStoredBoolean(key: string, value: boolean): void {
67
- try {
68
- localStorage.setItem(key, value ? '1' : '0')
69
- } catch {
70
- /* quota / privacy mode: non-persistent is acceptable */
71
- }
72
- }
73
-
74
- /** Extract the raw event from one history entry (the fold consumes events only). */
75
- function eventOf(entry: { event: WireEvent }): WireEvent {
76
- return entry.event
77
- }
78
-
79
- /** Defensive runtime guard for projection payloads. */
80
- function isRecord(value: unknown): value is Record<string, unknown> {
81
- return typeof value === 'object' && value !== null && !Array.isArray(value)
82
- }
83
-
84
- /** One switchable permission preset (the `permissions` projection shape). */
85
- export interface PermissionOption {
86
- value: string
87
- name: string
88
- description?: string
89
- }
90
-
91
- /** The `permissions` projection value: options + the effective current value. */
92
- export interface PermissionSelectValue {
93
- options: PermissionOption[]
94
- currentValue: string
95
- }
96
-
97
- /** Parse the wire `permissions` projection defensively; undefined when absent. */
98
- function parsePermissionSelect(value: unknown): PermissionSelectValue | undefined {
99
- if (!isRecord(value)) return undefined
100
- const rawOptions = Array.isArray(value['options']) ? value['options'] : []
101
- const options: PermissionOption[] = []
102
- for (const raw of rawOptions) {
103
- if (!isRecord(raw)) continue
104
- const optionValue = typeof raw['value'] === 'string' ? raw['value'] : undefined
105
- const name = typeof raw['name'] === 'string' ? raw['name'] : undefined
106
- if (optionValue === undefined || name === undefined) continue
107
- options.push({
108
- value: optionValue,
109
- name,
110
- ...(typeof raw['description'] === 'string' ? { description: raw['description'] } : {}),
111
- })
112
- }
113
- const currentValue = typeof value['currentValue'] === 'string' ? value['currentValue'] : undefined
114
- if (currentValue === undefined || options.length === 0) return undefined
115
- return { options, currentValue }
116
- }
117
-
118
- /** One display-name transform for kebab-case machine names (web-UI parity). */
119
- function displayName(name: string): string {
120
- if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
121
- return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
122
- }
123
-
124
- /** First non-empty line of reasoning text (the collapsed summary). */
125
- function firstMeaningfulLine(text: string): string {
126
- const trimmed = text.trim()
127
- if (trimmed === '') return ''
128
- const newline = trimmed.indexOf('\n')
129
- return newline === -1 ? trimmed : trimmed.slice(0, newline)
130
- }
131
-
132
- /**
133
- * Render one session's chat.
134
- * @param props - the session, the mux client, and the back action.
135
- * @returns the chat surface.
136
- */
137
- export function ChatView({ session, mux, onBack }: ChatViewProps) {
138
- const [messages, setMessages] = useState<RenderMessage[]>([])
139
- const [hasOlder, setHasOlder] = useState(false)
140
- const [loading, setLoading] = useState(true)
141
- const [error, setError] = useState<string | undefined>(undefined)
142
- const [input, setInput] = useState('')
143
- const [sending, setSending] = useState(false)
144
- const scrollRef = useRef<HTMLDivElement | undefined>(undefined)
145
- const pendingRef = useRef(false)
146
- /**
147
- * True while the initial tail page is in flight. Live events arriving in
148
- * that window go to {@link liveBufferRef} instead of the message list: the
149
- * tail load replaces the list wholesale, so a directly folded event would
150
- * flash once, be discarded by the snapshot, and then be skipped forever by
151
- * the seq watermark.
152
- */
153
- const tailLoadingRef = useRef(true)
154
- /** Live session events buffered while the initial tail page loads. */
155
- const liveBufferRef = useRef<WireEvent[]>([])
156
- /** Incremental folder for this session's stream (indexes stay hot across events). */
157
- const folderRef = useRef<EventFolder | undefined>(undefined)
158
- /**
159
- * Live events buffered between fold flushes. Folding every single event
160
- * costs one O(messages) snapshot copy per event; flushing at a fixed
161
- * cadence folds each burst once while keeping streaming text fluid.
162
- */
163
- const foldBufferRef = useRef<WireEvent[]>([])
164
- /** Timer id of the scheduled fold flush, if any. */
165
- const foldFlushTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
166
- /** True once the live buffer hit its cap (oldest events were dropped). */
167
- const liveBufferOverflowRef = useRef(false)
168
-
169
- /** The session's permission select (absent = capability not composed). */
170
- const [permissions, setPermissions] = useState<PermissionSelectValue | undefined>(undefined)
171
- /** The current model selection for the toolbar chip (best-effort label). */
172
- const [currentModel, setCurrentModel] = useState<{ provider: string; model: string; reasoningEffort?: string } | undefined>(undefined)
173
- /** Which bottom sheet is open. */
174
- const [sheet, setSheet] = useState<'model' | 'permission' | 'display' | null>(null)
175
- /** Show tool-call disclosures (default on, persisted on the /m origin). */
176
- const [showToolCalls, setShowToolCalls] = useState<boolean>(() => readStoredBoolean(SHOW_TOOL_CALLS_KEY, true))
177
- /** Show injected system messages (default off, persisted on the /m origin). */
178
- const [showSystemMessages, setShowSystemMessages] = useState<boolean>(() => readStoredBoolean(SHOW_SYSTEM_MESSAGES_KEY, false))
179
- /**
180
- * Composer preference from the plugin's host settings (default true keeps
181
- * the legacy Enter-to-send behavior until the preference loads).
182
- */
183
- const [mobileEnterToSend, setMobileEnterToSend] = useState(true)
184
- /** Whether the assistant is currently generating (turn/start..turn/end). */
185
- const [running, setRunning] = useState(false)
186
- /** Whether a stop request is in flight (guards the composer's stop button). */
187
- const [stopping, setStopping] = useState(false)
188
- /** Pending tool approvals awaiting user decision (#1025). */
189
- const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([])
190
- /** Pending questions awaiting user answer (#1025). */
191
- const [pendingQuestions, setPendingQuestions] = useState<PendingQuestionItem[]>([])
192
-
193
- // Read-only mobile display preferences ride the plugin's local
194
- // `/m/api` method; a failure keeps the default (Enter sends).
195
- useEffect(() => {
196
- let cancelled = false
197
- void fetchMobilePreferences().then(
198
- (preferences) => {
199
- if (!cancelled) setMobileEnterToSend(preferences.mobileEnterToSend !== false)
200
- },
201
- () => { /* keep the default */ },
202
- )
203
- return () => { cancelled = true }
204
- }, [])
205
-
206
- // Tail page on open (content loads only when the session is opened).
207
- useEffect(() => {
208
- let cancelled = false
209
- const controller = new AbortController()
210
- // A stuck history load must not keep the chat empty (or the live buffer
211
- // growing) forever: abort it and surface the transport error.
212
- const timeout = setTimeout(() => {
213
- controller.abort(new DOMException('history load timed out', 'TimeoutError'))
214
- }, 15_000)
215
- tailLoadingRef.current = true
216
- liveBufferRef.current = []
217
- liveBufferOverflowRef.current = false
218
- folderRef.current = undefined
219
- setLoading(true)
220
- setError(undefined)
221
- setMessages([])
222
- void loadHistory(session.sessionId, undefined, controller.signal).then(
223
- (page) => {
224
- if (cancelled) return
225
- // Buffered live events re-fold on top of the snapshot; the watermark
226
- // drops any the snapshot already includes, so nothing is lost or doubled.
227
- const buffered = liveBufferRef.current
228
- liveBufferRef.current = []
229
- tailLoadingRef.current = false
230
- const folder = new EventFolder(foldEvents(page.events.map(eventOf)))
231
- folderRef.current = folder
232
- setMessages(folder.fold(buffered))
233
- setHasOlder(page.hasMore)
234
- setLoading(false)
235
- // The history-tail projection baseline seeds the permission picker.
236
- // The `permissions` key is declared by the deployment's permission
237
- // plugin (augmentation), so the base SDK map is indexed loosely.
238
- const projections = page.projections?.values as Record<string, unknown> | undefined
239
- setPermissions(parsePermissionSelect(projections?.['permissions']))
240
- // The buffer overflowed while waiting (oldest events were dropped), so
241
- // re-pull the freshest history page to close the gap on top of what is
242
- // already rendered. Best-effort: a failure here only logs, it must not
243
- // replace the loaded state with an error.
244
- if (liveBufferOverflowRef.current) {
245
- void loadHistory(session.sessionId, undefined, controller.signal).then(
246
- (fresh) => {
247
- if (cancelled) return
248
- const folder = folderRef.current
249
- const freshEvents = fresh.events.map(eventOf)
250
- setMessages(previous => folder === undefined ? foldEvents(freshEvents, previous) : folder.fold(freshEvents))
251
- liveBufferOverflowRef.current = false
252
- },
253
- (reason: unknown) => {
254
- if (cancelled) return
255
- console.warn('history tail re-pull after live buffer overflow failed', reason)
256
- },
257
- )
258
- }
259
- },
260
- (reason: unknown) => {
261
- if (cancelled) return
262
- // Load failed: flush the buffer so the live stream still renders.
263
- const buffered = liveBufferRef.current
264
- liveBufferRef.current = []
265
- tailLoadingRef.current = false
266
- if (buffered.length > 0) {
267
- const folder = folderRef.current
268
- setMessages(folder === undefined ? foldEvents(buffered) : folder.fold(buffered))
269
- }
270
- setError(errorText(reason))
271
- setLoading(false)
272
- },
273
- )
274
- // Best-effort current-model label for the toolbar chip; the sheet
275
- // always re-reads a fresh directory on open.
276
- void models(session.sessionId).then(
277
- (directory) => {
278
- if (!cancelled) setCurrentModel(directory.current)
279
- },
280
- () => { /* chip falls back to a plain label */ },
281
- )
282
- return () => {
283
- cancelled = true
284
- clearTimeout(timeout)
285
- controller.abort()
286
- }
287
- }, [session.sessionId])
288
-
289
- // Live frames: fold session events for this session in as they arrive.
290
- useEffect(() => {
291
- if (mux === undefined) return
292
- /** Fold every buffered live event in one batch and publish the snapshot. */
293
- const flushFoldBuffer = (): void => {
294
- if (foldFlushTimerRef.current !== undefined) {
295
- clearTimeout(foldFlushTimerRef.current)
296
- foldFlushTimerRef.current = undefined
297
- }
298
- const events = foldBufferRef.current
299
- if (events.length === 0) return
300
- foldBufferRef.current = []
301
- setMessages(previous => {
302
- const folder = folderRef.current
303
- return folder === undefined ? foldEvents(events, previous) : folder.fold(events)
304
- })
305
- }
306
- const unsubscribe = mux.onFrame((frame: MuxFrame) => {
307
- if (frame.type === 'session/event') {
308
- if (frame.sessionId !== session.sessionId) return
309
- const event = frame.event as WireEvent
310
- // Track the turn running state for the "outputting" indicator (#1017).
311
- if (typeof event.type === 'string') {
312
- if (event.type === 'turn/start') setRunning(true)
313
- if (event.type === 'turn/end') setRunning(false)
314
- }
315
- if (tailLoadingRef.current) {
316
- if (liveBufferRef.current.length >= MAX_TAIL_BUFFER_EVENTS) {
317
- // Bound the tail-load window: drop the oldest buffered event and
318
- // remember that a follow-up history re-pull is needed. Warn once
319
- // per load, not for every subsequent overflow.
320
- liveBufferRef.current.shift()
321
- if (!liveBufferOverflowRef.current) {
322
- console.warn(
323
- `history tail is slow: live buffer reached ${MAX_TAIL_BUFFER_EVENTS} events; oldest buffered events will be re-fetched`,
324
- )
325
- liveBufferOverflowRef.current = true
326
- }
327
- }
328
- liveBufferRef.current.push(event)
329
- return
330
- }
331
- foldBufferRef.current.push(event)
332
- // Only chunk streams batch on the flush cadence; every other event
333
- // (final message, turn close, user echo, update/delete) flushes the
334
- // buffer synchronously so settled UI states never wait for a timer.
335
- const isChunk = event.type === 'assistant/chunk' || event.type === 'message/chunk'
336
- if (!isChunk) {
337
- flushFoldBuffer()
338
- } else if (foldFlushTimerRef.current === undefined) {
339
- foldFlushTimerRef.current = setTimeout(flushFoldBuffer, FOLD_FLUSH_MS)
340
- }
341
- return
342
- }
343
- // Live projection pushes keep the permission picker current.
344
- if (frame.type === 'session/projection'
345
- && frame.sessionId === session.sessionId
346
- && frame.key === 'permissions') {
347
- setPermissions(parsePermissionSelect(frame.value))
348
- return
349
- }
350
- // Approval/question frames for this session (#1025).
351
- if (!('sessionId' in frame) || frame.sessionId !== session.sessionId) return
352
- if (frame.type === 'approval/requested') {
353
- setPendingApprovals(previous => {
354
- if (previous.some(a => a.approvalId === frame.approvalId)) return previous
355
- return [...previous, {
356
- approvalId: frame.approvalId as string,
357
- toolName: frame.toolName,
358
- callId: frame.callId as string | undefined,
359
- reason: frame.reason,
360
- }]
361
- })
362
- return
363
- }
364
- if (frame.type === 'approval/resolved') {
365
- setPendingApprovals(previous => previous.filter(a => a.approvalId !== frame.approvalId))
366
- return
367
- }
368
- if (frame.type === 'question/requested') {
369
- const items = (frame.questions as Array<{
370
- id: string; question: string; detail?: string; header?: string
371
- options?: Array<{ label: string; description?: string }>; multiSelect?: boolean
372
- }>)
373
- setPendingQuestions(items)
374
- return
375
- }
376
- if (frame.type === 'question/resolved') {
377
- setPendingQuestions([])
378
- return
379
- }
380
- })
381
- return () => {
382
- unsubscribe()
383
- // Drop the pending flush: its events belong to the session (or
384
- // component instance) being torn down, and the next session reseeds
385
- // its folder from a fresh history tail.
386
- if (foldFlushTimerRef.current !== undefined) {
387
- clearTimeout(foldFlushTimerRef.current)
388
- foldFlushTimerRef.current = undefined
389
- }
390
- foldBufferRef.current = []
391
- }
392
- }, [mux, session.sessionId])
393
-
394
- // Weak-network polling fallback: when the assistant is running, poll for
395
- // pending approvals/questions every 1.5 s so the phone can act even if the
396
- // SSE channel drops frames (#1025).
397
- useEffect(() => {
398
- if (!running) return
399
- let cancelled = false
400
- const tick = (): void => {
401
- void fetchPending(session.sessionId).then(
402
- (state) => {
403
- if (cancelled) return
404
- setPendingApprovals(state.approvals)
405
- setPendingQuestions(state.questions)
406
- },
407
- () => { /* transient; next tick retries */ },
408
- )
409
- }
410
- tick()
411
- const timer = setInterval(tick, 1500)
412
- return () => { cancelled = true; clearInterval(timer) }
413
- }, [running, session.sessionId])
414
-
415
- const scrollToBottom = useCallback(() => {
416
- const el = scrollRef.current
417
- if (el === undefined) return
418
- el.scrollTop = el.scrollHeight
419
- }, [])
420
-
421
- // Track the last message's fold key so scrolling only fires when the
422
- // newest message actually changes (seq bump and/or pending flip). Runs
423
- // after React has committed the render, so scrollHeight reflects the
424
- // freshly appended content.
425
- const lastMessageKeyRef = useRef<string | undefined>(undefined)
426
-
427
- // Keep the newest content visible. This covers the initial tail page (the
428
- // effect runs after commit, fixing the stale scrollHeight from the old
429
- // open-time scrollToBottom), live streaming chunks on the pending message,
430
- // and finalized/appended messages. Prepending older pages via loadOlder
431
- // leaves the last message untouched, so it never disturbs the scroll position.
432
- useEffect(() => {
433
- const last = messages[messages.length - 1]
434
- if (last === undefined) return
435
- const key = last.seq + ':' + (last.pending === true ? 'p' : 'f')
436
- if (key === lastMessageKeyRef.current) return
437
- lastMessageKeyRef.current = key
438
- scrollToBottom()
439
- }, [messages, scrollToBottom])
440
-
441
- /** Load one older page and prepend it. The fold is directional (incremental
442
- * tails only), so the older page folds standalone and concatenates ahead —
443
- * host page boundaries never cut a message, so the seam is exact. */
444
- const loadOlder = useCallback(() => {
445
- if (pendingRef.current) return
446
- pendingRef.current = true
447
- setLoading(true)
448
- const first = messages[0]
449
- if (first === undefined) {
450
- pendingRef.current = false
451
- setLoading(false)
452
- return
453
- }
454
- void loadHistory(session.sessionId, first.seq).then(
455
- (page) => {
456
- pendingRef.current = false
457
- setLoading(false)
458
- const older = foldEvents(page.events.map(eventOf))
459
- const folder = folderRef.current
460
- if (folder === undefined) {
461
- setMessages(previous => [...older, ...previous])
462
- } else {
463
- folder.prepend(older)
464
- setMessages(folder.snapshot())
465
- }
466
- setHasOlder(page.hasMore)
467
- },
468
- (reason: unknown) => {
469
- pendingRef.current = false
470
- setLoading(false)
471
- setError(errorText(reason))
472
- },
473
- )
474
- }, [session.sessionId, messages])
475
-
476
- /** Send the drafted prompt (the echoed user/message arrives over mux). */
477
- const send = useCallback(() => {
478
- const text = input.trim()
479
- if (text === '' || sending) return
480
- setSending(true)
481
- void prompt(session.sessionId, text).then(
482
- () => {
483
- setSending(false)
484
- setInput('')
485
- },
486
- (reason: unknown) => {
487
- setSending(false)
488
- setError(errorText(reason))
489
- },
490
- )
491
- }, [input, sending, session.sessionId])
492
-
493
- /**
494
- * Stop the active turn (desktop parity: the composer's primary button
495
- * becomes a stop button while running). The turn/end frame arriving over
496
- * mux flips the button back; a failed request surfaces through the chat
497
- * error line.
498
- */
499
- const stopTurn = useCallback(() => {
500
- if (stopping) return
501
- setStopping(true)
502
- void cancelSession(session.sessionId).then(
503
- () => { setStopping(false) },
504
- (reason: unknown) => {
505
- setStopping(false)
506
- setError(errorText(reason))
507
- },
508
- )
509
- }, [stopping, session.sessionId])
510
-
511
- const modelLabel = currentModel?.model ?? '模型'
512
- const permissionLabel = permissions === undefined
513
- ? undefined
514
- : permissions.options.find(option => option.value === permissions.currentValue)?.name
515
- ?? displayName(permissions.currentValue)
516
-
517
- // Context usage chip: the most recent assistant message carrying both usage and
518
- // a positive context window drives the percentage. Scanned from the end so a
519
- // newer answer (whose usage may be the last one reported) takes precedence.
520
- const contextUsage = useMemo(() => {
521
- for (let index = messages.length - 1; index >= 0; index--) {
522
- const message = messages[index]
523
- const usage = message.usage
524
- if (message.kind !== 'assistant' || usage === undefined) continue
525
- const window = message.contextWindow
526
- if (window === undefined || window <= 0) continue
527
- const tokens = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
528
- const pct = Math.round(tokens / window * 100)
529
- return { pct }
530
- }
531
- return undefined
532
- }, [messages])
533
-
534
- return (
535
- <div className="chat">
536
- <header className="mobile-header">
537
- <button type="button" className="mobile-back" aria-label="返回" onClick={onBack}>‹</button>
538
- <h1 className="mobile-title mobile-titleInline">{session.title}</h1>
539
- <ThemeToggle />
540
- </header>
541
- {error !== undefined && <p className="mobile-error mobile-pad">{error}</p>}
542
- <div className="chat-scroll" ref={ref => { scrollRef.current = ref ?? undefined }}>
543
- {hasOlder && (
544
- <button type="button" className="chat-load-older" disabled={loading} onClick={() => { void loadOlder() }}>
545
- {loading ? '加载中…' : '加载更早的消息'}
546
- </button>
547
- )}
548
- {messages.map(message => (
549
- <MessageRow
550
- key={message.id}
551
- message={message}
552
- showToolCalls={showToolCalls}
553
- showSystemMessages={showSystemMessages}
554
- />
555
- ))}
556
- {loading && messages.length === 0 && <p className="chat-typing">加载中…</p>}
557
- {!loading && messages.length === 0 && <p className="chat-typing">还没有消息,发一句话开始吧</p>}
558
- {running && (
559
- <div className="chat-turn-status" role="status" aria-label="输出中">
560
- 输出中<span className="chat-turn-dots" aria-hidden><span /><span /><span /></span>
561
- </div>
562
- )}
563
- {pendingApprovals.map(approval => (
564
- <ApprovalPanel
565
- key={approval.approvalId}
566
- approval={approval}
567
- sessionId={session.sessionId}
568
- onResolved={(id) => { setPendingApprovals(prev => prev.filter(a => a.approvalId !== id)) }}
569
- />
570
- ))}
571
- {pendingQuestions.length > 0 && (
572
- <QuestionPanel
573
- questions={pendingQuestions}
574
- sessionId={session.sessionId}
575
- onResolved={() => { setPendingQuestions([]) }}
576
- />
577
- )}
578
- </div>
579
- <div className="chat-tools">
580
- <button type="button" className="chat-chip" onClick={() => { setSheet('model') }} aria-haspopup="dialog">
581
- <span className="chat-chip-label">模型</span>
582
- <span className="chat-chip-value">{modelLabel}</span>
583
- <span className="chat-chip-chevron" aria-hidden>›</span>
584
- </button>
585
- <button type="button" className="chat-chip" onClick={() => { setSheet('display') }} aria-haspopup="dialog">
586
- <span className="chat-chip-label">显示</span>
587
- <span className="chat-chip-chevron" aria-hidden>›</span>
588
- </button>
589
- {permissionLabel !== undefined && (
590
- <button type="button" className="chat-chip" onClick={() => { setSheet('permission') }} aria-haspopup="dialog">
591
- <span className="chat-chip-label">权限</span>
592
- <span className="chat-chip-value">{permissionLabel}</span>
593
- <span className="chat-chip-chevron" aria-hidden>›</span>
594
- </button>
595
- )}
596
- {contextUsage !== undefined && (
597
- <div className={"chat-context" + (contextUsage.pct >= 80 ? " chat-context-warn" : "")}>
598
- 上下文 {contextUsage.pct}%
599
- </div>
600
- )}
601
- </div>
602
- <div className="chat-inputbar">
603
- <textarea
604
- className="chat-input"
605
- rows={1}
606
- value={input}
607
- placeholder={mobileEnterToSend ? '输入消息,Enter 发送…' : '输入消息,Enter 换行,点按钮发送…'}
608
- enterKeyHint={mobileEnterToSend ? 'send' : 'enter'}
609
- onChange={(event) => { setInput(event.target.value) }}
610
- onKeyDown={(event) => {
611
- if (mobileEnterToSend && event.key === 'Enter' && !event.shiftKey) {
612
- event.preventDefault()
613
- void send()
614
- }
615
- }}
616
- />
617
- <button
618
- type="button"
619
- className={running ? 'chat-send chat-send-stop' : 'chat-send'}
620
- {...(running ? { 'aria-label': stopping ? '停止中' : '停止' } : {})}
621
- disabled={running ? stopping : sending || input.trim() === ''}
622
- onClick={() => { if (running) void stopTurn(); else void send() }}
623
- >
624
- {running ? (
625
- <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
626
- <rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
627
- </svg>
628
- ) : sending ? '发送中…' : '发送'}
629
- </button>
630
- </div>
631
- {sheet === 'model' && (
632
- <ModelSheet
633
- sessionId={session.sessionId}
634
- current={currentModel}
635
- onCurrent={(selection) => { setCurrentModel(selection) }}
636
- onClose={() => { setSheet(null) }}
637
- />
638
- )}
639
- {sheet === 'permission' && permissions !== undefined && (
640
- <PermissionSheet
641
- sessionId={session.sessionId}
642
- value={permissions}
643
- onChanged={(value) => {
644
- setPermissions(previous => previous === undefined ? previous : { ...previous, currentValue: value })
645
- }}
646
- onClose={() => { setSheet(null) }}
647
- />
648
- )}
649
- {sheet === 'display' && (
650
- <DisplaySheet
651
- showToolCalls={showToolCalls}
652
- showSystemMessages={showSystemMessages}
653
- onToolCalls={(value) => { setShowToolCalls(value); writeStoredBoolean(SHOW_TOOL_CALLS_KEY, value) }}
654
- onSystemMessages={(value) => { setShowSystemMessages(value); writeStoredBoolean(SHOW_SYSTEM_MESSAGES_KEY, value) }}
655
- onClose={() => { setSheet(null) }}
656
- />
657
- )}
658
- </div>
659
- )
660
- }
661
-
662
- /* ── message rows ─────────────────────────────────────────────────────── */
663
-
664
- /**
665
- * One rendered message row (user bubble or assistant bubble with folds).
666
- * Memoized: live streaming updates exactly one message object per frame, so
667
- * unchanged rows skip re-rendering their markdown/sub-components.
668
- */
669
- const MessageRow = memo(function MessageRow({ message, showToolCalls, showSystemMessages }: {
670
- message: RenderMessage
671
- showToolCalls: boolean
672
- showSystemMessages: boolean
673
- }) {
674
- // Injected user messages (sourceKind defined and not 'user') hide behind
675
- // the system-message toggle.
676
- if (message.kind === 'user'
677
- && message.sourceKind !== undefined
678
- && message.sourceKind !== 'user'
679
- && !showSystemMessages) {
680
- return null
681
- }
682
- const hasReasoning = message.kind === 'assistant' && message.reasoning !== undefined && message.reasoning !== ''
683
- const hasTools = showToolCalls && message.kind === 'assistant' && message.tools !== undefined && message.tools.length > 0
684
- const hasText = message.text !== ''
685
- const hasFailTag = message.failed === true
686
-
687
- if (!hasReasoning && !hasTools && !hasText && !hasFailTag) {
688
- return null
689
- }
690
- return (
691
- <div className={`chat-msg chat-msg-${message.kind}${message.pending === true ? ' chat-msg-pending' : ''}${message.failed === true ? ' chat-msg-failed' : ''}`}>
692
- {message.kind === 'assistant' && message.reasoning !== undefined && message.reasoning !== '' && (
693
- <ReasoningDisclosure text={message.reasoning} pending={message.pending === true} />
694
- )}
695
- {showToolCalls && message.kind === 'assistant' && message.tools !== undefined && message.tools.length > 0 && (
696
- <ToolDisclosure tools={message.tools} />
697
- )}
698
- {message.kind === 'assistant'
699
- ? <MarkdownText text={message.text} pending={message.pending === true} />
700
- : <CollapsibleText text={message.text} />}
701
- {message.failed === true && <span className="chat-msg-failtag">本次回复失败</span>}
702
- <span className="chat-msg-time">{formatTime(message.time)}</span>
703
- </div>
704
- )
705
- })
706
-
707
- /** Collapsed-by-default reasoning disclosure (web-UI Think-row parity). */
708
- function ReasoningDisclosure({ text, pending }: { text: string; pending: boolean }) {
709
- const [open, setOpen] = useState(false)
710
- const summary = pending ? lastLine(text) : firstMeaningfulLine(text)
711
- return (
712
- <div className={`chat-disclosure chat-reasoning${open ? ' chat-disclosure-open' : ''}`} data-pending={pending || undefined}>
713
- <button
714
- type="button"
715
- className="chat-disclosure-head"
716
- aria-expanded={open}
717
- onClick={() => { setOpen(value => !value) }}
718
- >
719
- <span className="chat-disclosure-caret" aria-hidden>›</span>
720
- <span className="chat-disclosure-label">{pending ? '思考中…' : '深度思考'}</span>
721
- {!open && <span className="chat-disclosure-summary">{summary}</span>}
722
- </button>
723
- {open && <div className="chat-disclosure-body">{text}</div>}
724
- </div>
725
- )
726
- }
727
-
728
- /** Collapsed-by-default tool-call disclosure: pill tag summary + card details (#529). */
729
- function ToolDisclosure({ tools }: { tools: ToolCallInfo[] }) {
730
- const [open, setOpen] = useState(false)
731
- const uniqueNames = [...new Set(tools.map(tool => tool.name))]
732
- return (
733
- <div className={`chat-disclosure chat-tools${open ? ' chat-disclosure-open' : ''}`}>
734
- <button
735
- type="button"
736
- className="chat-disclosure-head"
737
- aria-expanded={open}
738
- onClick={() => { setOpen(value => !value) }}
739
- >
740
- <span className="chat-disclosure-caret" aria-hidden>›</span>
741
- <span className="chat-disclosure-label">工具</span>
742
- {!open && (
743
- <span className="chat-disclosure-summary chat-tool-pills">
744
- {uniqueNames.map(name => (
745
- <span key={name} className="chat-tool-pill">{name}</span>
746
- ))}
747
- </span>
748
- )}
749
- <span className="chat-disclosure-count">{tools.length} 次</span>
750
- </button>
751
- {open && (
752
- <div className="chat-disclosure-body chat-tools-body">
753
- {tools.map((tool, index) => (
754
- <div className="chat-tool-card" key={`${tool.callId}-${index}`}>
755
- <span className="chat-tool-pill">{tool.name}</span>
756
- {tool.arguments !== undefined && (
757
- <pre className="chat-tool-args">{tool.arguments}</pre>
758
- )}
759
- </div>
760
- ))}
761
- </div>
762
- )}
763
- </div>
764
- )
765
- }
766
-
767
- /**
768
- * Minimum interval between full markdown re-parses of a live (pending)
769
- * assistant message. Every streamed chunk replaces the message object, and
770
- * re-parsing the whole accumulated text per chunk turns a long reply into
771
- * O(n^2) work on mobile. Pending text keeps the last parsed result visible
772
- * and re-parses at most once per interval; the moment the turn closes the
773
- * final text parses immediately, so terminal messages render exactly as
774
- * before.
775
- */
776
- export const STREAM_RENDER_INTERVAL_MS = 120
777
-
778
- /**
779
- * Assistant text rendered as GFM markdown (escape-first, protocol
780
- * allow-list — see markdown.ts). Long replies collapse by clamping the
781
- * rendered block height instead of slicing the source, so half-cut code
782
- * fences or tables never leak malformed markup into the DOM. User
783
- * messages stay plain text (CollapsibleText).
784
- */
785
- function MarkdownText({ text, pending }: { text: string; pending: boolean }) {
786
- const [open, setOpen] = useState(false)
787
- const [html, setHtml] = useState<string>(() => renderMarkdown(text))
788
- /** Text of the last render actually applied to `html`. */
789
- const renderedTextRef = useRef(text)
790
- /** Newest streamed text, read by the trailing render at fire time. */
791
- const latestTextRef = useRef(text)
792
- /** Timestamp of the last applied parse (throttle window start). */
793
- const lastRenderAtRef = useRef(performance.now())
794
- const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
795
-
796
- // Throttled parse for a live stream: skip parses while the newest text is
797
- // already rendered, parse immediately once the throttle window elapsed,
798
- // otherwise schedule one trailing render that picks up the newest text.
799
- useEffect(() => {
800
- latestTextRef.current = text
801
- if (!pending) {
802
- // Turn closed: terminal messages are never throttled, so cancel any
803
- // scheduled stream render and parse the final text immediately.
804
- if (timerRef.current !== undefined) {
805
- clearTimeout(timerRef.current)
806
- timerRef.current = undefined
807
- }
808
- if (text === renderedTextRef.current) return
809
- lastRenderAtRef.current = performance.now()
810
- renderedTextRef.current = text
811
- setHtml(renderMarkdown(text))
812
- return
813
- }
814
- if (text === renderedTextRef.current) return
815
- const elapsed = performance.now() - lastRenderAtRef.current
816
- if (elapsed >= STREAM_RENDER_INTERVAL_MS) {
817
- lastRenderAtRef.current = performance.now()
818
- renderedTextRef.current = text
819
- setHtml(renderMarkdown(text))
820
- return
821
- }
822
- if (timerRef.current === undefined) {
823
- timerRef.current = setTimeout(() => {
824
- timerRef.current = undefined
825
- lastRenderAtRef.current = performance.now()
826
- renderedTextRef.current = latestTextRef.current
827
- setHtml(renderMarkdown(latestTextRef.current))
828
- }, STREAM_RENDER_INTERVAL_MS - elapsed)
829
- }
830
- }, [text, pending])
831
-
832
- // Cancel the trailing stream render if the row unmounts mid-stream.
833
- useEffect(() => {
834
- return () => {
835
- if (timerRef.current !== undefined) clearTimeout(timerRef.current)
836
- }
837
- }, [])
838
- const long = !pending && text.length > LONG_TEXT_LIMIT
839
- const collapsed = long && !open
840
- return (
841
- <div className={'chat-msg-text chat-md' + (collapsed ? ' chat-md-collapsed' : '')}>
842
- <div className="chat-md-body" dangerouslySetInnerHTML={{ __html: html }} />
843
- {long && (
844
- <button type="button" className="chat-msg-toggle" onClick={() => { setOpen(value => !value) }}>
845
- {open ? '收起' : '展开全文(' + text.length + ' 字)'}
846
- </button>
847
- )}
848
- </div>
849
- )
850
- }
851
-
852
- /** Long assistant text collapses behind an explicit expand toggle. */
853
- function CollapsibleText({ text }: { text: string }) {
854
- const [open, setOpen] = useState(false)
855
- if (text.length <= LONG_TEXT_LIMIT) {
856
- return <span className="chat-msg-text">{text}</span>
857
- }
858
- const shown = open ? text : text.slice(0, LONG_TEXT_PREVIEW)
859
- return (
860
- <span className="chat-msg-text">
861
- {shown}{!open ? '…' : ''}
862
- <button type="button" className="chat-msg-toggle" onClick={() => { setOpen(value => !value) }}>
863
- {open ? '收起' : `展开全文(${text.length} 字)`}
864
- </button>
865
- </span>
866
- )
867
- }
868
-
869
- export const LONG_TEXT_LIMIT = 6000
870
- const LONG_TEXT_PREVIEW = 800
871
-
872
- /** Latest non-empty line of a streaming reasoning buffer. */
873
- function lastLine(text: string): string {
874
- const trimmed = text.trimEnd()
875
- if (trimmed === '') return ''
876
- const newline = trimmed.lastIndexOf('\n')
877
- const line = newline === -1 ? trimmed : trimmed.slice(newline + 1)
878
- return line.trim() === '' ? '' : line
879
- }
880
-
881
- /* ── bottom sheets ───────────────────────────────────────────────────── */
882
-
883
- /** Shared bottom-sheet chrome (backdrop + slide-up panel). */
884
- function Sheet({ title, onClose, children }: { title: string; onClose(): void; children: ReactNode }) {
885
- return (
886
- <div className="sheet-backdrop" onClick={onClose}>
887
- <div
888
- className="sheet"
889
- role="dialog"
890
- aria-modal="true"
891
- aria-label={title}
892
- onClick={(event) => { event.stopPropagation() }}
893
- >
894
- <div className="sheet-handle" aria-hidden />
895
- <div className="sheet-title">{title}</div>
896
- <div className="sheet-body">{children}</div>
897
- </div>
898
- </div>
899
- )
900
- }
901
-
902
- /** The model + thinking-effort picker (fresh advisory directory per open). */
903
- function ModelSheet({ sessionId, current, onCurrent, onClose }: {
904
- sessionId: string
905
- current: { provider: string; model: string; reasoningEffort?: string } | undefined
906
- onCurrent(selection: { provider: string; model: string; reasoningEffort?: string }): void
907
- onClose(): void
908
- }) {
909
- const [state, setState] = useState<{ status: 'loading' } | { status: 'error'; message: string } | { status: 'ready'; data: SessionModels }>({ status: 'loading' })
910
- const [busy, setBusy] = useState(false)
911
- const [error, setError] = useState<string | undefined>(undefined)
912
-
913
- const load = useCallback(() => {
914
- setState({ status: 'loading' })
915
- void models(sessionId).then(
916
- data => { setState({ status: 'ready', data }) },
917
- (reason: unknown) => { setState({ status: 'error', message: errorText(reason) }) },
918
- )
919
- }, [sessionId])
920
-
921
- useEffect(() => { load() }, [load])
922
-
923
- /** Select model/effort and close on success (one-shot action per sheet). */
924
- const apply = useCallback((selection: { provider: string; model: string; reasoningEffort?: string }) => {
925
- if (busy) return
926
- setBusy(true)
927
- setError(undefined)
928
- void selectModel(sessionId, selection).then(
929
- (result) => {
930
- setBusy(false)
931
- onCurrent(result.selected)
932
- onClose()
933
- },
934
- (reason: unknown) => {
935
- setBusy(false)
936
- setError(errorText(reason))
937
- },
938
- )
939
- }, [busy, sessionId, onCurrent, onClose])
940
-
941
- if (state.status === 'loading') {
942
- return (
943
- <Sheet title="模型与思考强度" onClose={onClose}>
944
- <div className="sheet-status">正在加载模型目录…</div>
945
- </Sheet>
946
- )
947
- }
948
- if (state.status === 'error') {
949
- return (
950
- <Sheet title="模型与思考强度" onClose={onClose}>
951
- <div className="sheet-status sheet-status-error">
952
- <span>{state.message}</span>
953
- {staleHostHint(state.message) !== undefined && <span className="sheet-hint">{staleHostHint(state.message)}</span>}
954
- <button type="button" className="chat-load-older" onClick={load}>重试</button>
955
- </div>
956
- </Sheet>
957
- )
958
- }
959
-
960
- const { data } = state
961
- const selected = current ?? data.current
962
- const choices = data.groups.flatMap(group => group.models.map(model => ({ group, model })))
963
- const currentChoice = choices.find(choice => choice.group.id === selected.provider && choice.model.id === selected.model)
964
- const reasoning = currentChoice?.model.reasoning
965
- const effectiveEffort = selected.reasoningEffort ?? reasoning?.defaultEffort
966
- const effortChoices = reasoning === undefined
967
- ? []
968
- : [
969
- ...(reasoning.defaultEffort === undefined
970
- ? [{ key: 'provider-default', effort: undefined as string | undefined, label: '跟随模型默认' }]
971
- : []),
972
- ...reasoning.efforts.map(effort => ({
973
- key: `effort:${effort.id}`,
974
- effort: effort.id as string | undefined,
975
- label: effort.name,
976
- description: effort.description,
977
- })),
978
- ]
979
-
980
- return (
981
- <Sheet title="模型与思考强度" onClose={onClose}>
982
- {error !== undefined && <p className="sheet-error">{error}</p>}
983
- {error !== undefined && staleHostHint(error) !== undefined && <p className="sheet-hint">{staleHostHint(error)}</p>}
984
- {data.failures.map(failure => (
985
- <p className="sheet-error" key={failure.id}>{failure.name}: {failure.message}</p>
986
- ))}
987
- {data.groups.length === 0 && choices.length === 0 && (
988
- <div className="sheet-status">没有可用的模型</div>
989
- )}
990
- {data.groups.map(group => (
991
- <div className="sheet-section" key={group.id}>
992
- <div className="sheet-section-title">{group.name}</div>
993
- {group.models.map(model => {
994
- const isSelected = selected.provider === group.id && selected.model === model.id
995
- return (
996
- <button
997
- type="button"
998
- key={model.id}
999
- className={`sheet-option${isSelected ? ' sheet-option-selected' : ''}`}
1000
- disabled={busy}
1001
- onClick={() => {
1002
- apply({
1003
- provider: group.id,
1004
- model: model.id,
1005
- ...(model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: model.reasoning.defaultEffort }),
1006
- })
1007
- }}
1008
- >
1009
- <span className="sheet-option-copy">
1010
- <span className="sheet-option-title">{model.name}</span>
1011
- {model.description !== undefined && <span className="sheet-option-desc">{model.description}</span>}
1012
- </span>
1013
- {isSelected && <span className="sheet-option-check" aria-hidden>√</span>}
1014
- </button>
1015
- )
1016
- })}
1017
- </div>
1018
- ))}
1019
- {effortChoices.length > 0 && (
1020
- <div className="sheet-section">
1021
- <div className="sheet-section-title">思考强度</div>
1022
- {effortChoices.map(choice => {
1023
- const isSelected = effectiveEffort === choice.effort
1024
- return (
1025
- <button
1026
- type="button"
1027
- key={choice.key}
1028
- className={`sheet-option${isSelected ? ' sheet-option-selected' : ''}`}
1029
- disabled={busy}
1030
- onClick={() => { apply({ provider: selected.provider, model: selected.model, ...(choice.effort !== undefined ? { reasoningEffort: choice.effort } : {}) }) }}
1031
- >
1032
- <span className="sheet-option-copy">
1033
- <span className="sheet-option-title">{choice.label}</span>
1034
- </span>
1035
- {isSelected && <span className="sheet-option-check" aria-hidden>√</span>}
1036
- </button>
1037
- )
1038
- })}
1039
- </div>
1040
- )}
1041
- </Sheet>
1042
- )
1043
- }
1044
-
1045
- /** The permission-preset picker; full access needs an explicit confirm. */
1046
- function PermissionSheet({ sessionId, value, onChanged, onClose }: {
1047
- sessionId: string
1048
- value: PermissionSelectValue
1049
- onChanged(currentValue: string): void
1050
- onClose(): void
1051
- }) {
1052
- const [confirming, setConfirming] = useState<string | null>(null)
1053
- const [busy, setBusy] = useState(false)
1054
- const [error, setError] = useState<string | undefined>(undefined)
1055
-
1056
- /** Submit `/permission <value>` as a slash command (mode-agnostic). */
1057
- const submit = useCallback((next: string) => {
1058
- if (busy) return
1059
- setBusy(true)
1060
- setError(undefined)
1061
- void sendCommand(sessionId, `/permission ${next}`).then(
1062
- () => {
1063
- setBusy(false)
1064
- setConfirming(null)
1065
- onChanged(next)
1066
- onClose()
1067
- },
1068
- (reason: unknown) => {
1069
- setBusy(false)
1070
- setConfirming(null)
1071
- setError(errorText(reason))
1072
- },
1073
- )
1074
- }, [busy, sessionId, onChanged, onClose])
1075
-
1076
- const choose = (next: string): void => {
1077
- if (next === value.currentValue) {
1078
- onClose()
1079
- return
1080
- }
1081
- if (next === 'danger-full-access') {
1082
- setConfirming(next)
1083
- return
1084
- }
1085
- submit(next)
1086
- }
1087
-
1088
- if (confirming !== null) {
1089
- return (
1090
- <Sheet title="确认完全权限" onClose={() => { setConfirming(null) }}>
1091
- <p className="sheet-confirm-desc">
1092
- 开启完全权限后,远程会话可以在工作区内执行任意操作(包括运行命令、修改所有文件与访问凭证)。
1093
- 仅在您信任当前设备和网络时开启。
1094
- </p>
1095
- {error !== undefined && <p className="sheet-error">{error}</p>}
1096
- <div className="sheet-confirm-actions">
1097
- <button type="button" className="mobile-button" disabled={busy} onClick={() => { setConfirming(null) }}>取消</button>
1098
- <button type="button" className="sheet-confirm-danger" disabled={busy} onClick={() => { submit(confirming) }}>
1099
- {busy ? '提交中…' : '确认开启'}
1100
- </button>
1101
- </div>
1102
- </Sheet>
1103
- )
1104
- }
1105
-
1106
- return (
1107
- <Sheet title="权限" onClose={onClose}>
1108
- {error !== undefined && <p className="sheet-error">{error}</p>}
1109
- {value.options.map(option => {
1110
- const isSelected = option.value === value.currentValue
1111
- return (
1112
- <button
1113
- type="button"
1114
- key={option.value}
1115
- className={`sheet-option${isSelected ? ' sheet-option-selected' : ''}`}
1116
- disabled={busy}
1117
- onClick={() => { choose(option.value) }}
1118
- >
1119
- <span className="sheet-option-copy">
1120
- <span className="sheet-option-title">{option.name}</span>
1121
- {option.description !== undefined && <span className="sheet-option-desc">{option.description}</span>}
1122
- </span>
1123
- {isSelected && <span className="sheet-option-check" aria-hidden>√</span>}
1124
- </button>
1125
- )
1126
- })}
1127
- </Sheet>
1128
- )
1129
- }
1130
-
1131
- /** The display-options sheet: tool calls and injected system messages toggles. */
1132
- function DisplaySheet({ showToolCalls, showSystemMessages, onToolCalls, onSystemMessages, onClose }: {
1133
- showToolCalls: boolean
1134
- showSystemMessages: boolean
1135
- onToolCalls(value: boolean): void
1136
- onSystemMessages(value: boolean): void
1137
- onClose(): void
1138
- }) {
1139
- return (
1140
- <Sheet title="显示" onClose={onClose}>
1141
- <div role="group" aria-label="显示选项">
1142
- <div className="sheet-toggle-row">
1143
- <div className="sheet-toggle-copy">
1144
- <span className="sheet-toggle-title">工具调用</span>
1145
- <span className="sheet-toggle-desc">显示助手使用的工具调用</span>
1146
- </div>
1147
- <button
1148
- type="button"
1149
- role="switch"
1150
- aria-label="工具调用"
1151
- aria-checked={showToolCalls}
1152
- className={`sheet-toggle-switch${showToolCalls ? ' sheet-toggle-switch-on' : ''}`}
1153
- onClick={() => { onToolCalls(!showToolCalls) }}
1154
- >
1155
- <span className="sheet-toggle-switch-knob" aria-hidden />
1156
- </button>
1157
- </div>
1158
- <div className="sheet-toggle-row">
1159
- <div className="sheet-toggle-copy">
1160
- <span className="sheet-toggle-title">系统提示词</span>
1161
- <span className="sheet-toggle-desc">显示注入到对话中的系统消息</span>
1162
- </div>
1163
- <button
1164
- type="button"
1165
- role="switch"
1166
- aria-label="系统提示词"
1167
- aria-checked={showSystemMessages}
1168
- className={`sheet-toggle-switch${showSystemMessages ? ' sheet-toggle-switch-on' : ''}`}
1169
- onClick={() => { onSystemMessages(!showSystemMessages) }}
1170
- >
1171
- <span className="sheet-toggle-switch-knob" aria-hidden />
1172
- </button>
1173
- </div>
1174
- </div>
1175
- </Sheet>
1176
- )
1177
- }
1178
-
1179
- /* ── approval / question panels (#1025) ──────────────────────────────── */
1180
-
1181
- /** One pending tool approval card with allow/reject actions. */
1182
- function ApprovalPanel({ approval, sessionId, onResolved }: {
1183
- approval: PendingApproval
1184
- sessionId: string
1185
- onResolved(approvalId: string): void
1186
- }) {
1187
- const [busy, setBusy] = useState(false)
1188
- const [panelError, setPanelError] = useState<string | undefined>(undefined)
1189
-
1190
- const act = (outcome: 'allowed-once' | 'rejected'): void => {
1191
- if (busy) return
1192
- setBusy(true)
1193
- setPanelError(undefined)
1194
- void respondApproval(sessionId, approval.approvalId, outcome).then(
1195
- () => { onResolved(approval.approvalId) },
1196
- (reason: unknown) => {
1197
- setBusy(false)
1198
- setPanelError(reason instanceof Error ? reason.message : String(reason))
1199
- },
1200
- )
1201
- }
1202
-
1203
- return (
1204
- <div className="chat-approval-panel" role="alert">
1205
- <div className="chat-approval-header">
1206
- <span className="chat-tool-pill">{approval.toolName}</span>
1207
- {approval.reason !== undefined && (
1208
- <span className="chat-approval-reason">{approval.reason}</span>
1209
- )}
1210
- </div>
1211
- {panelError !== undefined && <p className="chat-approval-error">{panelError}</p>}
1212
- <div className="chat-approval-actions">
1213
- <button
1214
- type="button"
1215
- className="chat-approval-allow"
1216
- disabled={busy}
1217
- onClick={() => { act('allowed-once') }}
1218
- >
1219
- {busy ? '提交中…' : '允许一次'}
1220
- </button>
1221
- <button
1222
- type="button"
1223
- className="chat-approval-reject"
1224
- disabled={busy}
1225
- onClick={() => { act('rejected') }}
1226
- >
1227
- 拒绝
1228
- </button>
1229
- </div>
1230
- </div>
1231
- )
1232
- }
1233
-
1234
- /** Question panel: renders one or more questions with option pickers and a submit button. */
1235
- function QuestionPanel({ questions, sessionId, onResolved }: {
1236
- questions: PendingQuestionItem[]
1237
- sessionId: string
1238
- onResolved(): void
1239
- }) {
1240
- const [selections, setSelections] = useState<Map<string, { selected: string[]; custom: string }>>(
1241
- () => new Map(questions.map(q => [q.id, { selected: [], custom: '' }])),
1242
- )
1243
- const [busy, setBusy] = useState(false)
1244
- const [panelError, setPanelError] = useState<string | undefined>(undefined)
1245
-
1246
- const toggle = (questionId: string, label: string, multi: boolean): void => {
1247
- setSelections(previous => {
1248
- const next = new Map(previous)
1249
- const entry = next.get(questionId) ?? { selected: [], custom: '' }
1250
- if (multi) {
1251
- const set = new Set(entry.selected)
1252
- if (set.has(label)) set.delete(label); else set.add(label)
1253
- next.set(questionId, { ...entry, selected: [...set] })
1254
- } else {
1255
- next.set(questionId, { ...entry, selected: [label] })
1256
- }
1257
- return next
1258
- })
1259
- }
1260
-
1261
- const setCustom = (questionId: string, value: string): void => {
1262
- setSelections(previous => {
1263
- const next = new Map(previous)
1264
- const entry = next.get(questionId) ?? { selected: [], custom: '' }
1265
- next.set(questionId, { ...entry, custom: value })
1266
- return next
1267
- })
1268
- }
1269
-
1270
- const submit = (): void => {
1271
- if (busy) return
1272
- setBusy(true)
1273
- setPanelError(undefined)
1274
- const answers = questions.map(q => {
1275
- const entry = selections.get(q.id) ?? { selected: [], custom: '' }
1276
- return {
1277
- id: q.id,
1278
- selected: entry.selected,
1279
- ...(entry.custom.trim() !== '' ? { custom: entry.custom.trim() } : {}),
1280
- }
1281
- })
1282
- void respondQuestion(sessionId, answers).then(
1283
- () => { onResolved() },
1284
- (reason: unknown) => {
1285
- setBusy(false)
1286
- setPanelError(reason instanceof Error ? reason.message : String(reason))
1287
- },
1288
- )
1289
- }
1290
-
1291
- return (
1292
- <div className="chat-question-panel" role="form" aria-label="问题">
1293
- {questions.map(q => {
1294
- const entry = selections.get(q.id) ?? { selected: [], custom: '' }
1295
- return (
1296
- <div className="chat-question-group" key={q.id}>
1297
- {q.header !== undefined && <div className="chat-question-header">{q.header}</div>}
1298
- <div className="chat-question-text">{q.question}</div>
1299
- {q.detail !== undefined && <div className="chat-question-detail">{q.detail}</div>}
1300
- {q.options !== undefined && q.options.length > 0 && (
1301
- <div className="chat-question-options" role="group" aria-label={q.question}>
1302
- {q.options.map(option => {
1303
- const checked = entry.selected.includes(option.label)
1304
- return (
1305
- <label key={option.label} className={`chat-question-option${checked ? ' chat-question-option-selected' : ''}`}>
1306
- <input
1307
- type={q.multiSelect ? 'checkbox' : 'radio'}
1308
- name={`q-${q.id}`}
1309
- checked={checked}
1310
- onChange={() => { toggle(q.id, option.label, q.multiSelect === true) }}
1311
- />
1312
- <span className="chat-question-option-label">{option.label}</span>
1313
- {option.description !== undefined && (
1314
- <span className="chat-question-option-desc">{option.description}</span>
1315
- )}
1316
- </label>
1317
- )
1318
- })}
1319
- </div>
1320
- )}
1321
- <textarea
1322
- className="chat-question-custom"
1323
- placeholder="自定义回答(可选)"
1324
- rows={2}
1325
- value={entry.custom}
1326
- onChange={(e) => { setCustom(q.id, e.target.value) }}
1327
- />
1328
- </div>
1329
- )
1330
- })}
1331
- {panelError !== undefined && <p className="chat-approval-error">{panelError}</p>}
1332
- <button
1333
- type="button"
1334
- className="chat-question-submit"
1335
- disabled={busy}
1336
- onClick={submit}
1337
- >
1338
- {busy ? '提交中…' : '提交回答'}
1339
- </button>
1340
- </div>
1341
- )
1342
- }