@jaychang1989/dsh-webchat 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +18 -0
- package/README.md +78 -0
- package/README.zh.md +83 -0
- package/cordis.patch.yml +22 -0
- package/lib/client.js +1725 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3887 -0
- package/lib/types/client/api.d.ts +74 -0
- package/lib/types/client/controller.d.ts +19 -0
- package/lib/types/client/index.d.ts +30 -0
- package/lib/types/client/locales.d.ts +169 -0
- package/lib/types/client/mount.d.ts +23 -0
- package/lib/types/client/panel/Markdown.d.ts +15 -0
- package/lib/types/client/panel/WebChatPanel.d.ts +28 -0
- package/lib/types/client/sidebar-entry-core.d.ts +60 -0
- package/lib/types/client/sidebar-entry.d.ts +22 -0
- package/lib/types/engine/engine.d.ts +247 -0
- package/lib/types/engine/html-md.d.ts +23 -0
- package/lib/types/index.d.ts +63 -0
- package/lib/types/protocol.d.ts +107 -0
- package/lib/types/routes.d.ts +22 -0
- package/lib/types/store.d.ts +69 -0
- package/lib/types/tools.d.ts +27 -0
- package/lib/types/transfer.d.ts +138 -0
- package/package.json +96 -0
- package/src/client/api.ts +90 -0
- package/src/client/controller.ts +43 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +75 -0
- package/src/client/locales.ts +172 -0
- package/src/client/mount.tsx +125 -0
- package/src/client/panel/Markdown.tsx +251 -0
- package/src/client/panel/WebChatPanel.tsx +760 -0
- package/src/client/panel/panel.module.css +936 -0
- package/src/client/sidebar-entry-core.ts +207 -0
- package/src/client/sidebar-entry.ts +49 -0
- package/src/engine/engine.ts +1385 -0
- package/src/engine/html-md.ts +126 -0
- package/src/index.ts +212 -0
- package/src/protocol.ts +116 -0
- package/src/routes.ts +307 -0
- package/src/store.ts +217 -0
- package/src/tools.ts +257 -0
- package/src/transfer.ts +602 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web-chat panel: engine/login status, a conversation sidebar (the local
|
|
3
|
+
* transcript list + management), the chat transcript with markdown rendering,
|
|
4
|
+
* streaming updates (polled), the composer with deep-think / internet-search
|
|
5
|
+
* toggles, and the transfer-to-Harness handoff. All data flows through the
|
|
6
|
+
* /api/dsh-webchat routes; the panel is a plain React root in the center column.
|
|
7
|
+
*
|
|
8
|
+
* Layout mirrors chat.deepseek.com: a left conversation sidebar (new chat +
|
|
9
|
+
* history + per-item rename/delete + clear-all) beside the main chat column
|
|
10
|
+
* (messages → composer with 深度思考 / 智能搜索 toggles → handoff dock). The
|
|
11
|
+
* mono register stays reserved for machine things — timestamps, counts,
|
|
12
|
+
* status, and the handoff eyebrow.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
16
|
+
import type { ISessions, IWorkspaces, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
17
|
+
import type { WebChatApi } from '../api.ts'
|
|
18
|
+
import type { TransferMode, WebChatState, WebChatTranscript } from '../../protocol.ts'
|
|
19
|
+
import type { WebChatKey } from '../locales.ts'
|
|
20
|
+
import { Markdown } from './Markdown.tsx'
|
|
21
|
+
import css from './panel.module.css'
|
|
22
|
+
|
|
23
|
+
/** Interpolate {placeholder}s in a localized template. */
|
|
24
|
+
function fmt(template: string, values: Record<string, string>): string {
|
|
25
|
+
return template.replace(/\{(\w+)\}/g, (_, key: string) => values[key] ?? `{${key}}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Compact inline icons (stroke = currentColor, 16px viewBox). */
|
|
29
|
+
const PENCIL_ICON = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11.3 2.2l2.5 2.5L5.5 13H3v-2.5L11.3 2.2z"/></svg>'
|
|
30
|
+
const TRASH_ICON = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4h11M6.5 4V2.5h3V4M4 4l.5 9.5h7L12 4"/></svg>'
|
|
31
|
+
const CHECK_ICON = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 8.5L6.5 12 13 4.5"/></svg>'
|
|
32
|
+
const CROSS_ICON = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>'
|
|
33
|
+
|
|
34
|
+
export interface WebChatPanelProps {
|
|
35
|
+
api: WebChatApi
|
|
36
|
+
/** Locale accessor (the plugin registers its dictionaries on ctx.locale). */
|
|
37
|
+
tt: (key: WebChatKey) => string
|
|
38
|
+
/** Client sessions service (ctx.sessions) for opening transferred sessions. */
|
|
39
|
+
sessions: ISessions
|
|
40
|
+
/** Client workspaces service (ctx.workspaces) for the transfer target picker. */
|
|
41
|
+
workspaces: IWorkspaces
|
|
42
|
+
/** Resolve the current workspace directory (cwd for new sessions / exports). */
|
|
43
|
+
currentCwd: () => string | undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Time between /state polls while nothing is streaming. */
|
|
47
|
+
const POLL_IDLE_MS = 1_500
|
|
48
|
+
/** Faster polling while a reply is streaming. */
|
|
49
|
+
const POLL_STREAM_MS = 600
|
|
50
|
+
|
|
51
|
+
export function WebChatPanel({ api, tt, sessions, workspaces, currentCwd }: WebChatPanelProps) {
|
|
52
|
+
const [state, setState] = useState<WebChatState | null>(null)
|
|
53
|
+
const [viewChatId, setViewChatId] = useState<string | undefined>(undefined)
|
|
54
|
+
const [draft, setDraft] = useState('')
|
|
55
|
+
const [attachOpen, setAttachOpen] = useState(false)
|
|
56
|
+
const [imagePaths, setImagePaths] = useState('')
|
|
57
|
+
const [toast, setToast] = useState<{ text: string; error?: boolean } | null>(null)
|
|
58
|
+
const [transferring, setTransferring] = useState(false)
|
|
59
|
+
const [exporting, setExporting] = useState(false)
|
|
60
|
+
const [transferMode, setTransferMode] = useState<TransferMode>('distill')
|
|
61
|
+
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string | undefined>(undefined)
|
|
62
|
+
const [targetSessionId, setTargetSessionId] = useState<string | undefined>(undefined)
|
|
63
|
+
const [deepThink, setDeepThink] = useState(false)
|
|
64
|
+
const [search, setSearch] = useState(false)
|
|
65
|
+
const [renamingId, setRenamingId] = useState<string | undefined>(undefined)
|
|
66
|
+
const [renameDraft, setRenameDraft] = useState('')
|
|
67
|
+
const [deleteArmId, setDeleteArmId] = useState<string | undefined>(undefined)
|
|
68
|
+
const [clearArm, setClearArm] = useState(false)
|
|
69
|
+
const [recoverOpen, setRecoverOpen] = useState(false)
|
|
70
|
+
const [webMissing, setWebMissing] = useState<string[]>([])
|
|
71
|
+
const [recovering, setRecovering] = useState(false)
|
|
72
|
+
const listRef = useRef<HTMLDivElement | null>(null)
|
|
73
|
+
const toastTimer = useRef<number | undefined>(undefined)
|
|
74
|
+
const armTimer = useRef<number | undefined>(undefined)
|
|
75
|
+
const stateRef = useRef<WebChatState | null>(null)
|
|
76
|
+
stateRef.current = state
|
|
77
|
+
const pinnedRef = useRef(true) // user is following the latest message (not scrolled up)
|
|
78
|
+
const prevChatRef = useRef<string | undefined>(undefined)
|
|
79
|
+
|
|
80
|
+
const showToast = useCallback((text: string, error = false): void => {
|
|
81
|
+
setToast({ text, error })
|
|
82
|
+
window.clearTimeout(toastTimer.current)
|
|
83
|
+
toastTimer.current = window.setTimeout(() => setToast(null), 5_000)
|
|
84
|
+
}, [])
|
|
85
|
+
|
|
86
|
+
// Workspace list feed for the transfer-target picker (reactive snapshot).
|
|
87
|
+
const workspaceSnapshot = useSyncExternalStore(
|
|
88
|
+
useCallback((listener: () => void) => workspaces.list.subscribe(listener), [workspaces]),
|
|
89
|
+
() => workspaces.list.getSnapshot(),
|
|
90
|
+
)
|
|
91
|
+
const workspaceItems = workspaceSnapshot.items
|
|
92
|
+
const workspaceBaselinesReady = workspaceSnapshot.baselinesReady
|
|
93
|
+
|
|
94
|
+
// Sessions feed for the "continue into" picker, scoped to the active
|
|
95
|
+
// workspace: a task is continued inside the workspace the user is working in,
|
|
96
|
+
// so cross-workspace sessions are hidden.
|
|
97
|
+
const sessionSnapshot = useSyncExternalStore(
|
|
98
|
+
useCallback((listener: () => void) => sessions.list.subscribe(listener), [sessions]),
|
|
99
|
+
() => sessions.list.getSnapshot(),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
// The workspace the "continue into" list is scoped to. The transfer-target
|
|
103
|
+
// workspace selector drives it (defaults to the current session's workspace,
|
|
104
|
+
// set by the effect below); "ungrouped" leaves it undefined.
|
|
105
|
+
const selectedWorkspace = targetWorkspaceId === undefined
|
|
106
|
+
? undefined
|
|
107
|
+
: workspaceItems.find(ws => ws.workspaceId === targetWorkspaceId)
|
|
108
|
+
const groupedSessionIds = new Set(workspaceItems.flatMap(ws => ws.sessionIds))
|
|
109
|
+
const continuationTargets = sessionSnapshot.ids.flatMap(id => {
|
|
110
|
+
const session = sessionSnapshot.byId[id]
|
|
111
|
+
if (session === undefined || session.blank === true) return []
|
|
112
|
+
if (selectedWorkspace !== undefined) {
|
|
113
|
+
if (!selectedWorkspace.sessionIds.includes(id)) return []
|
|
114
|
+
} else if (groupedSessionIds.has(id)) {
|
|
115
|
+
return []
|
|
116
|
+
}
|
|
117
|
+
return [{ sessionId: session.id, title: session.displayTitle, running: session.running }]
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// Pick a default target workspace once the list is ready: prefer the current
|
|
121
|
+
// session's workspace, then the most recently active workspace, else ungrouped.
|
|
122
|
+
// Keep the transfer-target workspace defaulted to the active workspace until
|
|
123
|
+
// the user explicitly picks one; re-syncs as the current session moves.
|
|
124
|
+
const workspaceOverridden = useRef(false)
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (workspaceOverridden.current || !workspaceBaselinesReady) return
|
|
127
|
+
const currentSessionId = sessions.list.getSnapshot().current
|
|
128
|
+
const currentWorkspace = currentSessionId === undefined ? undefined : workspaceItems.find(ws => ws.sessionIds.includes(currentSessionId))
|
|
129
|
+
setTargetWorkspaceId(currentWorkspace?.workspaceId ?? workspaceSnapshot.recentWorkspaceId)
|
|
130
|
+
}, [workspaceBaselinesReady, workspaceItems, workspaceSnapshot.recentWorkspaceId, sessions])
|
|
131
|
+
|
|
132
|
+
const creatingWorkspace = useRef(false)
|
|
133
|
+
const createWorkspace = useCallback(async (): Promise<void> => {
|
|
134
|
+
if (creatingWorkspace.current) return
|
|
135
|
+
creatingWorkspace.current = true
|
|
136
|
+
try {
|
|
137
|
+
const path = await workspaces.pickDirectory()
|
|
138
|
+
if (path === null || path === '') return
|
|
139
|
+
const created = await workspaces.create({ path })
|
|
140
|
+
setTargetWorkspaceId(created.workspaceId)
|
|
141
|
+
showToast(fmt(tt('transfer.workspace.created'), { title: created.title }))
|
|
142
|
+
} catch (error) {
|
|
143
|
+
showToast(fmt(tt('transfer.workspace.createFailed'), { error: String(error) }), true)
|
|
144
|
+
} finally {
|
|
145
|
+
creatingWorkspace.current = false
|
|
146
|
+
}
|
|
147
|
+
}, [workspaces, showToast, tt])
|
|
148
|
+
|
|
149
|
+
// Poll /state.
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
let cancelled = false
|
|
152
|
+
let timer: number | undefined
|
|
153
|
+
const poll = async (): Promise<void> => {
|
|
154
|
+
if (cancelled) return
|
|
155
|
+
try {
|
|
156
|
+
const snapshot = await api.state()
|
|
157
|
+
if (cancelled) return
|
|
158
|
+
setState(snapshot)
|
|
159
|
+
setViewChatId(previous => {
|
|
160
|
+
const active = snapshot.activeChatId
|
|
161
|
+
if (previous !== undefined && snapshot.chats.some(chat => chat.id === previous)) return previous
|
|
162
|
+
return active
|
|
163
|
+
})
|
|
164
|
+
} catch {
|
|
165
|
+
// transient poll failure: keep the previous snapshot
|
|
166
|
+
}
|
|
167
|
+
const streaming = stateRef.current?.chats.some(chat => chat.streaming) ?? false
|
|
168
|
+
timer = window.setTimeout(poll, streaming ? POLL_STREAM_MS : POLL_IDLE_MS)
|
|
169
|
+
}
|
|
170
|
+
void poll()
|
|
171
|
+
return () => {
|
|
172
|
+
cancelled = true
|
|
173
|
+
window.clearTimeout(timer)
|
|
174
|
+
}
|
|
175
|
+
}, [api])
|
|
176
|
+
|
|
177
|
+
// Reconcile the web page's toggle state whenever the server reports it.
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (state === null) return
|
|
180
|
+
setDeepThink(state.deepThink)
|
|
181
|
+
setSearch(state.search)
|
|
182
|
+
}, [state?.deepThink, state?.search])
|
|
183
|
+
|
|
184
|
+
// Auto-scroll to the bottom only when the user is following the latest
|
|
185
|
+
// message (pinned) or has just switched chats — never yank the view while
|
|
186
|
+
// they are reading history.
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
const list = listRef.current
|
|
189
|
+
if (list === null) return
|
|
190
|
+
const switched = prevChatRef.current !== viewChatId
|
|
191
|
+
prevChatRef.current = viewChatId
|
|
192
|
+
if (switched || pinnedRef.current) {
|
|
193
|
+
list.scrollTop = list.scrollHeight
|
|
194
|
+
}
|
|
195
|
+
}, [state, viewChatId])
|
|
196
|
+
|
|
197
|
+
// Track whether the user has scrolled away from the bottom.
|
|
198
|
+
const handleScroll = useCallback((): void => {
|
|
199
|
+
const list = listRef.current
|
|
200
|
+
if (list === null) return
|
|
201
|
+
const nearBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 96
|
|
202
|
+
pinnedRef.current = nearBottom
|
|
203
|
+
}, [])
|
|
204
|
+
|
|
205
|
+
const chats: WebChatTranscript[] = state?.chats ?? []
|
|
206
|
+
const viewChat = chats.find(chat => chat.id === viewChatId) ?? chats[0]
|
|
207
|
+
const busy = state?.busy ?? false
|
|
208
|
+
const loggedIn = state?.loggedIn ?? null
|
|
209
|
+
const streaming = viewChat?.streaming ?? false
|
|
210
|
+
|
|
211
|
+
const modelLabel = useCallback((model: string): string => {
|
|
212
|
+
return model === 'deepseek-reasoner' ? tt('model.deepseek-reasoner') : tt('model.deepseek-chat')
|
|
213
|
+
}, [tt])
|
|
214
|
+
|
|
215
|
+
const send = useCallback(async (): Promise<void> => {
|
|
216
|
+
const text = draft.trim()
|
|
217
|
+
const images = imagePaths.split(/[\n,;]+/).map(path => path.trim()).filter(path => path !== '')
|
|
218
|
+
if ((text === '' && images.length === 0) || busy) return
|
|
219
|
+
setDraft('')
|
|
220
|
+
setImagePaths('')
|
|
221
|
+
pinnedRef.current = true // follow the reply we just sent
|
|
222
|
+
try {
|
|
223
|
+
const result = await api.send(text, images.length > 0 ? images : undefined)
|
|
224
|
+
if (result.ok !== true) showToast(result.error ?? 'send failed', true)
|
|
225
|
+
else setViewChatId(result.chatId)
|
|
226
|
+
} catch (error) {
|
|
227
|
+
showToast(String(error), true)
|
|
228
|
+
}
|
|
229
|
+
}, [draft, imagePaths, busy, api, showToast])
|
|
230
|
+
|
|
231
|
+
const stop = useCallback(async (): Promise<void> => {
|
|
232
|
+
await api.stop().catch(() => undefined)
|
|
233
|
+
}, [api])
|
|
234
|
+
|
|
235
|
+
const newChat = useCallback(async (): Promise<void> => {
|
|
236
|
+
try {
|
|
237
|
+
const result = await api.newChat()
|
|
238
|
+
if (result.ok === true && result.chatId !== undefined) setViewChatId(result.chatId)
|
|
239
|
+
else showToast(result.error ?? 'new chat failed', true)
|
|
240
|
+
} catch (error) {
|
|
241
|
+
showToast(String(error), true)
|
|
242
|
+
}
|
|
243
|
+
}, [api, showToast])
|
|
244
|
+
|
|
245
|
+
const toggleDeepThink = useCallback(async (): Promise<void> => {
|
|
246
|
+
const next = !deepThink
|
|
247
|
+
setDeepThink(next)
|
|
248
|
+
try {
|
|
249
|
+
const result = await api.setDeepThink(next)
|
|
250
|
+
if (result.ok !== true) showToast(result.error ?? 'toggle deep think failed', true)
|
|
251
|
+
} catch (error) {
|
|
252
|
+
showToast(String(error), true)
|
|
253
|
+
}
|
|
254
|
+
}, [deepThink, api, showToast])
|
|
255
|
+
|
|
256
|
+
const toggleSearch = useCallback(async (): Promise<void> => {
|
|
257
|
+
const next = !search
|
|
258
|
+
setSearch(next)
|
|
259
|
+
try {
|
|
260
|
+
const result = await api.setSearch(next)
|
|
261
|
+
if (result.ok !== true) showToast(result.error ?? 'toggle search failed', true)
|
|
262
|
+
} catch (error) {
|
|
263
|
+
showToast(String(error), true)
|
|
264
|
+
}
|
|
265
|
+
}, [search, api, showToast])
|
|
266
|
+
|
|
267
|
+
const openLogin = useCallback(async (): Promise<void> => {
|
|
268
|
+
try {
|
|
269
|
+
const result = await api.openLogin()
|
|
270
|
+
if (result.ok !== true) showToast(result.error ?? 'open login failed', true)
|
|
271
|
+
} catch (error) {
|
|
272
|
+
showToast(String(error), true)
|
|
273
|
+
}
|
|
274
|
+
}, [api, showToast])
|
|
275
|
+
|
|
276
|
+
const closeBrowser = useCallback(async (): Promise<void> => {
|
|
277
|
+
await api.closeBrowser().catch(() => undefined)
|
|
278
|
+
}, [api])
|
|
279
|
+
|
|
280
|
+
const toggleRecover = useCallback(async (): Promise<void> => {
|
|
281
|
+
if (recoverOpen) {
|
|
282
|
+
setRecoverOpen(false)
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const result = await api.webChats()
|
|
287
|
+
setWebMissing(result.ok === true ? (result.missing ?? []) : [])
|
|
288
|
+
setRecoverOpen(true)
|
|
289
|
+
} catch (error) {
|
|
290
|
+
showToast(String(error), true)
|
|
291
|
+
}
|
|
292
|
+
}, [recoverOpen, api, showToast])
|
|
293
|
+
|
|
294
|
+
const recoverFromWeb = useCallback(async (title: string): Promise<void> => {
|
|
295
|
+
if (recovering) return
|
|
296
|
+
setRecovering(true)
|
|
297
|
+
try {
|
|
298
|
+
const result = await api.recover(title)
|
|
299
|
+
if (result.ok !== true || result.chatId === undefined) {
|
|
300
|
+
showToast(result.error ?? 'recover failed', true)
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
setWebMissing(list => list.filter(item => item !== title))
|
|
304
|
+
showToast(fmt(tt('recover.done'), { title: result.title ?? title }))
|
|
305
|
+
} catch (error) {
|
|
306
|
+
showToast(String(error), true)
|
|
307
|
+
} finally {
|
|
308
|
+
setRecovering(false)
|
|
309
|
+
}
|
|
310
|
+
}, [recovering, api, showToast, tt])
|
|
311
|
+
|
|
312
|
+
const transferToHarness = useCallback(async (): Promise<void> => {
|
|
313
|
+
if (viewChat === undefined || transferring) return
|
|
314
|
+
setTransferring(true)
|
|
315
|
+
try {
|
|
316
|
+
const result = await api.transfer(viewChat.id, currentCwd(), transferMode, targetWorkspaceId, targetSessionId)
|
|
317
|
+
if (result.ok !== true || result.sessionId === undefined) {
|
|
318
|
+
showToast(result.error ?? 'transfer failed', true)
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
const target = result.sessionId
|
|
322
|
+
if (result.continued === true) {
|
|
323
|
+
// Continuation: the session is already listed — just open it.
|
|
324
|
+
showToast(fmt(tt('transfer.continued'), { sessionId: target }))
|
|
325
|
+
sessions.open(target as SessionId)
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
if (result.attached === false && targetWorkspaceId !== undefined) {
|
|
329
|
+
showToast(fmt(tt('transfer.attached.failed'), { sessionId: target }), true)
|
|
330
|
+
} else {
|
|
331
|
+
showToast(fmt(tt('transfer.done'), { sessionId: target }))
|
|
332
|
+
}
|
|
333
|
+
// The session is persisted cold, so the cached list does not know about
|
|
334
|
+
// it yet. Trigger a full list refresh (concrete-only on the runtime
|
|
335
|
+
// sessions service), then poll until the row lands and open it.
|
|
336
|
+
const refreshable = sessions as ISessions & { refresh?: () => Promise<void> }
|
|
337
|
+
try {
|
|
338
|
+
await refreshable.refresh?.()
|
|
339
|
+
} catch {
|
|
340
|
+
// Refresh is best-effort; fall through to the poll below.
|
|
341
|
+
}
|
|
342
|
+
const tryOpen = (): void => {
|
|
343
|
+
const snapshot = sessions.list.getSnapshot()
|
|
344
|
+
if (snapshot.byId[target as SessionId] !== undefined) {
|
|
345
|
+
sessions.open(target as SessionId)
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
window.setTimeout(tryOpen, 300)
|
|
349
|
+
}
|
|
350
|
+
window.setTimeout(tryOpen, 200)
|
|
351
|
+
} catch (error) {
|
|
352
|
+
showToast(String(error), true)
|
|
353
|
+
} finally {
|
|
354
|
+
setTransferring(false)
|
|
355
|
+
}
|
|
356
|
+
}, [viewChat, transferring, api, currentCwd, sessions, showToast, tt, transferMode, targetWorkspaceId, targetSessionId])
|
|
357
|
+
|
|
358
|
+
const exportFile = useCallback(async (): Promise<void> => {
|
|
359
|
+
if (viewChat === undefined || exporting) return
|
|
360
|
+
setExporting(true)
|
|
361
|
+
try {
|
|
362
|
+
const result = await api.exportFile(viewChat.id, currentCwd())
|
|
363
|
+
if (result.ok !== true || result.filePath === undefined) {
|
|
364
|
+
showToast(result.error ?? 'export failed', true)
|
|
365
|
+
} else {
|
|
366
|
+
showToast(fmt(tt('export.done'), { filePath: result.filePath }))
|
|
367
|
+
}
|
|
368
|
+
} catch (error) {
|
|
369
|
+
showToast(String(error), true)
|
|
370
|
+
} finally {
|
|
371
|
+
setExporting(false)
|
|
372
|
+
}
|
|
373
|
+
}, [viewChat, exporting, api, currentCwd, showToast, tt])
|
|
374
|
+
|
|
375
|
+
// --- transcript management (rename / delete / clear-all) -------------------
|
|
376
|
+
const resetArmTimer = useCallback((next: (() => void) | undefined): void => {
|
|
377
|
+
window.clearTimeout(armTimer.current)
|
|
378
|
+
armTimer.current = next === undefined ? undefined : window.setTimeout(next, 3_000)
|
|
379
|
+
}, [])
|
|
380
|
+
|
|
381
|
+
const startRename = useCallback((chat: WebChatTranscript): void => {
|
|
382
|
+
setDeleteArmId(undefined)
|
|
383
|
+
setClearArm(false)
|
|
384
|
+
setRenameDraft(chat.title)
|
|
385
|
+
setRenamingId(chat.id)
|
|
386
|
+
}, [])
|
|
387
|
+
|
|
388
|
+
const commitRename = useCallback(async (): Promise<void> => {
|
|
389
|
+
const id = renamingId
|
|
390
|
+
if (id === undefined) return
|
|
391
|
+
const title = renameDraft.trim().replace(/\s+/g, ' ')
|
|
392
|
+
setRenamingId(undefined)
|
|
393
|
+
const original = chats.find(chat => chat.id === id)
|
|
394
|
+
if (title === '' || (original !== undefined && title === original.title)) return
|
|
395
|
+
try {
|
|
396
|
+
const result = await api.renameChat(id, title)
|
|
397
|
+
if (result.ok !== true) showToast(fmt(tt('toast.rename.failed'), { error: result.error ?? '' }), true)
|
|
398
|
+
else showToast(tt('toast.rename.done'))
|
|
399
|
+
} catch (error) {
|
|
400
|
+
showToast(fmt(tt('toast.rename.failed'), { error: String(error) }), true)
|
|
401
|
+
}
|
|
402
|
+
}, [renamingId, renameDraft, chats, api, showToast, tt])
|
|
403
|
+
|
|
404
|
+
const cancelRename = useCallback((): void => {
|
|
405
|
+
setRenamingId(undefined)
|
|
406
|
+
setRenameDraft('')
|
|
407
|
+
}, [])
|
|
408
|
+
|
|
409
|
+
const requestDelete = useCallback(async (id: string): Promise<void> => {
|
|
410
|
+
if (deleteArmId !== id) {
|
|
411
|
+
setClearArm(false)
|
|
412
|
+
setRenamingId(undefined)
|
|
413
|
+
setDeleteArmId(id)
|
|
414
|
+
resetArmTimer(() => setDeleteArmId(undefined))
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
resetArmTimer(undefined)
|
|
418
|
+
setDeleteArmId(undefined)
|
|
419
|
+
try {
|
|
420
|
+
const result = await api.deleteChat(id)
|
|
421
|
+
if (result.ok !== true) showToast(fmt(tt('toast.delete.failed'), { error: result.error ?? '' }), true)
|
|
422
|
+
else showToast(tt('toast.delete.done'))
|
|
423
|
+
} catch (error) {
|
|
424
|
+
showToast(fmt(tt('toast.delete.failed'), { error: String(error) }), true)
|
|
425
|
+
}
|
|
426
|
+
}, [deleteArmId, api, showToast, tt, resetArmTimer])
|
|
427
|
+
|
|
428
|
+
const requestClear = useCallback(async (): Promise<void> => {
|
|
429
|
+
if (chats.length === 0) return
|
|
430
|
+
if (!clearArm) {
|
|
431
|
+
setDeleteArmId(undefined)
|
|
432
|
+
setRenamingId(undefined)
|
|
433
|
+
setClearArm(true)
|
|
434
|
+
resetArmTimer(() => setClearArm(false))
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
resetArmTimer(undefined)
|
|
438
|
+
setClearArm(false)
|
|
439
|
+
try {
|
|
440
|
+
const result = await api.clearChats()
|
|
441
|
+
if (result.ok !== true) showToast(fmt(tt('toast.clear.failed'), { error: result.error ?? '' }), true)
|
|
442
|
+
else showToast(tt('toast.clear.done'))
|
|
443
|
+
} catch (error) {
|
|
444
|
+
showToast(fmt(tt('toast.clear.failed'), { error: String(error) }), true)
|
|
445
|
+
}
|
|
446
|
+
}, [chats.length, clearArm, api, showToast, tt, resetArmTimer])
|
|
447
|
+
|
|
448
|
+
const engineState = state?.engine ?? 'stopped'
|
|
449
|
+
const engineError = state?.engineError
|
|
450
|
+
const statusDot: 'ok' | 'warn' | 'bad' | 'busy' =
|
|
451
|
+
busy ? 'busy'
|
|
452
|
+
: engineState === 'error' ? 'bad'
|
|
453
|
+
: loggedIn === true ? 'ok'
|
|
454
|
+
: 'warn'
|
|
455
|
+
const statusText = busy
|
|
456
|
+
? tt('composer.busy')
|
|
457
|
+
: engineState === 'error'
|
|
458
|
+
? `${tt('status.engine.error')}${engineError !== undefined ? `: ${engineError}` : ''}`
|
|
459
|
+
: engineState === 'launching'
|
|
460
|
+
? tt('status.engine.launching')
|
|
461
|
+
: loggedIn === true
|
|
462
|
+
? tt('status.loggedIn')
|
|
463
|
+
: loggedIn === false
|
|
464
|
+
? tt('status.notLoggedIn')
|
|
465
|
+
: tt('status.unknown')
|
|
466
|
+
|
|
467
|
+
const togglesDisabled = busy || loggedIn !== true
|
|
468
|
+
|
|
469
|
+
return (
|
|
470
|
+
<div className={css.panel}>
|
|
471
|
+
{/* Identity + live status */}
|
|
472
|
+
<header className={css.panelHeader}>
|
|
473
|
+
<h2 className={css.panelTitle}>{tt('panel.title')}</h2>
|
|
474
|
+
<div className={css.headerSpacer} />
|
|
475
|
+
<div className={css.statusChip}>
|
|
476
|
+
<span className={css.statusDot} data-state={statusDot} />
|
|
477
|
+
<span className={css.statusText}>{statusText}</span>
|
|
478
|
+
</div>
|
|
479
|
+
{loggedIn !== true && (
|
|
480
|
+
<button className={`${css.button} ${css.buttonPrimary}`} onClick={() => { void openLogin() }}>
|
|
481
|
+
{tt('action.openLogin')}
|
|
482
|
+
</button>
|
|
483
|
+
)}
|
|
484
|
+
{engineState !== 'stopped' && (
|
|
485
|
+
<button className={css.buttonGhost} onClick={() => { void closeBrowser() }}>
|
|
486
|
+
{tt('action.closeBrowser')}
|
|
487
|
+
</button>
|
|
488
|
+
)}
|
|
489
|
+
</header>
|
|
490
|
+
|
|
491
|
+
<div className={css.panelBody}>
|
|
492
|
+
{/* Conversation sidebar (chat history + management) */}
|
|
493
|
+
<aside className={css.sidebar}>
|
|
494
|
+
<button className={`${css.button} ${css.buttonPrimary} ${css.sidebarNew}`} onClick={() => { void newChat() }}>
|
|
495
|
+
{tt('action.newChat')}
|
|
496
|
+
</button>
|
|
497
|
+
<button
|
|
498
|
+
className={`${css.buttonGhost} ${css.sidebarNew}`}
|
|
499
|
+
disabled={loggedIn !== true}
|
|
500
|
+
onClick={() => { void toggleRecover() }}
|
|
501
|
+
title={tt('recover.hint')}
|
|
502
|
+
>
|
|
503
|
+
{tt('action.recover')}
|
|
504
|
+
</button>
|
|
505
|
+
{recoverOpen && (
|
|
506
|
+
<div className={css.recoverList}>
|
|
507
|
+
{webMissing.length === 0 ? (
|
|
508
|
+
<div className={css.recoverEmpty}>{tt('recover.empty')}</div>
|
|
509
|
+
) : (
|
|
510
|
+
webMissing.map(title => (
|
|
511
|
+
<button
|
|
512
|
+
className={css.recoverItem}
|
|
513
|
+
key={title}
|
|
514
|
+
disabled={recovering}
|
|
515
|
+
onClick={() => { void recoverFromWeb(title) }}
|
|
516
|
+
title={title}
|
|
517
|
+
>
|
|
518
|
+
<span className={css.recoverTitle}>{title}</span>
|
|
519
|
+
</button>
|
|
520
|
+
))
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
)}
|
|
524
|
+
<div className={css.sidebarList}>
|
|
525
|
+
{chats.length === 0 ? (
|
|
526
|
+
<div className={css.sidebarEmpty}>{tt('sidebar.empty')}</div>
|
|
527
|
+
) : (
|
|
528
|
+
chats.map(chat => (
|
|
529
|
+
renamingId === chat.id ? (
|
|
530
|
+
<div className={css.sidebarItem} data-active={chat.id === viewChatId ? 'true' : undefined} key={chat.id}>
|
|
531
|
+
<div className={css.sidebarRename}>
|
|
532
|
+
<input
|
|
533
|
+
className={css.sidebarRenameInput}
|
|
534
|
+
value={renameDraft}
|
|
535
|
+
placeholder={tt('chat.rename.placeholder')}
|
|
536
|
+
aria-label="rename"
|
|
537
|
+
autoFocus
|
|
538
|
+
onChange={event => setRenameDraft(event.target.value)}
|
|
539
|
+
onKeyDown={event => {
|
|
540
|
+
if (event.key === 'Enter') {
|
|
541
|
+
event.preventDefault()
|
|
542
|
+
void commitRename()
|
|
543
|
+
} else if (event.key === 'Escape') {
|
|
544
|
+
cancelRename()
|
|
545
|
+
}
|
|
546
|
+
}}
|
|
547
|
+
/>
|
|
548
|
+
<button className={css.sidebarAction} onClick={() => { void commitRename() }} aria-label={tt('chat.rename.ok')} dangerouslySetInnerHTML={{ __html: CHECK_ICON }} />
|
|
549
|
+
<button className={css.sidebarAction} onClick={cancelRename} aria-label={tt('chat.rename.cancel')} dangerouslySetInnerHTML={{ __html: CROSS_ICON }} />
|
|
550
|
+
</div>
|
|
551
|
+
</div>
|
|
552
|
+
) : (
|
|
553
|
+
<div className={css.sidebarItem} data-active={chat.id === viewChatId ? 'true' : undefined} key={chat.id}>
|
|
554
|
+
<button className={css.sidebarRow} onClick={() => setViewChatId(chat.id)} title={chat.title}>
|
|
555
|
+
<span className={css.sidebarTitle}>{chat.title}</span>
|
|
556
|
+
<span className={css.sidebarMeta}>{fmt(tt('chats.count'), { count: String(chat.messages.length) })}</span>
|
|
557
|
+
</button>
|
|
558
|
+
<span className={css.sidebarActions}>
|
|
559
|
+
<button className={css.sidebarAction} onClick={() => startRename(chat)} aria-label={tt('chat.rename')} title={tt('chat.rename')} dangerouslySetInnerHTML={{ __html: PENCIL_ICON }} />
|
|
560
|
+
<button
|
|
561
|
+
className={deleteArmId === chat.id ? css.sidebarActionDanger : css.sidebarAction}
|
|
562
|
+
onClick={() => { void requestDelete(chat.id) }}
|
|
563
|
+
aria-label={deleteArmId === chat.id ? tt('chat.delete.confirm') : tt('chat.delete')}
|
|
564
|
+
title={deleteArmId === chat.id ? tt('chat.delete.confirm') : tt('chat.delete')}
|
|
565
|
+
>
|
|
566
|
+
{deleteArmId === chat.id ? tt('chat.delete.confirm') : <span dangerouslySetInnerHTML={{ __html: TRASH_ICON }} />}
|
|
567
|
+
</button>
|
|
568
|
+
</span>
|
|
569
|
+
</div>
|
|
570
|
+
)
|
|
571
|
+
))
|
|
572
|
+
)}
|
|
573
|
+
</div>
|
|
574
|
+
<button
|
|
575
|
+
className={clearArm ? `${css.button} ${css.buttonDanger} ${css.sidebarClear}` : `${css.buttonGhost} ${css.sidebarClear}`}
|
|
576
|
+
disabled={chats.length === 0}
|
|
577
|
+
onClick={() => { void requestClear() }}
|
|
578
|
+
>
|
|
579
|
+
{clearArm ? tt('chat.clearAll.confirm') : tt('chat.clearAll')}
|
|
580
|
+
</button>
|
|
581
|
+
</aside>
|
|
582
|
+
|
|
583
|
+
{/* Main chat column */}
|
|
584
|
+
<div className={css.main}>
|
|
585
|
+
<div className={css.messageList} ref={listRef} onScroll={handleScroll}>
|
|
586
|
+
{viewChat === undefined || viewChat.messages.length === 0 ? (
|
|
587
|
+
<div className={css.emptyState}>
|
|
588
|
+
<div className={css.emptyEyebrow}>{tt('empty.eyebrow')}</div>
|
|
589
|
+
<h3 className={css.emptyTitle}>{tt('empty.title')}</h3>
|
|
590
|
+
<p className={css.emptyBody}>{tt('empty.body')}</p>
|
|
591
|
+
{loggedIn !== true && (
|
|
592
|
+
<button className={`${css.button} ${css.buttonPrimary}`} onClick={() => { void openLogin() }}>
|
|
593
|
+
{tt('action.openLogin')}
|
|
594
|
+
</button>
|
|
595
|
+
)}
|
|
596
|
+
</div>
|
|
597
|
+
) : (
|
|
598
|
+
viewChat.messages.map(message => (
|
|
599
|
+
<div className={css.messageRow} data-role={message.role} key={message.id}>
|
|
600
|
+
<div className={css.messageMeta}>
|
|
601
|
+
<span className={css.messageSpeaker}>{message.role === 'user' ? tt('role.you') : modelLabel(viewChat.model)}</span>
|
|
602
|
+
<span className={css.messageTime}>{new Date(message.ts).toLocaleTimeString()}</span>
|
|
603
|
+
{message.streaming === true && <span className={css.streamBadge}>{tt('streaming')}…</span>}
|
|
604
|
+
</div>
|
|
605
|
+
<div className={css.messageBubble}>
|
|
606
|
+
<Markdown source={message.content} />
|
|
607
|
+
{message.error !== undefined && message.content !== '' && (
|
|
608
|
+
<div className={css.messageError}>{fmt(tt('msg.error'), { error: message.error })}</div>
|
|
609
|
+
)}
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
))
|
|
613
|
+
)}
|
|
614
|
+
</div>
|
|
615
|
+
|
|
616
|
+
<div className={css.composer}>
|
|
617
|
+
<div className={css.composerToolbar}>
|
|
618
|
+
<button
|
|
619
|
+
className={css.toggleButton}
|
|
620
|
+
data-active={deepThink ? 'true' : undefined}
|
|
621
|
+
disabled={togglesDisabled}
|
|
622
|
+
title={tt('toggle.deepThink.hint')}
|
|
623
|
+
aria-pressed={deepThink}
|
|
624
|
+
onClick={() => { void toggleDeepThink() }}
|
|
625
|
+
>
|
|
626
|
+
{tt('toggle.deepThink')}
|
|
627
|
+
</button>
|
|
628
|
+
<button
|
|
629
|
+
className={css.toggleButton}
|
|
630
|
+
data-active={search ? 'true' : undefined}
|
|
631
|
+
disabled={togglesDisabled}
|
|
632
|
+
title={tt('toggle.search.hint')}
|
|
633
|
+
aria-pressed={search}
|
|
634
|
+
onClick={() => { void toggleSearch() }}
|
|
635
|
+
>
|
|
636
|
+
{tt('toggle.search')}
|
|
637
|
+
</button>
|
|
638
|
+
</div>
|
|
639
|
+
<textarea
|
|
640
|
+
className={css.composerInput}
|
|
641
|
+
value={draft}
|
|
642
|
+
placeholder={busy ? tt('composer.busy') : loggedIn !== true ? tt('composer.notLoggedIn') : tt('composer.placeholder')}
|
|
643
|
+
disabled={busy || loggedIn !== true}
|
|
644
|
+
onChange={event => setDraft(event.target.value)}
|
|
645
|
+
onKeyDown={event => {
|
|
646
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
647
|
+
event.preventDefault()
|
|
648
|
+
void send()
|
|
649
|
+
}
|
|
650
|
+
}}
|
|
651
|
+
/>
|
|
652
|
+
{attachOpen && (
|
|
653
|
+
<input
|
|
654
|
+
className={css.attachInput}
|
|
655
|
+
value={imagePaths}
|
|
656
|
+
placeholder={tt('composer.attach.placeholder')}
|
|
657
|
+
disabled={busy || loggedIn !== true}
|
|
658
|
+
onChange={event => setImagePaths(event.target.value)}
|
|
659
|
+
/>
|
|
660
|
+
)}
|
|
661
|
+
<div className={css.composerFooter}>
|
|
662
|
+
<button
|
|
663
|
+
className={css.toggleButton}
|
|
664
|
+
data-active={attachOpen ? 'true' : undefined}
|
|
665
|
+
title={tt('composer.attach.hint')}
|
|
666
|
+
aria-pressed={attachOpen}
|
|
667
|
+
disabled={busy || loggedIn !== true}
|
|
668
|
+
onClick={() => setAttachOpen(open => !open)}
|
|
669
|
+
>
|
|
670
|
+
{tt('composer.attach')}
|
|
671
|
+
</button>
|
|
672
|
+
<span className={css.composerHint}>{tt('composer.hint')}</span>
|
|
673
|
+
<div className={css.composerSpacer} />
|
|
674
|
+
{streaming ? (
|
|
675
|
+
<button className={`${css.button} ${css.buttonDanger}`} onClick={() => { void stop() }}>
|
|
676
|
+
{tt('action.stop')}
|
|
677
|
+
</button>
|
|
678
|
+
) : (
|
|
679
|
+
<button
|
|
680
|
+
className={`${css.button} ${css.buttonPrimary}`}
|
|
681
|
+
disabled={busy || loggedIn !== true || (draft.trim() === '' && imagePaths.trim() === '')}
|
|
682
|
+
onClick={() => { void send() }}
|
|
683
|
+
>
|
|
684
|
+
{tt('action.send')}
|
|
685
|
+
</button>
|
|
686
|
+
)}
|
|
687
|
+
</div>
|
|
688
|
+
</div>
|
|
689
|
+
|
|
690
|
+
{/* Handoff dock — the chat → harness gateway (signature) */}
|
|
691
|
+
<div className={css.handoffDock}>
|
|
692
|
+
<span className={css.handoffEyebrow}>{tt('handoff.eyebrow')}</span>
|
|
693
|
+
<div className={css.handoffSpacer} />
|
|
694
|
+
<div className={css.segmented} role="group" aria-label="transfer mode" title={tt('transfer.mode.hint')}>
|
|
695
|
+
<button
|
|
696
|
+
className={css.segmentedButton}
|
|
697
|
+
data-active={transferMode === 'distill' ? 'true' : undefined}
|
|
698
|
+
onClick={() => setTransferMode('distill')}
|
|
699
|
+
>
|
|
700
|
+
{tt('transfer.mode.distill')}
|
|
701
|
+
</button>
|
|
702
|
+
<button
|
|
703
|
+
className={css.segmentedButton}
|
|
704
|
+
data-active={transferMode === 'raw' ? 'true' : undefined}
|
|
705
|
+
onClick={() => setTransferMode('raw')}
|
|
706
|
+
>
|
|
707
|
+
{tt('transfer.mode.raw')}
|
|
708
|
+
</button>
|
|
709
|
+
</div>
|
|
710
|
+
<select
|
|
711
|
+
className={css.workspaceSelect}
|
|
712
|
+
value={targetSessionId ?? ''}
|
|
713
|
+
disabled={continuationTargets.length === 0}
|
|
714
|
+
title={tt('transfer.session.hint')}
|
|
715
|
+
aria-label={tt('transfer.session.label')}
|
|
716
|
+
onChange={event => setTargetSessionId(event.target.value === '' ? undefined : event.target.value)}
|
|
717
|
+
>
|
|
718
|
+
<option value="">{tt('transfer.session.new')}</option>
|
|
719
|
+
{continuationTargets.map(session => (
|
|
720
|
+
<option key={session.sessionId} value={session.sessionId}>
|
|
721
|
+
{session.title}{session.running === true ? ' ·●' : ''}
|
|
722
|
+
</option>
|
|
723
|
+
))}
|
|
724
|
+
</select>
|
|
725
|
+
<select
|
|
726
|
+
className={css.workspaceSelect}
|
|
727
|
+
value={targetWorkspaceId ?? ''}
|
|
728
|
+
disabled={targetSessionId !== undefined}
|
|
729
|
+
title={tt('transfer.workspace.hint')}
|
|
730
|
+
aria-label={tt('transfer.workspace.label')}
|
|
731
|
+
onChange={event => { workspaceOverridden.current = true; setTargetWorkspaceId(event.target.value === '' ? undefined : event.target.value) }}
|
|
732
|
+
>
|
|
733
|
+
<option value="">{tt('transfer.workspace.ungrouped')}</option>
|
|
734
|
+
{workspaceItems.map(ws => (
|
|
735
|
+
<option key={ws.workspaceId} value={ws.workspaceId}>
|
|
736
|
+
{ws.title}{ws.path !== ws.title ? ` — ${ws.path}` : ''}
|
|
737
|
+
</option>
|
|
738
|
+
))}
|
|
739
|
+
</select>
|
|
740
|
+
<button className={css.buttonGhost} onClick={() => { void createWorkspace() }} title={tt('transfer.workspace.new')} disabled={targetSessionId !== undefined}>
|
|
741
|
+
{tt('transfer.workspace.new')}
|
|
742
|
+
</button>
|
|
743
|
+
<button
|
|
744
|
+
className={`${css.button} ${css.buttonPrimary}`}
|
|
745
|
+
disabled={viewChat === undefined || transferring}
|
|
746
|
+
onClick={() => { void transferToHarness() }}
|
|
747
|
+
>
|
|
748
|
+
{tt('action.transferToHarness')}
|
|
749
|
+
</button>
|
|
750
|
+
<button className={css.button} disabled={viewChat === undefined || exporting} onClick={() => { void exportFile() }}>
|
|
751
|
+
{tt('action.exportFile')}
|
|
752
|
+
</button>
|
|
753
|
+
</div>
|
|
754
|
+
</div>
|
|
755
|
+
</div>
|
|
756
|
+
|
|
757
|
+
{toast !== null && <div className={css.toast} data-error={toast.error === true ? 'true' : undefined}>{toast.text}</div>}
|
|
758
|
+
</div>
|
|
759
|
+
)
|
|
760
|
+
}
|