@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.
- package/README.i18n.yaml +2 -2
- package/README.md +77 -468
- package/README.zh.md +83 -113
- package/lib/client.js +955 -203
- package/lib/client.js.map +1 -1
- package/lib/index.js +1136 -1466
- package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
- package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
- package/lib/types/client/FooterRemoteEntry.js +1 -1
- package/lib/types/client/RemoteEntry.d.ts +7 -4
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +7 -10
- package/lib/types/client/RemotePanel.d.ts +2 -2
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +56 -3
- package/lib/types/client/deep-link.d.ts +10 -13
- package/lib/types/client/deep-link.d.ts.map +1 -1
- package/lib/types/client/deep-link.js +14 -74
- package/lib/types/client/index.d.ts +2 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +73 -39
- package/lib/types/client/locales.d.ts +26 -12
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -22
- package/lib/types/client/mobile-adapt.d.ts +40 -0
- package/lib/types/client/mobile-adapt.d.ts.map +1 -0
- package/lib/types/client/mobile-adapt.js +967 -0
- package/lib/types/client/pair-api.d.ts +24 -6
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +9 -14
- package/lib/types/client/remote-channel.d.ts +3 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +35 -3
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +60 -53
- package/lib/types/firewall.d.ts +75 -0
- package/lib/types/firewall.d.ts.map +1 -0
- package/lib/types/firewall.js +184 -0
- package/lib/types/gate.d.ts +11 -5
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +11 -5
- package/lib/types/index.d.ts +39 -10
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +206 -58
- package/lib/types/inner-auth.d.ts +32 -0
- package/lib/types/inner-auth.d.ts.map +1 -0
- package/lib/types/inner-auth.js +72 -0
- package/lib/types/lan-bind-plan.d.ts +55 -0
- package/lib/types/lan-bind-plan.d.ts.map +1 -0
- package/lib/types/lan-bind-plan.js +60 -0
- package/lib/types/lan-bind.d.ts +69 -0
- package/lib/types/lan-bind.d.ts.map +1 -0
- package/lib/types/lan-bind.js +128 -0
- package/lib/types/loopback-proxy.d.ts +9 -2
- package/lib/types/loopback-proxy.d.ts.map +1 -1
- package/lib/types/loopback-proxy.js +24 -2
- package/lib/types/pairing.d.ts +15 -3
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +20 -6
- package/lib/types/remote-api.d.ts +30 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +55 -42
- package/lib/types/remote-channel-boot.d.ts +21 -2
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +65 -3
- package/lib/types/remote-channel-rules.d.ts +8 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +9 -2
- package/lib/types/remote-methods.d.ts +45 -11
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +56 -27
- package/lib/types/remote-presence-pet.d.ts +51 -0
- package/lib/types/remote-presence-pet.d.ts.map +1 -0
- package/lib/types/remote-presence-pet.js +101 -0
- package/lib/types/routes.d.ts +76 -4
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +336 -19
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -1
- package/package.json +20 -18
- package/src/client/FooterRemoteEntry.tsx +9 -10
- package/src/client/RemoteEntry.tsx +13 -14
- package/src/client/RemotePanel.tsx +5 -16
- package/src/client/RemoteSettingsCard.tsx +72 -13
- package/src/client/deep-link.ts +14 -75
- package/src/client/index.ts +77 -39
- package/src/client/locales.ts +36 -22
- package/src/client/mobile-adapt.ts +913 -0
- package/src/client/pair-api.ts +26 -15
- package/src/client/remote-channel.ts +31 -3
- package/src/client/settings-form.ts +78 -92
- package/src/dsh-home.ts +1 -1
- package/src/firewall.ts +230 -0
- package/src/gate.ts +11 -5
- package/src/index.ts +235 -65
- package/src/inner-auth.ts +85 -0
- package/src/lan-bind-plan.ts +81 -0
- package/src/lan-bind.ts +145 -0
- package/src/loopback-proxy.ts +34 -0
- package/src/pairing.persist.test.ts +1 -1
- package/src/pairing.ts +20 -6
- package/src/remote-api.ts +67 -45
- package/src/remote-channel-boot.ts +70 -3
- package/src/remote-channel-rules.ts +17 -2
- package/src/remote-methods.ts +58 -27
- package/src/remote-presence-pet.ts +129 -0
- package/src/routes.ts +358 -19
- package/src/tunnel.ts +7 -1
- package/assets/apple-touch-icon.png +0 -0
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +0 -63
- package/lib/mobile.js +0 -17621
- package/lib/mobile.js.map +0 -1
- package/lib/types/mobile/PairRequiredView.d.ts +0 -7
- package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.js +0 -28
- package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.test.js +0 -24
- package/lib/types/mobile/api.d.ts +0 -145
- package/lib/types/mobile/api.d.ts.map +0 -1
- package/lib/types/mobile/api.js +0 -103
- package/lib/types/mobile/index.d.ts +0 -10
- package/lib/types/mobile/index.d.ts.map +0 -1
- package/lib/types/mobile/index.js +0 -38
- package/lib/types/mobile/markdown.d.ts +0 -34
- package/lib/types/mobile/markdown.d.ts.map +0 -1
- package/lib/types/mobile/markdown.js +0 -285
- package/lib/types/mobile/markdown.test.d.ts +0 -2
- package/lib/types/mobile/markdown.test.d.ts.map +0 -1
- package/lib/types/mobile/markdown.test.js +0 -79
- package/lib/types/mobile/messages.d.ts +0 -141
- package/lib/types/mobile/messages.d.ts.map +0 -1
- package/lib/types/mobile/messages.js +0 -553
- package/lib/types/mobile/messages.test.d.ts +0 -2
- package/lib/types/mobile/messages.test.d.ts.map +0 -1
- package/lib/types/mobile/messages.test.js +0 -356
- package/lib/types/mobile/mobile-styles.d.ts +0 -6
- package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
- package/lib/types/mobile/mobile-styles.js +0 -1438
- package/lib/types/mobile/mobile-theme.d.ts +0 -22
- package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.js +0 -70
- package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
- package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.test.js +0 -71
- package/lib/types/mobile/mux.d.ts +0 -128
- package/lib/types/mobile/mux.d.ts.map +0 -1
- package/lib/types/mobile/mux.js +0 -305
- package/lib/types/mobile/mux.test.d.ts +0 -2
- package/lib/types/mobile/mux.test.d.ts.map +0 -1
- package/lib/types/mobile/mux.test.js +0 -267
- package/lib/types/mobile/pairing.d.ts +0 -31
- package/lib/types/mobile/pairing.d.ts.map +0 -1
- package/lib/types/mobile/pairing.js +0 -62
- package/lib/types/mobile/rpc.d.ts +0 -38
- package/lib/types/mobile/rpc.d.ts.map +0 -1
- package/lib/types/mobile/rpc.js +0 -83
- package/lib/types/mobile/theme-toggle.d.ts +0 -12
- package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
- package/lib/types/mobile/theme-toggle.js +0 -26
- package/lib/types/mobile/views/App.d.ts +0 -57
- package/lib/types/mobile/views/App.d.ts.map +0 -1
- package/lib/types/mobile/views/App.js +0 -150
- package/lib/types/mobile/views/App.test.d.ts +0 -2
- package/lib/types/mobile/views/App.test.d.ts.map +0 -1
- package/lib/types/mobile/views/App.test.js +0 -52
- package/lib/types/mobile/views/ChatView.d.ts +0 -56
- package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.js +0 -773
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.stream.test.js +0 -231
- package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.test.js +0 -718
- package/lib/types/mobile/views/SessionListView.d.ts +0 -27
- package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.js +0 -139
- package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.test.js +0 -167
- package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.js +0 -93
- package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
- package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
- package/lib/types/mobile-api.d.ts +0 -65
- package/lib/types/mobile-api.d.ts.map +0 -1
- package/lib/types/mobile-api.js +0 -404
- package/lib/types/mobile-pending.d.ts +0 -38
- package/lib/types/mobile-pending.d.ts.map +0 -1
- package/lib/types/mobile-pending.js +0 -59
- package/lib/types/mobile-pwa.d.ts +0 -6
- package/lib/types/mobile-pwa.d.ts.map +0 -1
- package/lib/types/mobile-pwa.js +0 -17
- package/lib/types/mobile-routes.d.ts +0 -13
- package/lib/types/mobile-routes.d.ts.map +0 -1
- package/lib/types/mobile-routes.js +0 -217
- package/lib/types/paired-model-catalog.d.ts +0 -17
- package/lib/types/paired-model-catalog.d.ts.map +0 -1
- package/lib/types/paired-model-catalog.js +0 -311
- package/src/mobile/PairRequiredView.test.tsx +0 -28
- package/src/mobile/PairRequiredView.tsx +0 -58
- package/src/mobile/api.ts +0 -229
- package/src/mobile/index.tsx +0 -41
- package/src/mobile/markdown.test.ts +0 -91
- package/src/mobile/markdown.ts +0 -291
- package/src/mobile/messages.test.ts +0 -402
- package/src/mobile/messages.ts +0 -677
- package/src/mobile/mobile-styles.ts +0 -1439
- package/src/mobile/mobile-theme.test.ts +0 -84
- package/src/mobile/mobile-theme.ts +0 -76
- package/src/mobile/mux.test.ts +0 -314
- package/src/mobile/mux.ts +0 -336
- package/src/mobile/pairing.ts +0 -77
- package/src/mobile/rpc.ts +0 -90
- package/src/mobile/theme-toggle.tsx +0 -47
- package/src/mobile/views/App.test.tsx +0 -62
- package/src/mobile/views/App.tsx +0 -213
- package/src/mobile/views/ChatView.stream.test.tsx +0 -263
- package/src/mobile/views/ChatView.test.tsx +0 -816
- package/src/mobile/views/ChatView.tsx +0 -1295
- package/src/mobile/views/SessionListView.test.tsx +0 -207
- package/src/mobile/views/SessionListView.tsx +0 -244
- package/src/mobile/views/WorkspaceView.test.tsx +0 -227
- package/src/mobile/views/WorkspaceView.tsx +0 -245
- package/src/mobile-api.ts +0 -418
- package/src/mobile-pending.ts +0 -88
- package/src/mobile-pwa.ts +0 -19
- package/src/mobile-routes.ts +0 -231
- package/src/paired-model-catalog.ts +0 -272
|
@@ -1,773 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
/**
|
|
3
|
-
* Chat level: one session. Loads the history tail page on open, appends
|
|
4
|
-
* pages upward (loadOlder), folds live mux frames in as they arrive, and
|
|
5
|
-
* sends prompts through session.prompt.
|
|
6
|
-
*
|
|
7
|
-
* Rendering mirrors the desktop web UI's fold discipline on a small screen:
|
|
8
|
-
* - reasoning text hides behind a collapsed "深度思考" disclosure,
|
|
9
|
-
* - tool calls behind a collapsed tool disclosure (name + arguments),
|
|
10
|
-
* - very long assistant text collapses with an explicit expand toggle,
|
|
11
|
-
* - a toolbar above the composer carries the model (+ thinking effort) and
|
|
12
|
-
* permission pickers, both as bottom sheets.
|
|
13
|
-
*/
|
|
14
|
-
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
15
|
-
import { loadHistory, prompt } from "./App.js";
|
|
16
|
-
import { errorText, formatTime, staleHostHint } from "./App.js";
|
|
17
|
-
import { fetchMobilePreferences, models, selectModel, sendCommand, cancelSession, fetchPending, respondApproval, respondQuestion } from "../api.js";
|
|
18
|
-
import { EventFolder, foldEvents } from "../messages.js";
|
|
19
|
-
import { renderMarkdown } from "../markdown.js";
|
|
20
|
-
import { ThemeToggle } from "../theme-toggle.js";
|
|
21
|
-
/**
|
|
22
|
-
* Hard cap on live events buffered while the initial history tail page is in
|
|
23
|
-
* flight. Beyond this the oldest buffered event is dropped and a follow-up
|
|
24
|
-
* history tail re-pull closes the seam.
|
|
25
|
-
*/
|
|
26
|
-
export const MAX_TAIL_BUFFER_EVENTS = 500;
|
|
27
|
-
/** localStorage key for the tool-call display toggle (persisted on the /m origin). */
|
|
28
|
-
const SHOW_TOOL_CALLS_KEY = 'dsh.mobile.showToolCalls';
|
|
29
|
-
/** localStorage key for the injected-system-message display toggle. */
|
|
30
|
-
const SHOW_SYSTEM_MESSAGES_KEY = 'dsh.mobile.showSystemMessages';
|
|
31
|
-
/** Read a boolean from localStorage defensively; falls back to the default. */
|
|
32
|
-
function readStoredBoolean(key, fallback) {
|
|
33
|
-
try {
|
|
34
|
-
const raw = localStorage.getItem(key);
|
|
35
|
-
if (raw === null)
|
|
36
|
-
return fallback;
|
|
37
|
-
return raw === '1' || raw.toLowerCase() === 'true';
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
return fallback;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
/** Persist a boolean toggle; storage failures are ignored (feature stays non-persistent). */
|
|
44
|
-
function writeStoredBoolean(key, value) {
|
|
45
|
-
try {
|
|
46
|
-
localStorage.setItem(key, value ? '1' : '0');
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
/* quota / privacy mode: non-persistent is acceptable */
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
/** Extract the raw event from one history entry (the fold consumes events only). */
|
|
53
|
-
function eventOf(entry) {
|
|
54
|
-
return entry.event;
|
|
55
|
-
}
|
|
56
|
-
/** Defensive runtime guard for projection payloads. */
|
|
57
|
-
function isRecord(value) {
|
|
58
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
59
|
-
}
|
|
60
|
-
/** Parse the wire `permissions` projection defensively; undefined when absent. */
|
|
61
|
-
function parsePermissionSelect(value) {
|
|
62
|
-
if (!isRecord(value))
|
|
63
|
-
return undefined;
|
|
64
|
-
const rawOptions = Array.isArray(value['options']) ? value['options'] : [];
|
|
65
|
-
const options = [];
|
|
66
|
-
for (const raw of rawOptions) {
|
|
67
|
-
if (!isRecord(raw))
|
|
68
|
-
continue;
|
|
69
|
-
const optionValue = typeof raw['value'] === 'string' ? raw['value'] : undefined;
|
|
70
|
-
const name = typeof raw['name'] === 'string' ? raw['name'] : undefined;
|
|
71
|
-
if (optionValue === undefined || name === undefined)
|
|
72
|
-
continue;
|
|
73
|
-
options.push({
|
|
74
|
-
value: optionValue,
|
|
75
|
-
name,
|
|
76
|
-
...(typeof raw['description'] === 'string' ? { description: raw['description'] } : {}),
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
const currentValue = typeof value['currentValue'] === 'string' ? value['currentValue'] : undefined;
|
|
80
|
-
if (currentValue === undefined || options.length === 0)
|
|
81
|
-
return undefined;
|
|
82
|
-
return { options, currentValue };
|
|
83
|
-
}
|
|
84
|
-
/** One display-name transform for kebab-case machine names (web-UI parity). */
|
|
85
|
-
function displayName(name) {
|
|
86
|
-
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name))
|
|
87
|
-
return name;
|
|
88
|
-
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
|
89
|
-
}
|
|
90
|
-
/** First non-empty line of reasoning text (the collapsed summary). */
|
|
91
|
-
function firstMeaningfulLine(text) {
|
|
92
|
-
const trimmed = text.trim();
|
|
93
|
-
if (trimmed === '')
|
|
94
|
-
return '';
|
|
95
|
-
const newline = trimmed.indexOf('\n');
|
|
96
|
-
return newline === -1 ? trimmed : trimmed.slice(0, newline);
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Render one session's chat.
|
|
100
|
-
* @param props - the session, the mux client, and the back action.
|
|
101
|
-
* @returns the chat surface.
|
|
102
|
-
*/
|
|
103
|
-
export function ChatView({ session, mux, onBack }) {
|
|
104
|
-
const [messages, setMessages] = useState([]);
|
|
105
|
-
const [hasOlder, setHasOlder] = useState(false);
|
|
106
|
-
const [loading, setLoading] = useState(true);
|
|
107
|
-
const [error, setError] = useState(undefined);
|
|
108
|
-
const [input, setInput] = useState('');
|
|
109
|
-
const [sending, setSending] = useState(false);
|
|
110
|
-
const scrollRef = useRef(undefined);
|
|
111
|
-
const pendingRef = useRef(false);
|
|
112
|
-
/**
|
|
113
|
-
* True while the initial tail page is in flight. Live events arriving in
|
|
114
|
-
* that window go to {@link liveBufferRef} instead of the message list: the
|
|
115
|
-
* tail load replaces the list wholesale, so a directly folded event would
|
|
116
|
-
* flash once, be discarded by the snapshot, and then be skipped forever by
|
|
117
|
-
* the seq watermark.
|
|
118
|
-
*/
|
|
119
|
-
const tailLoadingRef = useRef(true);
|
|
120
|
-
/** Live session events buffered while the initial tail page loads. */
|
|
121
|
-
const liveBufferRef = useRef([]);
|
|
122
|
-
/** Incremental folder for this session's stream (indexes stay hot across events). */
|
|
123
|
-
const folderRef = useRef(undefined);
|
|
124
|
-
/** True once the live buffer hit its cap (oldest events were dropped). */
|
|
125
|
-
const liveBufferOverflowRef = useRef(false);
|
|
126
|
-
/** The session's permission select (absent = capability not composed). */
|
|
127
|
-
const [permissions, setPermissions] = useState(undefined);
|
|
128
|
-
/** The current model selection for the toolbar chip (best-effort label). */
|
|
129
|
-
const [currentModel, setCurrentModel] = useState(undefined);
|
|
130
|
-
/** Which bottom sheet is open. */
|
|
131
|
-
const [sheet, setSheet] = useState(null);
|
|
132
|
-
/** Show tool-call disclosures (default on, persisted on the /m origin). */
|
|
133
|
-
const [showToolCalls, setShowToolCalls] = useState(() => readStoredBoolean(SHOW_TOOL_CALLS_KEY, true));
|
|
134
|
-
/** Show injected system messages (default off, persisted on the /m origin). */
|
|
135
|
-
const [showSystemMessages, setShowSystemMessages] = useState(() => readStoredBoolean(SHOW_SYSTEM_MESSAGES_KEY, false));
|
|
136
|
-
/**
|
|
137
|
-
* Composer preference from the plugin's host settings (default true keeps
|
|
138
|
-
* the legacy Enter-to-send behavior until the preference loads).
|
|
139
|
-
*/
|
|
140
|
-
const [mobileEnterToSend, setMobileEnterToSend] = useState(true);
|
|
141
|
-
/** Whether the assistant is currently generating (turn/start..turn/end). */
|
|
142
|
-
const [running, setRunning] = useState(false);
|
|
143
|
-
/** Whether a stop request is in flight (guards the composer's stop button). */
|
|
144
|
-
const [stopping, setStopping] = useState(false);
|
|
145
|
-
/** Pending tool approvals awaiting user decision (#1025). */
|
|
146
|
-
const [pendingApprovals, setPendingApprovals] = useState([]);
|
|
147
|
-
/** Pending questions awaiting user answer (#1025). */
|
|
148
|
-
const [pendingQuestions, setPendingQuestions] = useState([]);
|
|
149
|
-
// Read-only mobile display preferences ride the plugin's local
|
|
150
|
-
// `/m/api` method; a failure keeps the default (Enter sends).
|
|
151
|
-
useEffect(() => {
|
|
152
|
-
let cancelled = false;
|
|
153
|
-
void fetchMobilePreferences().then((preferences) => {
|
|
154
|
-
if (!cancelled)
|
|
155
|
-
setMobileEnterToSend(preferences.mobileEnterToSend !== false);
|
|
156
|
-
}, () => { });
|
|
157
|
-
return () => { cancelled = true; };
|
|
158
|
-
}, []);
|
|
159
|
-
// Tail page on open (content loads only when the session is opened).
|
|
160
|
-
useEffect(() => {
|
|
161
|
-
let cancelled = false;
|
|
162
|
-
const controller = new AbortController();
|
|
163
|
-
// A stuck history load must not keep the chat empty (or the live buffer
|
|
164
|
-
// growing) forever: abort it and surface the transport error.
|
|
165
|
-
const timeout = setTimeout(() => {
|
|
166
|
-
controller.abort(new DOMException('history load timed out', 'TimeoutError'));
|
|
167
|
-
}, 15_000);
|
|
168
|
-
tailLoadingRef.current = true;
|
|
169
|
-
liveBufferRef.current = [];
|
|
170
|
-
liveBufferOverflowRef.current = false;
|
|
171
|
-
folderRef.current = undefined;
|
|
172
|
-
setLoading(true);
|
|
173
|
-
setError(undefined);
|
|
174
|
-
setMessages([]);
|
|
175
|
-
void loadHistory(session.sessionId, undefined, controller.signal).then((page) => {
|
|
176
|
-
if (cancelled)
|
|
177
|
-
return;
|
|
178
|
-
// Buffered live events re-fold on top of the snapshot; the watermark
|
|
179
|
-
// drops any the snapshot already includes, so nothing is lost or doubled.
|
|
180
|
-
const buffered = liveBufferRef.current;
|
|
181
|
-
liveBufferRef.current = [];
|
|
182
|
-
tailLoadingRef.current = false;
|
|
183
|
-
const folder = new EventFolder(foldEvents(page.events.map(eventOf)));
|
|
184
|
-
folderRef.current = folder;
|
|
185
|
-
setMessages(folder.fold(buffered));
|
|
186
|
-
setHasOlder(page.hasMore);
|
|
187
|
-
setLoading(false);
|
|
188
|
-
// The history-tail projection baseline seeds the permission picker.
|
|
189
|
-
// The `permissions` key is declared by the deployment's permission
|
|
190
|
-
// plugin (augmentation), so the base SDK map is indexed loosely.
|
|
191
|
-
const projections = page.projections?.values;
|
|
192
|
-
setPermissions(parsePermissionSelect(projections?.['permissions']));
|
|
193
|
-
// The buffer overflowed while waiting (oldest events were dropped), so
|
|
194
|
-
// re-pull the freshest history page to close the gap on top of what is
|
|
195
|
-
// already rendered. Best-effort: a failure here only logs, it must not
|
|
196
|
-
// replace the loaded state with an error.
|
|
197
|
-
if (liveBufferOverflowRef.current) {
|
|
198
|
-
void loadHistory(session.sessionId, undefined, controller.signal).then((fresh) => {
|
|
199
|
-
if (cancelled)
|
|
200
|
-
return;
|
|
201
|
-
const folder = folderRef.current;
|
|
202
|
-
const freshEvents = fresh.events.map(eventOf);
|
|
203
|
-
setMessages(previous => folder === undefined ? foldEvents(freshEvents, previous) : folder.fold(freshEvents));
|
|
204
|
-
liveBufferOverflowRef.current = false;
|
|
205
|
-
}, (reason) => {
|
|
206
|
-
if (cancelled)
|
|
207
|
-
return;
|
|
208
|
-
console.warn('history tail re-pull after live buffer overflow failed', reason);
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
}, (reason) => {
|
|
212
|
-
if (cancelled)
|
|
213
|
-
return;
|
|
214
|
-
// Load failed: flush the buffer so the live stream still renders.
|
|
215
|
-
const buffered = liveBufferRef.current;
|
|
216
|
-
liveBufferRef.current = [];
|
|
217
|
-
tailLoadingRef.current = false;
|
|
218
|
-
if (buffered.length > 0) {
|
|
219
|
-
const folder = folderRef.current;
|
|
220
|
-
setMessages(folder === undefined ? foldEvents(buffered) : folder.fold(buffered));
|
|
221
|
-
}
|
|
222
|
-
setError(errorText(reason));
|
|
223
|
-
setLoading(false);
|
|
224
|
-
});
|
|
225
|
-
// Best-effort current-model label for the toolbar chip; the sheet
|
|
226
|
-
// always re-reads a fresh directory on open.
|
|
227
|
-
void models(session.sessionId).then((directory) => {
|
|
228
|
-
if (!cancelled)
|
|
229
|
-
setCurrentModel(directory.current);
|
|
230
|
-
}, () => { });
|
|
231
|
-
return () => {
|
|
232
|
-
cancelled = true;
|
|
233
|
-
clearTimeout(timeout);
|
|
234
|
-
controller.abort();
|
|
235
|
-
};
|
|
236
|
-
}, [session.sessionId]);
|
|
237
|
-
// Live frames: fold session events for this session in as they arrive.
|
|
238
|
-
useEffect(() => {
|
|
239
|
-
if (mux === undefined)
|
|
240
|
-
return;
|
|
241
|
-
return mux.onFrame((frame) => {
|
|
242
|
-
if (frame.type === 'session/event') {
|
|
243
|
-
if (frame.sessionId !== session.sessionId)
|
|
244
|
-
return;
|
|
245
|
-
const event = frame.event;
|
|
246
|
-
// Track the turn running state for the "outputting" indicator (#1017).
|
|
247
|
-
if (typeof event.type === 'string') {
|
|
248
|
-
if (event.type === 'turn/start')
|
|
249
|
-
setRunning(true);
|
|
250
|
-
if (event.type === 'turn/end')
|
|
251
|
-
setRunning(false);
|
|
252
|
-
}
|
|
253
|
-
if (tailLoadingRef.current) {
|
|
254
|
-
if (liveBufferRef.current.length >= MAX_TAIL_BUFFER_EVENTS) {
|
|
255
|
-
// Bound the tail-load window: drop the oldest buffered event and
|
|
256
|
-
// remember that a follow-up history re-pull is needed. Warn once
|
|
257
|
-
// per load, not for every subsequent overflow.
|
|
258
|
-
liveBufferRef.current.shift();
|
|
259
|
-
if (!liveBufferOverflowRef.current) {
|
|
260
|
-
console.warn(`history tail is slow: live buffer reached ${MAX_TAIL_BUFFER_EVENTS} events; oldest buffered events will be re-fetched`);
|
|
261
|
-
liveBufferOverflowRef.current = true;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
liveBufferRef.current.push(event);
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
setMessages(previous => {
|
|
268
|
-
const folder = folderRef.current;
|
|
269
|
-
return folder === undefined ? foldEvents([event], previous) : folder.fold([event]);
|
|
270
|
-
});
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
// Live projection pushes keep the permission picker current.
|
|
274
|
-
if (frame.type === 'session/projection'
|
|
275
|
-
&& frame.sessionId === session.sessionId
|
|
276
|
-
&& frame.key === 'permissions') {
|
|
277
|
-
setPermissions(parsePermissionSelect(frame.value));
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
// Approval/question frames for this session (#1025).
|
|
281
|
-
if (!('sessionId' in frame) || frame.sessionId !== session.sessionId)
|
|
282
|
-
return;
|
|
283
|
-
if (frame.type === 'approval/requested') {
|
|
284
|
-
setPendingApprovals(previous => {
|
|
285
|
-
if (previous.some(a => a.approvalId === frame.approvalId))
|
|
286
|
-
return previous;
|
|
287
|
-
return [...previous, {
|
|
288
|
-
approvalId: frame.approvalId,
|
|
289
|
-
toolName: frame.toolName,
|
|
290
|
-
callId: frame.callId,
|
|
291
|
-
reason: frame.reason,
|
|
292
|
-
}];
|
|
293
|
-
});
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
if (frame.type === 'approval/resolved') {
|
|
297
|
-
setPendingApprovals(previous => previous.filter(a => a.approvalId !== frame.approvalId));
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
if (frame.type === 'question/requested') {
|
|
301
|
-
const items = frame.questions;
|
|
302
|
-
setPendingQuestions(items);
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
if (frame.type === 'question/resolved') {
|
|
306
|
-
setPendingQuestions([]);
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
});
|
|
310
|
-
}, [mux, session.sessionId]);
|
|
311
|
-
// Weak-network polling fallback: when the assistant is running, poll for
|
|
312
|
-
// pending approvals/questions every 1.5 s so the phone can act even if the
|
|
313
|
-
// SSE channel drops frames (#1025).
|
|
314
|
-
useEffect(() => {
|
|
315
|
-
if (!running)
|
|
316
|
-
return;
|
|
317
|
-
let cancelled = false;
|
|
318
|
-
const tick = () => {
|
|
319
|
-
void fetchPending(session.sessionId).then((state) => {
|
|
320
|
-
if (cancelled)
|
|
321
|
-
return;
|
|
322
|
-
setPendingApprovals(state.approvals);
|
|
323
|
-
setPendingQuestions(state.questions);
|
|
324
|
-
}, () => { });
|
|
325
|
-
};
|
|
326
|
-
tick();
|
|
327
|
-
const timer = setInterval(tick, 1500);
|
|
328
|
-
return () => { cancelled = true; clearInterval(timer); };
|
|
329
|
-
}, [running, session.sessionId]);
|
|
330
|
-
const scrollToBottom = useCallback(() => {
|
|
331
|
-
const el = scrollRef.current;
|
|
332
|
-
if (el === undefined)
|
|
333
|
-
return;
|
|
334
|
-
el.scrollTop = el.scrollHeight;
|
|
335
|
-
}, []);
|
|
336
|
-
// Track the last message's fold key so scrolling only fires when the
|
|
337
|
-
// newest message actually changes (seq bump and/or pending flip). Runs
|
|
338
|
-
// after React has committed the render, so scrollHeight reflects the
|
|
339
|
-
// freshly appended content.
|
|
340
|
-
const lastMessageKeyRef = useRef(undefined);
|
|
341
|
-
// Keep the newest content visible. This covers the initial tail page (the
|
|
342
|
-
// effect runs after commit, fixing the stale scrollHeight from the old
|
|
343
|
-
// open-time scrollToBottom), live streaming chunks on the pending message,
|
|
344
|
-
// and finalized/appended messages. Prepending older pages via loadOlder
|
|
345
|
-
// leaves the last message untouched, so it never disturbs the scroll position.
|
|
346
|
-
useEffect(() => {
|
|
347
|
-
const last = messages[messages.length - 1];
|
|
348
|
-
if (last === undefined)
|
|
349
|
-
return;
|
|
350
|
-
const key = last.seq + ':' + (last.pending === true ? 'p' : 'f');
|
|
351
|
-
if (key === lastMessageKeyRef.current)
|
|
352
|
-
return;
|
|
353
|
-
lastMessageKeyRef.current = key;
|
|
354
|
-
scrollToBottom();
|
|
355
|
-
}, [messages, scrollToBottom]);
|
|
356
|
-
/** Load one older page and prepend it. The fold is directional (incremental
|
|
357
|
-
* tails only), so the older page folds standalone and concatenates ahead —
|
|
358
|
-
* host page boundaries never cut a message, so the seam is exact. */
|
|
359
|
-
const loadOlder = useCallback(() => {
|
|
360
|
-
if (pendingRef.current)
|
|
361
|
-
return;
|
|
362
|
-
pendingRef.current = true;
|
|
363
|
-
setLoading(true);
|
|
364
|
-
const first = messages[0];
|
|
365
|
-
if (first === undefined) {
|
|
366
|
-
pendingRef.current = false;
|
|
367
|
-
setLoading(false);
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
void loadHistory(session.sessionId, first.seq).then((page) => {
|
|
371
|
-
pendingRef.current = false;
|
|
372
|
-
setLoading(false);
|
|
373
|
-
const older = foldEvents(page.events.map(eventOf));
|
|
374
|
-
const folder = folderRef.current;
|
|
375
|
-
if (folder === undefined) {
|
|
376
|
-
setMessages(previous => [...older, ...previous]);
|
|
377
|
-
}
|
|
378
|
-
else {
|
|
379
|
-
folder.prepend(older);
|
|
380
|
-
setMessages(folder.snapshot());
|
|
381
|
-
}
|
|
382
|
-
setHasOlder(page.hasMore);
|
|
383
|
-
}, (reason) => {
|
|
384
|
-
pendingRef.current = false;
|
|
385
|
-
setLoading(false);
|
|
386
|
-
setError(errorText(reason));
|
|
387
|
-
});
|
|
388
|
-
}, [session.sessionId, messages]);
|
|
389
|
-
/** Send the drafted prompt (the echoed user/message arrives over mux). */
|
|
390
|
-
const send = useCallback(() => {
|
|
391
|
-
const text = input.trim();
|
|
392
|
-
if (text === '' || sending)
|
|
393
|
-
return;
|
|
394
|
-
setSending(true);
|
|
395
|
-
void prompt(session.sessionId, text).then(() => {
|
|
396
|
-
setSending(false);
|
|
397
|
-
setInput('');
|
|
398
|
-
}, (reason) => {
|
|
399
|
-
setSending(false);
|
|
400
|
-
setError(errorText(reason));
|
|
401
|
-
});
|
|
402
|
-
}, [input, sending, session.sessionId]);
|
|
403
|
-
/**
|
|
404
|
-
* Stop the active turn (desktop parity: the composer's primary button
|
|
405
|
-
* becomes a stop button while running). The turn/end frame arriving over
|
|
406
|
-
* mux flips the button back; a failed request surfaces through the chat
|
|
407
|
-
* error line.
|
|
408
|
-
*/
|
|
409
|
-
const stopTurn = useCallback(() => {
|
|
410
|
-
if (stopping)
|
|
411
|
-
return;
|
|
412
|
-
setStopping(true);
|
|
413
|
-
void cancelSession(session.sessionId).then(() => { setStopping(false); }, (reason) => {
|
|
414
|
-
setStopping(false);
|
|
415
|
-
setError(errorText(reason));
|
|
416
|
-
});
|
|
417
|
-
}, [stopping, session.sessionId]);
|
|
418
|
-
const modelLabel = currentModel?.model ?? '模型';
|
|
419
|
-
const permissionLabel = permissions === undefined
|
|
420
|
-
? undefined
|
|
421
|
-
: permissions.options.find(option => option.value === permissions.currentValue)?.name
|
|
422
|
-
?? displayName(permissions.currentValue);
|
|
423
|
-
// Context usage chip: the most recent assistant message carrying both usage and
|
|
424
|
-
// a positive context window drives the percentage. Scanned from the end so a
|
|
425
|
-
// newer answer (whose usage may be the last one reported) takes precedence.
|
|
426
|
-
const contextUsage = useMemo(() => {
|
|
427
|
-
for (let index = messages.length - 1; index >= 0; index--) {
|
|
428
|
-
const message = messages[index];
|
|
429
|
-
const usage = message.usage;
|
|
430
|
-
if (message.kind !== 'assistant' || usage === undefined)
|
|
431
|
-
continue;
|
|
432
|
-
const window = message.contextWindow;
|
|
433
|
-
if (window === undefined || window <= 0)
|
|
434
|
-
continue;
|
|
435
|
-
const tokens = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
|
|
436
|
-
const pct = Math.round(tokens / window * 100);
|
|
437
|
-
return { pct };
|
|
438
|
-
}
|
|
439
|
-
return undefined;
|
|
440
|
-
}, [messages]);
|
|
441
|
-
return (_jsxs("div", { className: "chat", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("button", { type: "button", className: "mobile-back", "aria-label": "\u8FD4\u56DE", onClick: onBack, children: "\u2039" }), _jsx("h1", { className: "mobile-title mobile-titleInline", children: session.title }), _jsx(ThemeToggle, {})] }), error !== undefined && _jsx("p", { className: "mobile-error mobile-pad", children: error }), _jsxs("div", { className: "chat-scroll", ref: ref => { scrollRef.current = ref ?? undefined; }, children: [hasOlder && (_jsx("button", { type: "button", className: "chat-load-older", disabled: loading, onClick: () => { void loadOlder(); }, children: loading ? '加载中…' : '加载更早的消息' })), messages.map(message => (_jsx(MessageRow, { message: message, showToolCalls: showToolCalls, showSystemMessages: showSystemMessages }, message.id))), loading && messages.length === 0 && _jsx("p", { className: "chat-typing", children: "\u52A0\u8F7D\u4E2D\u2026" }), !loading && messages.length === 0 && _jsx("p", { className: "chat-typing", children: "\u8FD8\u6CA1\u6709\u6D88\u606F\uFF0C\u53D1\u4E00\u53E5\u8BDD\u5F00\u59CB\u5427" }), running && (_jsxs("div", { className: "chat-turn-status", role: "status", "aria-label": "\u8F93\u51FA\u4E2D", children: ["\u8F93\u51FA\u4E2D", _jsxs("span", { className: "chat-turn-dots", "aria-hidden": true, children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] })] })), pendingApprovals.map(approval => (_jsx(ApprovalPanel, { approval: approval, sessionId: session.sessionId, onResolved: (id) => { setPendingApprovals(prev => prev.filter(a => a.approvalId !== id)); } }, approval.approvalId))), pendingQuestions.length > 0 && (_jsx(QuestionPanel, { questions: pendingQuestions, sessionId: session.sessionId, onResolved: () => { setPendingQuestions([]); } }))] }), _jsxs("div", { className: "chat-tools", children: [_jsxs("button", { type: "button", className: "chat-chip", onClick: () => { setSheet('model'); }, "aria-haspopup": "dialog", children: [_jsx("span", { className: "chat-chip-label", children: "\u6A21\u578B" }), _jsx("span", { className: "chat-chip-value", children: modelLabel }), _jsx("span", { className: "chat-chip-chevron", "aria-hidden": true, children: "\u203A" })] }), _jsxs("button", { type: "button", className: "chat-chip", onClick: () => { setSheet('display'); }, "aria-haspopup": "dialog", children: [_jsx("span", { className: "chat-chip-label", children: "\u663E\u793A" }), _jsx("span", { className: "chat-chip-chevron", "aria-hidden": true, children: "\u203A" })] }), permissionLabel !== undefined && (_jsxs("button", { type: "button", className: "chat-chip", onClick: () => { setSheet('permission'); }, "aria-haspopup": "dialog", children: [_jsx("span", { className: "chat-chip-label", children: "\u6743\u9650" }), _jsx("span", { className: "chat-chip-value", children: permissionLabel }), _jsx("span", { className: "chat-chip-chevron", "aria-hidden": true, children: "\u203A" })] })), contextUsage !== undefined && (_jsxs("div", { className: "chat-context" + (contextUsage.pct >= 80 ? " chat-context-warn" : ""), children: ["\u4E0A\u4E0B\u6587 ", contextUsage.pct, "%"] }))] }), _jsxs("div", { className: "chat-inputbar", children: [_jsx("textarea", { className: "chat-input", rows: 1, value: input, placeholder: mobileEnterToSend ? '输入消息,Enter 发送…' : '输入消息,Enter 换行,点按钮发送…', enterKeyHint: mobileEnterToSend ? 'send' : 'enter', onChange: (event) => { setInput(event.target.value); }, onKeyDown: (event) => {
|
|
442
|
-
if (mobileEnterToSend && event.key === 'Enter' && !event.shiftKey) {
|
|
443
|
-
event.preventDefault();
|
|
444
|
-
void send();
|
|
445
|
-
}
|
|
446
|
-
} }), _jsx("button", { type: "button", className: running ? 'chat-send chat-send-stop' : 'chat-send', ...(running ? { 'aria-label': stopping ? '停止中' : '停止' } : {}), disabled: running ? stopping : sending || input.trim() === '', onClick: () => { if (running)
|
|
447
|
-
void stopTurn();
|
|
448
|
-
else
|
|
449
|
-
void send(); }, children: running ? (_jsx("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": "true", focusable: "false", children: _jsx("rect", { x: "3", y: "3", width: "10", height: "10", rx: "3", fill: "currentColor" }) })) : sending ? '发送中…' : '发送' })] }), sheet === 'model' && (_jsx(ModelSheet, { sessionId: session.sessionId, current: currentModel, onCurrent: (selection) => { setCurrentModel(selection); }, onClose: () => { setSheet(null); } })), sheet === 'permission' && permissions !== undefined && (_jsx(PermissionSheet, { sessionId: session.sessionId, value: permissions, onChanged: (value) => {
|
|
450
|
-
setPermissions(previous => previous === undefined ? previous : { ...previous, currentValue: value });
|
|
451
|
-
}, onClose: () => { setSheet(null); } })), sheet === 'display' && (_jsx(DisplaySheet, { showToolCalls: showToolCalls, showSystemMessages: showSystemMessages, onToolCalls: (value) => { setShowToolCalls(value); writeStoredBoolean(SHOW_TOOL_CALLS_KEY, value); }, onSystemMessages: (value) => { setShowSystemMessages(value); writeStoredBoolean(SHOW_SYSTEM_MESSAGES_KEY, value); }, onClose: () => { setSheet(null); } }))] }));
|
|
452
|
-
}
|
|
453
|
-
/* ── message rows ─────────────────────────────────────────────────────── */
|
|
454
|
-
/**
|
|
455
|
-
* One rendered message row (user bubble or assistant bubble with folds).
|
|
456
|
-
* Memoized: live streaming updates exactly one message object per frame, so
|
|
457
|
-
* unchanged rows skip re-rendering their markdown/sub-components.
|
|
458
|
-
*/
|
|
459
|
-
const MessageRow = memo(function MessageRow({ message, showToolCalls, showSystemMessages }) {
|
|
460
|
-
// Injected user messages (sourceKind defined and not 'user') hide behind
|
|
461
|
-
// the system-message toggle.
|
|
462
|
-
if (message.kind === 'user'
|
|
463
|
-
&& message.sourceKind !== undefined
|
|
464
|
-
&& message.sourceKind !== 'user'
|
|
465
|
-
&& !showSystemMessages) {
|
|
466
|
-
return null;
|
|
467
|
-
}
|
|
468
|
-
const hasReasoning = message.kind === 'assistant' && message.reasoning !== undefined && message.reasoning !== '';
|
|
469
|
-
const hasTools = showToolCalls && message.kind === 'assistant' && message.tools !== undefined && message.tools.length > 0;
|
|
470
|
-
const hasText = message.text !== '';
|
|
471
|
-
const hasFailTag = message.failed === true;
|
|
472
|
-
if (!hasReasoning && !hasTools && !hasText && !hasFailTag) {
|
|
473
|
-
return null;
|
|
474
|
-
}
|
|
475
|
-
return (_jsxs("div", { className: `chat-msg chat-msg-${message.kind}${message.pending === true ? ' chat-msg-pending' : ''}${message.failed === true ? ' chat-msg-failed' : ''}`, children: [message.kind === 'assistant' && message.reasoning !== undefined && message.reasoning !== '' && (_jsx(ReasoningDisclosure, { text: message.reasoning, pending: message.pending === true })), showToolCalls && message.kind === 'assistant' && message.tools !== undefined && message.tools.length > 0 && (_jsx(ToolDisclosure, { tools: message.tools })), message.kind === 'assistant'
|
|
476
|
-
? _jsx(MarkdownText, { text: message.text, pending: message.pending === true })
|
|
477
|
-
: _jsx(CollapsibleText, { text: message.text }), message.failed === true && _jsx("span", { className: "chat-msg-failtag", children: "\u672C\u6B21\u56DE\u590D\u5931\u8D25" }), _jsx("span", { className: "chat-msg-time", children: formatTime(message.time) })] }));
|
|
478
|
-
});
|
|
479
|
-
/** Collapsed-by-default reasoning disclosure (web-UI Think-row parity). */
|
|
480
|
-
function ReasoningDisclosure({ text, pending }) {
|
|
481
|
-
const [open, setOpen] = useState(false);
|
|
482
|
-
const summary = pending ? lastLine(text) : firstMeaningfulLine(text);
|
|
483
|
-
return (_jsxs("div", { className: `chat-disclosure chat-reasoning${open ? ' chat-disclosure-open' : ''}`, "data-pending": pending || undefined, children: [_jsxs("button", { type: "button", className: "chat-disclosure-head", "aria-expanded": open, onClick: () => { setOpen(value => !value); }, children: [_jsx("span", { className: "chat-disclosure-caret", "aria-hidden": true, children: "\u203A" }), _jsx("span", { className: "chat-disclosure-label", children: pending ? '思考中…' : '深度思考' }), !open && _jsx("span", { className: "chat-disclosure-summary", children: summary })] }), open && _jsx("div", { className: "chat-disclosure-body", children: text })] }));
|
|
484
|
-
}
|
|
485
|
-
/** Collapsed-by-default tool-call disclosure: pill tag summary + card details (#529). */
|
|
486
|
-
function ToolDisclosure({ tools }) {
|
|
487
|
-
const [open, setOpen] = useState(false);
|
|
488
|
-
const uniqueNames = [...new Set(tools.map(tool => tool.name))];
|
|
489
|
-
return (_jsxs("div", { className: `chat-disclosure chat-tools${open ? ' chat-disclosure-open' : ''}`, children: [_jsxs("button", { type: "button", className: "chat-disclosure-head", "aria-expanded": open, onClick: () => { setOpen(value => !value); }, children: [_jsx("span", { className: "chat-disclosure-caret", "aria-hidden": true, children: "\u203A" }), _jsx("span", { className: "chat-disclosure-label", children: "\u5DE5\u5177" }), !open && (_jsx("span", { className: "chat-disclosure-summary chat-tool-pills", children: uniqueNames.map(name => (_jsx("span", { className: "chat-tool-pill", children: name }, name))) })), _jsxs("span", { className: "chat-disclosure-count", children: [tools.length, " \u6B21"] })] }), open && (_jsx("div", { className: "chat-disclosure-body chat-tools-body", children: tools.map((tool, index) => (_jsxs("div", { className: "chat-tool-card", children: [_jsx("span", { className: "chat-tool-pill", children: tool.name }), tool.arguments !== undefined && (_jsx("pre", { className: "chat-tool-args", children: tool.arguments }))] }, `${tool.callId}-${index}`))) }))] }));
|
|
490
|
-
}
|
|
491
|
-
/**
|
|
492
|
-
* Minimum interval between full markdown re-parses of a live (pending)
|
|
493
|
-
* assistant message. Every streamed chunk replaces the message object, and
|
|
494
|
-
* re-parsing the whole accumulated text per chunk turns a long reply into
|
|
495
|
-
* O(n^2) work on mobile. Pending text keeps the last parsed result visible
|
|
496
|
-
* and re-parses at most once per interval; the moment the turn closes the
|
|
497
|
-
* final text parses immediately, so terminal messages render exactly as
|
|
498
|
-
* before.
|
|
499
|
-
*/
|
|
500
|
-
export const STREAM_RENDER_INTERVAL_MS = 120;
|
|
501
|
-
/**
|
|
502
|
-
* Assistant text rendered as GFM markdown (escape-first, protocol
|
|
503
|
-
* allow-list — see markdown.ts). Long replies collapse by clamping the
|
|
504
|
-
* rendered block height instead of slicing the source, so half-cut code
|
|
505
|
-
* fences or tables never leak malformed markup into the DOM. User
|
|
506
|
-
* messages stay plain text (CollapsibleText).
|
|
507
|
-
*/
|
|
508
|
-
function MarkdownText({ text, pending }) {
|
|
509
|
-
const [open, setOpen] = useState(false);
|
|
510
|
-
const [html, setHtml] = useState(() => renderMarkdown(text));
|
|
511
|
-
/** Text of the last render actually applied to `html`. */
|
|
512
|
-
const renderedTextRef = useRef(text);
|
|
513
|
-
/** Newest streamed text, read by the trailing render at fire time. */
|
|
514
|
-
const latestTextRef = useRef(text);
|
|
515
|
-
/** Timestamp of the last applied parse (throttle window start). */
|
|
516
|
-
const lastRenderAtRef = useRef(performance.now());
|
|
517
|
-
const timerRef = useRef(undefined);
|
|
518
|
-
// Throttled parse for a live stream: skip parses while the newest text is
|
|
519
|
-
// already rendered, parse immediately once the throttle window elapsed,
|
|
520
|
-
// otherwise schedule one trailing render that picks up the newest text.
|
|
521
|
-
useEffect(() => {
|
|
522
|
-
latestTextRef.current = text;
|
|
523
|
-
if (!pending) {
|
|
524
|
-
// Turn closed: terminal messages are never throttled, so cancel any
|
|
525
|
-
// scheduled stream render and parse the final text immediately.
|
|
526
|
-
if (timerRef.current !== undefined) {
|
|
527
|
-
clearTimeout(timerRef.current);
|
|
528
|
-
timerRef.current = undefined;
|
|
529
|
-
}
|
|
530
|
-
if (text === renderedTextRef.current)
|
|
531
|
-
return;
|
|
532
|
-
lastRenderAtRef.current = performance.now();
|
|
533
|
-
renderedTextRef.current = text;
|
|
534
|
-
setHtml(renderMarkdown(text));
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
if (text === renderedTextRef.current)
|
|
538
|
-
return;
|
|
539
|
-
const elapsed = performance.now() - lastRenderAtRef.current;
|
|
540
|
-
if (elapsed >= STREAM_RENDER_INTERVAL_MS) {
|
|
541
|
-
lastRenderAtRef.current = performance.now();
|
|
542
|
-
renderedTextRef.current = text;
|
|
543
|
-
setHtml(renderMarkdown(text));
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
if (timerRef.current === undefined) {
|
|
547
|
-
timerRef.current = setTimeout(() => {
|
|
548
|
-
timerRef.current = undefined;
|
|
549
|
-
lastRenderAtRef.current = performance.now();
|
|
550
|
-
renderedTextRef.current = latestTextRef.current;
|
|
551
|
-
setHtml(renderMarkdown(latestTextRef.current));
|
|
552
|
-
}, STREAM_RENDER_INTERVAL_MS - elapsed);
|
|
553
|
-
}
|
|
554
|
-
}, [text, pending]);
|
|
555
|
-
// Cancel the trailing stream render if the row unmounts mid-stream.
|
|
556
|
-
useEffect(() => {
|
|
557
|
-
return () => {
|
|
558
|
-
if (timerRef.current !== undefined)
|
|
559
|
-
clearTimeout(timerRef.current);
|
|
560
|
-
};
|
|
561
|
-
}, []);
|
|
562
|
-
const long = !pending && text.length > LONG_TEXT_LIMIT;
|
|
563
|
-
const collapsed = long && !open;
|
|
564
|
-
return (_jsxs("div", { className: 'chat-msg-text chat-md' + (collapsed ? ' chat-md-collapsed' : ''), children: [_jsx("div", { className: "chat-md-body", dangerouslySetInnerHTML: { __html: html } }), long && (_jsx("button", { type: "button", className: "chat-msg-toggle", onClick: () => { setOpen(value => !value); }, children: open ? '收起' : '展开全文(' + text.length + ' 字)' }))] }));
|
|
565
|
-
}
|
|
566
|
-
/** Long assistant text collapses behind an explicit expand toggle. */
|
|
567
|
-
function CollapsibleText({ text }) {
|
|
568
|
-
const [open, setOpen] = useState(false);
|
|
569
|
-
if (text.length <= LONG_TEXT_LIMIT) {
|
|
570
|
-
return _jsx("span", { className: "chat-msg-text", children: text });
|
|
571
|
-
}
|
|
572
|
-
const shown = open ? text : text.slice(0, LONG_TEXT_PREVIEW);
|
|
573
|
-
return (_jsxs("span", { className: "chat-msg-text", children: [shown, !open ? '…' : '', _jsx("button", { type: "button", className: "chat-msg-toggle", onClick: () => { setOpen(value => !value); }, children: open ? '收起' : `展开全文(${text.length} 字)` })] }));
|
|
574
|
-
}
|
|
575
|
-
export const LONG_TEXT_LIMIT = 6000;
|
|
576
|
-
const LONG_TEXT_PREVIEW = 800;
|
|
577
|
-
/** Latest non-empty line of a streaming reasoning buffer. */
|
|
578
|
-
function lastLine(text) {
|
|
579
|
-
const trimmed = text.trimEnd();
|
|
580
|
-
if (trimmed === '')
|
|
581
|
-
return '';
|
|
582
|
-
const newline = trimmed.lastIndexOf('\n');
|
|
583
|
-
const line = newline === -1 ? trimmed : trimmed.slice(newline + 1);
|
|
584
|
-
return line.trim() === '' ? '' : line;
|
|
585
|
-
}
|
|
586
|
-
/* ── bottom sheets ───────────────────────────────────────────────────── */
|
|
587
|
-
/** Shared bottom-sheet chrome (backdrop + slide-up panel). */
|
|
588
|
-
function Sheet({ title, onClose, children }) {
|
|
589
|
-
return (_jsx("div", { className: "sheet-backdrop", onClick: onClose, children: _jsxs("div", { className: "sheet", role: "dialog", "aria-modal": "true", "aria-label": title, onClick: (event) => { event.stopPropagation(); }, children: [_jsx("div", { className: "sheet-handle", "aria-hidden": true }), _jsx("div", { className: "sheet-title", children: title }), _jsx("div", { className: "sheet-body", children: children })] }) }));
|
|
590
|
-
}
|
|
591
|
-
/** The model + thinking-effort picker (fresh advisory directory per open). */
|
|
592
|
-
function ModelSheet({ sessionId, current, onCurrent, onClose }) {
|
|
593
|
-
const [state, setState] = useState({ status: 'loading' });
|
|
594
|
-
const [busy, setBusy] = useState(false);
|
|
595
|
-
const [error, setError] = useState(undefined);
|
|
596
|
-
const load = useCallback(() => {
|
|
597
|
-
setState({ status: 'loading' });
|
|
598
|
-
void models(sessionId).then(data => { setState({ status: 'ready', data }); }, (reason) => { setState({ status: 'error', message: errorText(reason) }); });
|
|
599
|
-
}, [sessionId]);
|
|
600
|
-
useEffect(() => { load(); }, [load]);
|
|
601
|
-
/** Select model/effort and close on success (one-shot action per sheet). */
|
|
602
|
-
const apply = useCallback((selection) => {
|
|
603
|
-
if (busy)
|
|
604
|
-
return;
|
|
605
|
-
setBusy(true);
|
|
606
|
-
setError(undefined);
|
|
607
|
-
void selectModel(sessionId, selection).then((result) => {
|
|
608
|
-
setBusy(false);
|
|
609
|
-
onCurrent(result.selected);
|
|
610
|
-
onClose();
|
|
611
|
-
}, (reason) => {
|
|
612
|
-
setBusy(false);
|
|
613
|
-
setError(errorText(reason));
|
|
614
|
-
});
|
|
615
|
-
}, [busy, sessionId, onCurrent, onClose]);
|
|
616
|
-
if (state.status === 'loading') {
|
|
617
|
-
return (_jsx(Sheet, { title: "\u6A21\u578B\u4E0E\u601D\u8003\u5F3A\u5EA6", onClose: onClose, children: _jsx("div", { className: "sheet-status", children: "\u6B63\u5728\u52A0\u8F7D\u6A21\u578B\u76EE\u5F55\u2026" }) }));
|
|
618
|
-
}
|
|
619
|
-
if (state.status === 'error') {
|
|
620
|
-
return (_jsx(Sheet, { title: "\u6A21\u578B\u4E0E\u601D\u8003\u5F3A\u5EA6", onClose: onClose, children: _jsxs("div", { className: "sheet-status sheet-status-error", children: [_jsx("span", { children: state.message }), staleHostHint(state.message) !== undefined && _jsx("span", { className: "sheet-hint", children: staleHostHint(state.message) }), _jsx("button", { type: "button", className: "chat-load-older", onClick: load, children: "\u91CD\u8BD5" })] }) }));
|
|
621
|
-
}
|
|
622
|
-
const { data } = state;
|
|
623
|
-
const selected = current ?? data.current;
|
|
624
|
-
const choices = data.groups.flatMap(group => group.models.map(model => ({ group, model })));
|
|
625
|
-
const currentChoice = choices.find(choice => choice.group.id === selected.provider && choice.model.id === selected.model);
|
|
626
|
-
const reasoning = currentChoice?.model.reasoning;
|
|
627
|
-
const effectiveEffort = selected.reasoningEffort ?? reasoning?.defaultEffort;
|
|
628
|
-
const effortChoices = reasoning === undefined
|
|
629
|
-
? []
|
|
630
|
-
: [
|
|
631
|
-
...(reasoning.defaultEffort === undefined
|
|
632
|
-
? [{ key: 'provider-default', effort: undefined, label: '跟随模型默认' }]
|
|
633
|
-
: []),
|
|
634
|
-
...reasoning.efforts.map(effort => ({
|
|
635
|
-
key: `effort:${effort.id}`,
|
|
636
|
-
effort: effort.id,
|
|
637
|
-
label: effort.name,
|
|
638
|
-
description: effort.description,
|
|
639
|
-
})),
|
|
640
|
-
];
|
|
641
|
-
return (_jsxs(Sheet, { title: "\u6A21\u578B\u4E0E\u601D\u8003\u5F3A\u5EA6", onClose: onClose, children: [error !== undefined && _jsx("p", { className: "sheet-error", children: error }), error !== undefined && staleHostHint(error) !== undefined && _jsx("p", { className: "sheet-hint", children: staleHostHint(error) }), data.failures.map(failure => (_jsxs("p", { className: "sheet-error", children: [failure.name, ": ", failure.message] }, failure.id))), data.groups.length === 0 && choices.length === 0 && (_jsx("div", { className: "sheet-status", children: "\u6CA1\u6709\u53EF\u7528\u7684\u6A21\u578B" })), data.groups.map(group => (_jsxs("div", { className: "sheet-section", children: [_jsx("div", { className: "sheet-section-title", children: group.name }), group.models.map(model => {
|
|
642
|
-
const isSelected = selected.provider === group.id && selected.model === model.id;
|
|
643
|
-
return (_jsxs("button", { type: "button", className: `sheet-option${isSelected ? ' sheet-option-selected' : ''}`, disabled: busy, onClick: () => {
|
|
644
|
-
apply({
|
|
645
|
-
provider: group.id,
|
|
646
|
-
model: model.id,
|
|
647
|
-
...(model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: model.reasoning.defaultEffort }),
|
|
648
|
-
});
|
|
649
|
-
}, children: [_jsxs("span", { className: "sheet-option-copy", children: [_jsx("span", { className: "sheet-option-title", children: model.name }), model.description !== undefined && _jsx("span", { className: "sheet-option-desc", children: model.description })] }), isSelected && _jsx("span", { className: "sheet-option-check", "aria-hidden": true, children: "\u221A" })] }, model.id));
|
|
650
|
-
})] }, group.id))), effortChoices.length > 0 && (_jsxs("div", { className: "sheet-section", children: [_jsx("div", { className: "sheet-section-title", children: "\u601D\u8003\u5F3A\u5EA6" }), effortChoices.map(choice => {
|
|
651
|
-
const isSelected = effectiveEffort === choice.effort;
|
|
652
|
-
return (_jsxs("button", { type: "button", className: `sheet-option${isSelected ? ' sheet-option-selected' : ''}`, disabled: busy, onClick: () => { apply({ provider: selected.provider, model: selected.model, ...(choice.effort !== undefined ? { reasoningEffort: choice.effort } : {}) }); }, children: [_jsx("span", { className: "sheet-option-copy", children: _jsx("span", { className: "sheet-option-title", children: choice.label }) }), isSelected && _jsx("span", { className: "sheet-option-check", "aria-hidden": true, children: "\u221A" })] }, choice.key));
|
|
653
|
-
})] }))] }));
|
|
654
|
-
}
|
|
655
|
-
/** The permission-preset picker; full access needs an explicit confirm. */
|
|
656
|
-
function PermissionSheet({ sessionId, value, onChanged, onClose }) {
|
|
657
|
-
const [confirming, setConfirming] = useState(null);
|
|
658
|
-
const [busy, setBusy] = useState(false);
|
|
659
|
-
const [error, setError] = useState(undefined);
|
|
660
|
-
/** Submit `/permission <value>` as a slash command (mode-agnostic). */
|
|
661
|
-
const submit = useCallback((next) => {
|
|
662
|
-
if (busy)
|
|
663
|
-
return;
|
|
664
|
-
setBusy(true);
|
|
665
|
-
setError(undefined);
|
|
666
|
-
void sendCommand(sessionId, `/permission ${next}`).then(() => {
|
|
667
|
-
setBusy(false);
|
|
668
|
-
setConfirming(null);
|
|
669
|
-
onChanged(next);
|
|
670
|
-
onClose();
|
|
671
|
-
}, (reason) => {
|
|
672
|
-
setBusy(false);
|
|
673
|
-
setConfirming(null);
|
|
674
|
-
setError(errorText(reason));
|
|
675
|
-
});
|
|
676
|
-
}, [busy, sessionId, onChanged, onClose]);
|
|
677
|
-
const choose = (next) => {
|
|
678
|
-
if (next === value.currentValue) {
|
|
679
|
-
onClose();
|
|
680
|
-
return;
|
|
681
|
-
}
|
|
682
|
-
if (next === 'danger-full-access') {
|
|
683
|
-
setConfirming(next);
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
submit(next);
|
|
687
|
-
};
|
|
688
|
-
if (confirming !== null) {
|
|
689
|
-
return (_jsxs(Sheet, { title: "\u786E\u8BA4\u5B8C\u5168\u6743\u9650", onClose: () => { setConfirming(null); }, children: [_jsx("p", { className: "sheet-confirm-desc", children: "\u5F00\u542F\u5B8C\u5168\u6743\u9650\u540E\uFF0C\u8FDC\u7A0B\u4F1A\u8BDD\u53EF\u4EE5\u5728\u5DE5\u4F5C\u533A\u5185\u6267\u884C\u4EFB\u610F\u64CD\u4F5C\uFF08\u5305\u62EC\u8FD0\u884C\u547D\u4EE4\u3001\u4FEE\u6539\u6240\u6709\u6587\u4EF6\u4E0E\u8BBF\u95EE\u51ED\u8BC1\uFF09\u3002 \u4EC5\u5728\u60A8\u4FE1\u4EFB\u5F53\u524D\u8BBE\u5907\u548C\u7F51\u7EDC\u65F6\u5F00\u542F\u3002" }), error !== undefined && _jsx("p", { className: "sheet-error", children: error }), _jsxs("div", { className: "sheet-confirm-actions", children: [_jsx("button", { type: "button", className: "mobile-button", disabled: busy, onClick: () => { setConfirming(null); }, children: "\u53D6\u6D88" }), _jsx("button", { type: "button", className: "sheet-confirm-danger", disabled: busy, onClick: () => { submit(confirming); }, children: busy ? '提交中…' : '确认开启' })] })] }));
|
|
690
|
-
}
|
|
691
|
-
return (_jsxs(Sheet, { title: "\u6743\u9650", onClose: onClose, children: [error !== undefined && _jsx("p", { className: "sheet-error", children: error }), value.options.map(option => {
|
|
692
|
-
const isSelected = option.value === value.currentValue;
|
|
693
|
-
return (_jsxs("button", { type: "button", className: `sheet-option${isSelected ? ' sheet-option-selected' : ''}`, disabled: busy, onClick: () => { choose(option.value); }, children: [_jsxs("span", { className: "sheet-option-copy", children: [_jsx("span", { className: "sheet-option-title", children: option.name }), option.description !== undefined && _jsx("span", { className: "sheet-option-desc", children: option.description })] }), isSelected && _jsx("span", { className: "sheet-option-check", "aria-hidden": true, children: "\u221A" })] }, option.value));
|
|
694
|
-
})] }));
|
|
695
|
-
}
|
|
696
|
-
/** The display-options sheet: tool calls and injected system messages toggles. */
|
|
697
|
-
function DisplaySheet({ showToolCalls, showSystemMessages, onToolCalls, onSystemMessages, onClose }) {
|
|
698
|
-
return (_jsx(Sheet, { title: "\u663E\u793A", onClose: onClose, children: _jsxs("div", { role: "group", "aria-label": "\u663E\u793A\u9009\u9879", children: [_jsxs("div", { className: "sheet-toggle-row", children: [_jsxs("div", { className: "sheet-toggle-copy", children: [_jsx("span", { className: "sheet-toggle-title", children: "\u5DE5\u5177\u8C03\u7528" }), _jsx("span", { className: "sheet-toggle-desc", children: "\u663E\u793A\u52A9\u624B\u4F7F\u7528\u7684\u5DE5\u5177\u8C03\u7528" })] }), _jsx("button", { type: "button", role: "switch", "aria-label": "\u5DE5\u5177\u8C03\u7528", "aria-checked": showToolCalls, className: `sheet-toggle-switch${showToolCalls ? ' sheet-toggle-switch-on' : ''}`, onClick: () => { onToolCalls(!showToolCalls); }, children: _jsx("span", { className: "sheet-toggle-switch-knob", "aria-hidden": true }) })] }), _jsxs("div", { className: "sheet-toggle-row", children: [_jsxs("div", { className: "sheet-toggle-copy", children: [_jsx("span", { className: "sheet-toggle-title", children: "\u7CFB\u7EDF\u63D0\u793A\u8BCD" }), _jsx("span", { className: "sheet-toggle-desc", children: "\u663E\u793A\u6CE8\u5165\u5230\u5BF9\u8BDD\u4E2D\u7684\u7CFB\u7EDF\u6D88\u606F" })] }), _jsx("button", { type: "button", role: "switch", "aria-label": "\u7CFB\u7EDF\u63D0\u793A\u8BCD", "aria-checked": showSystemMessages, className: `sheet-toggle-switch${showSystemMessages ? ' sheet-toggle-switch-on' : ''}`, onClick: () => { onSystemMessages(!showSystemMessages); }, children: _jsx("span", { className: "sheet-toggle-switch-knob", "aria-hidden": true }) })] })] }) }));
|
|
699
|
-
}
|
|
700
|
-
/* ── approval / question panels (#1025) ──────────────────────────────── */
|
|
701
|
-
/** One pending tool approval card with allow/reject actions. */
|
|
702
|
-
function ApprovalPanel({ approval, sessionId, onResolved }) {
|
|
703
|
-
const [busy, setBusy] = useState(false);
|
|
704
|
-
const [panelError, setPanelError] = useState(undefined);
|
|
705
|
-
const act = (outcome) => {
|
|
706
|
-
if (busy)
|
|
707
|
-
return;
|
|
708
|
-
setBusy(true);
|
|
709
|
-
setPanelError(undefined);
|
|
710
|
-
void respondApproval(sessionId, approval.approvalId, outcome).then(() => { onResolved(approval.approvalId); }, (reason) => {
|
|
711
|
-
setBusy(false);
|
|
712
|
-
setPanelError(reason instanceof Error ? reason.message : String(reason));
|
|
713
|
-
});
|
|
714
|
-
};
|
|
715
|
-
return (_jsxs("div", { className: "chat-approval-panel", role: "alert", children: [_jsxs("div", { className: "chat-approval-header", children: [_jsx("span", { className: "chat-tool-pill", children: approval.toolName }), approval.reason !== undefined && (_jsx("span", { className: "chat-approval-reason", children: approval.reason }))] }), panelError !== undefined && _jsx("p", { className: "chat-approval-error", children: panelError }), _jsxs("div", { className: "chat-approval-actions", children: [_jsx("button", { type: "button", className: "chat-approval-allow", disabled: busy, onClick: () => { act('allowed-once'); }, children: busy ? '提交中…' : '允许一次' }), _jsx("button", { type: "button", className: "chat-approval-reject", disabled: busy, onClick: () => { act('rejected'); }, children: "\u62D2\u7EDD" })] })] }));
|
|
716
|
-
}
|
|
717
|
-
/** Question panel: renders one or more questions with option pickers and a submit button. */
|
|
718
|
-
function QuestionPanel({ questions, sessionId, onResolved }) {
|
|
719
|
-
const [selections, setSelections] = useState(() => new Map(questions.map(q => [q.id, { selected: [], custom: '' }])));
|
|
720
|
-
const [busy, setBusy] = useState(false);
|
|
721
|
-
const [panelError, setPanelError] = useState(undefined);
|
|
722
|
-
const toggle = (questionId, label, multi) => {
|
|
723
|
-
setSelections(previous => {
|
|
724
|
-
const next = new Map(previous);
|
|
725
|
-
const entry = next.get(questionId) ?? { selected: [], custom: '' };
|
|
726
|
-
if (multi) {
|
|
727
|
-
const set = new Set(entry.selected);
|
|
728
|
-
if (set.has(label))
|
|
729
|
-
set.delete(label);
|
|
730
|
-
else
|
|
731
|
-
set.add(label);
|
|
732
|
-
next.set(questionId, { ...entry, selected: [...set] });
|
|
733
|
-
}
|
|
734
|
-
else {
|
|
735
|
-
next.set(questionId, { ...entry, selected: [label] });
|
|
736
|
-
}
|
|
737
|
-
return next;
|
|
738
|
-
});
|
|
739
|
-
};
|
|
740
|
-
const setCustom = (questionId, value) => {
|
|
741
|
-
setSelections(previous => {
|
|
742
|
-
const next = new Map(previous);
|
|
743
|
-
const entry = next.get(questionId) ?? { selected: [], custom: '' };
|
|
744
|
-
next.set(questionId, { ...entry, custom: value });
|
|
745
|
-
return next;
|
|
746
|
-
});
|
|
747
|
-
};
|
|
748
|
-
const submit = () => {
|
|
749
|
-
if (busy)
|
|
750
|
-
return;
|
|
751
|
-
setBusy(true);
|
|
752
|
-
setPanelError(undefined);
|
|
753
|
-
const answers = questions.map(q => {
|
|
754
|
-
const entry = selections.get(q.id) ?? { selected: [], custom: '' };
|
|
755
|
-
return {
|
|
756
|
-
id: q.id,
|
|
757
|
-
selected: entry.selected,
|
|
758
|
-
...(entry.custom.trim() !== '' ? { custom: entry.custom.trim() } : {}),
|
|
759
|
-
};
|
|
760
|
-
});
|
|
761
|
-
void respondQuestion(sessionId, answers).then(() => { onResolved(); }, (reason) => {
|
|
762
|
-
setBusy(false);
|
|
763
|
-
setPanelError(reason instanceof Error ? reason.message : String(reason));
|
|
764
|
-
});
|
|
765
|
-
};
|
|
766
|
-
return (_jsxs("div", { className: "chat-question-panel", role: "form", "aria-label": "\u95EE\u9898", children: [questions.map(q => {
|
|
767
|
-
const entry = selections.get(q.id) ?? { selected: [], custom: '' };
|
|
768
|
-
return (_jsxs("div", { className: "chat-question-group", children: [q.header !== undefined && _jsx("div", { className: "chat-question-header", children: q.header }), _jsx("div", { className: "chat-question-text", children: q.question }), q.detail !== undefined && _jsx("div", { className: "chat-question-detail", children: q.detail }), q.options !== undefined && q.options.length > 0 && (_jsx("div", { className: "chat-question-options", role: "group", "aria-label": q.question, children: q.options.map(option => {
|
|
769
|
-
const checked = entry.selected.includes(option.label);
|
|
770
|
-
return (_jsxs("label", { className: `chat-question-option${checked ? ' chat-question-option-selected' : ''}`, children: [_jsx("input", { type: q.multiSelect ? 'checkbox' : 'radio', name: `q-${q.id}`, checked: checked, onChange: () => { toggle(q.id, option.label, q.multiSelect === true); } }), _jsx("span", { className: "chat-question-option-label", children: option.label }), option.description !== undefined && (_jsx("span", { className: "chat-question-option-desc", children: option.description }))] }, option.label));
|
|
771
|
-
}) })), _jsx("textarea", { className: "chat-question-custom", placeholder: "\u81EA\u5B9A\u4E49\u56DE\u7B54\uFF08\u53EF\u9009\uFF09", rows: 2, value: entry.custom, onChange: (e) => { setCustom(q.id, e.target.value); } })] }, q.id));
|
|
772
|
-
}), panelError !== undefined && _jsx("p", { className: "chat-approval-error", children: panelError }), _jsx("button", { type: "button", className: "chat-question-submit", disabled: busy, onClick: submit, children: busy ? '提交中…' : '提交回答' })] }));
|
|
773
|
-
}
|