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