@h-ai/ui 0.1.0-alpha.18 → 0.1.0-alpha.20

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.
@@ -0,0 +1,112 @@
1
+ const HTML_DOCUMENT_PATTERN = /<\s*(?:!doctype|html|body|head|div|section|article|main|span|style|script|svg|canvas)\b/i;
2
+ const HTML_LANGUAGE_SET = new Set(['html', 'htm', 'xhtml', 'xml', 'svg']);
3
+ const JAVASCRIPT_LANGUAGE_SET = new Set(['javascript', 'js', 'typescript', 'ts']);
4
+ const CSS_LANGUAGE_SET = new Set(['css']);
5
+ /**
6
+ * 根据运行结果构造 iframe sandbox 值。
7
+ *
8
+ * 默认不允许脚本执行;只有显式声明 `allowScripts` 时才放开 `allow-scripts`。
9
+ */
10
+ export function resolvePreviewSandbox(allowScripts) {
11
+ return allowScripts ? 'allow-scripts' : '';
12
+ }
13
+ export function createBuiltInCodePreview(request, options) {
14
+ const language = (request.language ?? '').trim().toLowerCase();
15
+ if (language === 'markdown' || language === 'md') {
16
+ return {
17
+ kind: 'markdown',
18
+ title: options.previewTitle,
19
+ content: request.code,
20
+ };
21
+ }
22
+ if (!options.allowUnsafeCodePreview) {
23
+ return undefined;
24
+ }
25
+ if (HTML_LANGUAGE_SET.has(language) || (!language && looksLikeHtml(request.code))) {
26
+ return {
27
+ kind: 'html',
28
+ title: options.previewTitle,
29
+ content: request.code,
30
+ allowScripts: true,
31
+ };
32
+ }
33
+ if (JAVASCRIPT_LANGUAGE_SET.has(language)) {
34
+ return {
35
+ kind: 'html',
36
+ title: options.previewTitle,
37
+ content: buildJavaScriptPreview(request.code),
38
+ allowScripts: true,
39
+ };
40
+ }
41
+ if (CSS_LANGUAGE_SET.has(language)) {
42
+ return {
43
+ kind: 'html',
44
+ title: options.previewTitle,
45
+ content: buildCssPreview(request.code),
46
+ };
47
+ }
48
+ return undefined;
49
+ }
50
+ function looksLikeHtml(code) {
51
+ return HTML_DOCUMENT_PATTERN.test(code);
52
+ }
53
+ function buildJavaScriptPreview(code) {
54
+ const safeCode = code.replace(/<\/script/gi, '<\\/script');
55
+ return `<!doctype html>
56
+ <html lang="en">
57
+ <body>
58
+ <div id="app"></div>
59
+ <script type="module">
60
+ ${safeCode}
61
+ </script>
62
+ </body>
63
+ </html>`;
64
+ }
65
+ function buildCssPreview(code) {
66
+ const sampleMarkup = [
67
+ '<div class="hai-preview-shell">',
68
+ ' <button class="hai-preview-button">Preview Button</button>',
69
+ ' <div class="hai-preview-card">Preview Card</div>',
70
+ '</div>',
71
+ ].join('\n');
72
+ return `<!doctype html>
73
+ <html lang="en">
74
+ <head>
75
+ <style>
76
+ body {
77
+ margin: 0;
78
+ min-height: 100vh;
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: center;
82
+ background: #f5f5f5;
83
+ font-family: system-ui, sans-serif;
84
+ }
85
+ .hai-preview-shell {
86
+ display: grid;
87
+ gap: 12px;
88
+ padding: 24px;
89
+ }
90
+ .hai-preview-button {
91
+ border: 0;
92
+ border-radius: 999px;
93
+ padding: 10px 16px;
94
+ background: #2563eb;
95
+ color: white;
96
+ font-size: 14px;
97
+ cursor: pointer;
98
+ }
99
+ .hai-preview-card {
100
+ padding: 16px;
101
+ border-radius: 16px;
102
+ background: white;
103
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
104
+ }
105
+ ${code}
106
+ </style>
107
+ </head>
108
+ <body>
109
+ ${sampleMarkup}
110
+ </body>
111
+ </html>`;
112
+ }
@@ -41,6 +41,13 @@ export interface MarkdownCodeRunResult {
41
41
  title?: string;
42
42
  /** Optional preview description. */
43
43
  description?: string;
44
+ /**
45
+ * 预览 iframe 是否允许执行脚本。
46
+ *
47
+ * 默认值为空,组件会以最严格的 sandbox 渲染 HTML 预览;
48
+ * 只有调用方显式声明后才会放开 `allow-scripts`。
49
+ */
50
+ allowScripts?: boolean;
44
51
  }
45
52
  export interface MarkdownRewriteAction {
46
53
  /** Rewrite action id used in callbacks. */
@@ -136,6 +143,13 @@ export interface AiDocumentEditorProps {
136
143
  showRunButton?: boolean;
137
144
  /** Whether to show the code/preview toggle on code blocks. */
138
145
  showCodePreviewToggle?: boolean;
146
+ /**
147
+ * 是否启用内置的高风险代码预览。
148
+ *
149
+ * 默认仅允许 Markdown 预览;HTML / JS / CSS 等会执行或渲染任意代码的内置预览
150
+ * 需要宿主显式开启,或由宿主通过 `oncoderun` 自行提供受控沙箱结果。
151
+ */
152
+ allowUnsafeCodePreview?: boolean;
139
153
  /** Optional helper text displayed beside language when preview toggle is enabled. */
140
154
  codePreviewHint?: string;
141
155
  /** Whether syntax highlighting is enabled. */
@@ -10,6 +10,7 @@
10
10
  -->
11
11
  <script lang='ts'>
12
12
  import type { Size } from '../../../types.js'
13
+ import { writeTextToClipboard } from '../../../internal/browser-safety.js'
13
14
  import { uiM } from '../../../messages.js'
14
15
  import { BareButton, IconButton, Input } from '../../primitives/index.js'
15
16
 
@@ -87,7 +88,7 @@
87
88
 
88
89
  function copyEncrypted() {
89
90
  if (encryptedValue) {
90
- navigator.clipboard.writeText(encryptedValue)
91
+ void writeTextToClipboard(encryptedValue)
91
92
  }
92
93
  }
93
94
  </script>
@@ -10,6 +10,7 @@
10
10
  -->
11
11
  <script lang='ts'>
12
12
  import type { HashDisplayProps } from '../types.js'
13
+ import { writeTextToClipboard } from '../../../internal/browser-safety.js'
13
14
  import { uiM } from '../../../messages.js'
14
15
  import { cn } from '../../../utils.js'
15
16
  import { Badge, IconButton } from '../../primitives/index.js'
@@ -44,16 +45,12 @@
44
45
  if (!value)
45
46
  return
46
47
 
47
- try {
48
- await navigator.clipboard.writeText(value)
48
+ if (await writeTextToClipboard(value)) {
49
49
  copied = true
50
- setTimeout(() => {
50
+ window.setTimeout(() => {
51
51
  copied = false
52
52
  }, 2000)
53
53
  }
54
- catch {
55
- // 复制失败
56
- }
57
54
  }
58
55
  </script>
59
56
 
@@ -10,6 +10,7 @@
10
10
  -->
11
11
  <script lang='ts'>
12
12
  import type { SignatureDisplayProps } from '../types.js'
13
+ import { writeTextToClipboard } from '../../../internal/browser-safety.js'
13
14
  import { uiM } from '../../../messages.js'
14
15
  import { cn } from '../../../utils.js'
15
16
  import { Badge, IconButton } from '../../primitives/index.js'
@@ -46,31 +47,23 @@
46
47
  async function copySignature() {
47
48
  if (!signature)
48
49
  return
49
- try {
50
- await navigator.clipboard.writeText(signature)
50
+ if (await writeTextToClipboard(signature)) {
51
51
  copiedSig = true
52
- setTimeout(() => {
52
+ window.setTimeout(() => {
53
53
  copiedSig = false
54
54
  }, 2000)
55
55
  }
56
- catch {
57
- // 复制失败
58
- }
59
56
  }
60
57
 
61
58
  async function copyPublicKey() {
62
59
  if (!publicKey)
63
60
  return
64
- try {
65
- await navigator.clipboard.writeText(publicKey)
61
+ if (await writeTextToClipboard(publicKey)) {
66
62
  copiedKey = true
67
- setTimeout(() => {
63
+ window.setTimeout(() => {
68
64
  copiedKey = false
69
65
  }, 2000)
70
66
  }
71
- catch {
72
- // 复制失败
73
- }
74
67
  }
75
68
  </script>
76
69
 
@@ -42,14 +42,23 @@
42
42
  ),
43
43
  )
44
44
 
45
+ function getSubmittedValue(formData: FormData, key: string, fallback = ''): string | undefined {
46
+ const submitted = formData.get(key)
47
+ const value = typeof submitted === 'string' ? submitted : fallback
48
+ const trimmed = value.trim()
49
+ return trimmed || undefined
50
+ }
51
+
45
52
  async function handleSubmit(e: SubmitEvent) {
46
53
  e.preventDefault()
47
54
  if (loading || disabled)
48
55
  return
49
56
 
57
+ const submittedFormData = new FormData(e.currentTarget as HTMLFormElement)
58
+
50
59
  const data: ForgotPasswordFormData = {
51
- email: mode === 'email' ? email : undefined,
52
- phone: mode === 'phone' ? phone : undefined,
60
+ email: mode === 'email' ? getSubmittedValue(submittedFormData, 'email', email) : undefined,
61
+ phone: mode === 'phone' ? getSubmittedValue(submittedFormData, 'phone', phone) : undefined,
53
62
  }
54
63
 
55
64
  await onsubmit?.(data)
@@ -101,6 +101,13 @@
101
101
  : ''),
102
102
  )
103
103
 
104
+ function getSubmittedValue(formData: FormData, key: string, fallback = ''): string | undefined {
105
+ const submitted = formData.get(key)
106
+ const value = typeof submitted === 'string' ? submitted : fallback
107
+ const trimmed = value.trim()
108
+ return trimmed || undefined
109
+ }
110
+
104
111
  async function handleSubmit(e: SubmitEvent) {
105
112
  e.preventDefault()
106
113
  if (loading || disabled)
@@ -108,13 +115,15 @@
108
115
  if (!passwordsMatch)
109
116
  return
110
117
 
118
+ const submittedFormData = new FormData(e.currentTarget as HTMLFormElement)
119
+
111
120
  const data: RegisterFormData = {
112
- username,
113
- email,
114
- phone,
121
+ username: getSubmittedValue(submittedFormData, 'username', username),
122
+ email: getSubmittedValue(submittedFormData, 'email', email),
123
+ phone: getSubmittedValue(submittedFormData, 'phone', phone),
115
124
  password,
116
125
  confirmPassword: requireConfirmPassword ? confirmPassword : undefined,
117
- nickname,
126
+ nickname: getSubmittedValue(submittedFormData, 'nickname', nickname),
118
127
  }
119
128
 
120
129
  await onsubmit?.(data)
@@ -13,6 +13,9 @@
13
13
  import { cn } from '../../../utils.js'
14
14
  import BareInput from '../../primitives/BareInput.svelte'
15
15
 
16
+ const SAFE_HTTP_URL_REGEX = /^https?:\/\//i
17
+ const SAFE_RELATIVE_URL_REGEX = /^(?:\/(?!\/)|\.\.?\/)/
18
+
16
19
  let {
17
20
  value = $bindable(''),
18
21
  size = 'lg',
@@ -30,6 +33,7 @@
30
33
 
31
34
  let loading = $state(false)
32
35
  let inputElement = $state<HTMLInputElement | undefined>(undefined)
36
+ let localPreviewUrl: string | null = $state(null)
33
37
 
34
38
  const sizeClass = $derived({
35
39
  'xs': 'w-8 h-8',
@@ -75,6 +79,33 @@
75
79
  return null
76
80
  }
77
81
 
82
+ function isSafePreviewUrl(url: string): boolean {
83
+ return SAFE_HTTP_URL_REGEX.test(url) || SAFE_RELATIVE_URL_REGEX.test(url)
84
+ }
85
+
86
+ function replaceAvatarValue(nextValue: string): void {
87
+ if (localPreviewUrl && localPreviewUrl !== nextValue) {
88
+ URL.revokeObjectURL(localPreviewUrl)
89
+ localPreviewUrl = null
90
+ }
91
+
92
+ if (nextValue.startsWith('blob:')) {
93
+ localPreviewUrl = nextValue
94
+ }
95
+
96
+ value = nextValue
97
+ onchange?.(nextValue)
98
+ }
99
+
100
+ $effect(() => {
101
+ return () => {
102
+ if (localPreviewUrl) {
103
+ URL.revokeObjectURL(localPreviewUrl)
104
+ localPreviewUrl = null
105
+ }
106
+ }
107
+ })
108
+
78
109
  // 上传文件
79
110
  async function uploadFile(file: File) {
80
111
  loading = true
@@ -82,8 +113,7 @@
82
113
  try {
83
114
  if (!uploadUrl && !presignUrl) {
84
115
  // 没有上传地址,创建本地预览
85
- value = URL.createObjectURL(file)
86
- onchange?.(value)
116
+ replaceAvatarValue(URL.createObjectURL(file))
87
117
  return
88
118
  }
89
119
 
@@ -109,6 +139,9 @@
109
139
  }
110
140
 
111
141
  const data = await presignResponse.json()
142
+ if (typeof data?.url !== 'string' || !SAFE_HTTP_URL_REGEX.test(data.url)) {
143
+ throw new Error(uiM('avatar_upload_get_url_failed'))
144
+ }
112
145
  targetUrl = data.url
113
146
  }
114
147
 
@@ -128,19 +161,31 @@
128
161
 
129
162
  // 获取最终 URL
130
163
  let finalUrl = targetUrl!.split('?')[0]
164
+ let responseUrl: string | undefined
131
165
 
132
166
  try {
133
- const data = await response.json()
134
- if (data.url) {
135
- finalUrl = data.url
167
+ const data = await response.clone().json()
168
+ if (typeof data?.url === 'string') {
169
+ responseUrl = data.url
136
170
  }
137
171
  }
138
172
  catch {
139
173
  // 响应不是 JSON
140
174
  }
141
175
 
142
- value = finalUrl
143
- onchange?.(finalUrl)
176
+ if (responseUrl) {
177
+ if (!isSafePreviewUrl(responseUrl)) {
178
+ throw new Error(uiM('avatar_upload_failed'))
179
+ }
180
+
181
+ finalUrl = responseUrl
182
+ }
183
+
184
+ if (!isSafePreviewUrl(finalUrl)) {
185
+ throw new Error(uiM('avatar_upload_failed'))
186
+ }
187
+
188
+ replaceAvatarValue(finalUrl)
144
189
  }
145
190
  catch (error) {
146
191
  const message = error instanceof Error ? error.message : uiM('avatar_upload_failed')
@@ -10,6 +10,7 @@
10
10
  -->
11
11
  <script lang='ts'>
12
12
  import type { FileUploadProps, UploadFile } from '../types.js'
13
+ import { SvelteMap } from 'svelte/reactivity'
13
14
  import { uiM } from '../../../messages.js'
14
15
  import { cn, generateId } from '../../../utils.js'
15
16
  import BareInput from '../../primitives/BareInput.svelte'
@@ -17,6 +18,7 @@
17
18
  import Progress from '../../primitives/Progress.svelte'
18
19
 
19
20
  const SAFE_HTTP_URL_REGEX = /^https?:\/\//i
21
+ const MAX_PARALLEL_UPLOADS = 3
20
22
 
21
23
  const {
22
24
  accept = '*',
@@ -40,6 +42,7 @@
40
42
  let files = $state<UploadFile[]>([])
41
43
  let isDragging = $state(false)
42
44
  let inputElement = $state<HTMLInputElement | undefined>(undefined)
45
+ const activeRequests = new SvelteMap<string, XMLHttpRequest>()
43
46
 
44
47
  const containerClass = $derived(
45
48
  cn(
@@ -95,6 +98,63 @@
95
98
  return null
96
99
  }
97
100
 
101
+ function registerActiveRequest(fileId: string, xhr: XMLHttpRequest): void {
102
+ activeRequests.set(fileId, xhr)
103
+ }
104
+
105
+ function clearActiveRequest(fileId: string, xhr?: XMLHttpRequest): void {
106
+ const current = activeRequests.get(fileId)
107
+ if (!current || (xhr && current !== xhr)) {
108
+ return
109
+ }
110
+
111
+ current.upload.onprogress = null
112
+ current.onload = null
113
+ current.onerror = null
114
+ current.onabort = null
115
+ activeRequests.delete(fileId)
116
+ }
117
+
118
+ function abortActiveRequest(fileId: string): void {
119
+ const current = activeRequests.get(fileId)
120
+ if (!current) {
121
+ return
122
+ }
123
+
124
+ clearActiveRequest(fileId, current)
125
+ current.abort()
126
+ }
127
+
128
+ function abortAllActiveRequests(): void {
129
+ for (const fileId of [...activeRequests.keys()]) {
130
+ abortActiveRequest(fileId)
131
+ }
132
+ }
133
+
134
+ async function uploadPendingFiles(targetFiles: UploadFile[]): Promise<void> {
135
+ const queue = [...targetFiles]
136
+ const workerCount = Math.min(MAX_PARALLEL_UPLOADS, queue.length)
137
+
138
+ await Promise.all(
139
+ Array.from({ length: workerCount }, async () => {
140
+ while (queue.length > 0) {
141
+ const nextFile = queue.shift()
142
+ if (!nextFile) {
143
+ return
144
+ }
145
+
146
+ await uploadFile(nextFile)
147
+ }
148
+ }),
149
+ )
150
+ }
151
+
152
+ $effect(() => {
153
+ return () => {
154
+ abortAllActiveRequests()
155
+ }
156
+ })
157
+
98
158
  // 添加文件
99
159
  async function addFiles(fileList: FileList | null) {
100
160
  if (!fileList)
@@ -129,9 +189,7 @@
129
189
 
130
190
  // 自动上传
131
191
  if (autoUpload) {
132
- for (const file of newFiles.filter(f => f.state === 'pending')) {
133
- await uploadFile(file)
134
- }
192
+ await uploadPendingFiles(newFiles.filter(f => f.state === 'pending'))
135
193
  }
136
194
  }
137
195
 
@@ -146,6 +204,8 @@
146
204
  uploadFile.state = 'uploading'
147
205
  files = [...files]
148
206
 
207
+ let xhr: XMLHttpRequest | undefined
208
+
149
209
  try {
150
210
  let targetUrl = uploadUrl
151
211
 
@@ -176,52 +236,62 @@
176
236
  }
177
237
 
178
238
  // 上传文件
179
- const xhr = new XMLHttpRequest()
239
+ xhr = new XMLHttpRequest()
240
+ registerActiveRequest(uploadFile.id, xhr)
241
+ const activeXhr = xhr
180
242
 
181
243
  await new Promise<void>((resolve, reject) => {
182
- xhr.upload.onprogress = (e) => {
244
+ activeXhr.upload.onprogress = (e) => {
183
245
  if (e.lengthComputable) {
184
246
  uploadFile.progress = Math.round((e.loaded / e.total) * 100)
185
247
  files = [...files]
186
248
  }
187
249
  }
188
250
 
189
- xhr.onload = () => {
190
- if (xhr.status >= 200 && xhr.status < 300) {
251
+ activeXhr.onload = () => {
252
+ if (activeXhr.status >= 200 && activeXhr.status < 300) {
191
253
  uploadFile.state = 'success'
192
254
  uploadFile.progress = 100
193
255
  try {
194
- uploadFile.response = JSON.parse(xhr.responseText)
256
+ uploadFile.response = JSON.parse(activeXhr.responseText)
195
257
  }
196
258
  catch {
197
- uploadFile.response = xhr.responseText
259
+ uploadFile.response = activeXhr.responseText
198
260
  }
199
261
  resolve()
200
262
  }
201
263
  else {
202
- reject(new Error(`${uiM('file_upload_failed')}: ${xhr.statusText}`))
264
+ reject(new Error(`${uiM('file_upload_failed')}: ${activeXhr.statusText}`))
203
265
  }
204
266
  }
205
267
 
206
- xhr.onerror = () => reject(new Error(uiM('file_upload_network_error')))
268
+ activeXhr.onerror = () => reject(new Error(uiM('file_upload_network_error')))
269
+ activeXhr.onabort = () => reject(new DOMException('Upload aborted', 'AbortError'))
207
270
 
208
- xhr.open('PUT', targetUrl!)
209
- xhr.setRequestHeader('Content-Type', uploadFile.type || 'application/octet-stream')
271
+ activeXhr.open('PUT', targetUrl!)
272
+ activeXhr.setRequestHeader('Content-Type', uploadFile.type || 'application/octet-stream')
210
273
 
211
274
  for (const [key, value] of Object.entries(headers)) {
212
- xhr.setRequestHeader(key, value)
275
+ activeXhr.setRequestHeader(key, value)
213
276
  }
214
277
 
215
- xhr.send(uploadFile.file)
278
+ activeXhr.send(uploadFile.file)
216
279
  })
217
280
 
218
281
  onupload?.(uploadFile)
219
282
  }
220
283
  catch (error) {
284
+ if (error instanceof DOMException && error.name === 'AbortError') {
285
+ return
286
+ }
287
+
221
288
  uploadFile.state = 'error'
222
289
  uploadFile.error = error instanceof Error ? error.message : uiM('file_upload_failed')
223
290
  onerror?.(uploadFile.error)
224
291
  }
292
+ finally {
293
+ clearActiveRequest(uploadFile.id, xhr)
294
+ }
225
295
 
226
296
  files = [...files]
227
297
  }
@@ -230,6 +300,7 @@
230
300
  function removeFile(id: string) {
231
301
  const file = files.find(f => f.id === id)
232
302
  if (file) {
303
+ abortActiveRequest(id)
233
304
  files = files.filter(f => f.id !== id)
234
305
  onremove?.(file)
235
306
  onchange?.(files)
@@ -279,13 +350,12 @@
279
350
 
280
351
  /** 手动触发上传 */
281
352
  export async function upload() {
282
- for (const file of files.filter(f => f.state === 'pending')) {
283
- await uploadFile(file)
284
- }
353
+ await uploadPendingFiles(files.filter(f => f.state === 'pending'))
285
354
  }
286
355
 
287
356
  /** 清空文件列表 */
288
357
  export function clear() {
358
+ abortAllActiveRequests()
289
359
  files = []
290
360
  onchange?.(files)
291
361
  }
@@ -5,6 +5,7 @@
5
5
  * @module i18n.svelte
6
6
  */
7
7
  import { core } from '@h-ai/core';
8
+ import { readStoredValue, writeStoredValue } from './internal/browser-safety.js';
8
9
  // 从 core.i18n 解构常用函数
9
10
  const { DEFAULT_LOCALE, DEFAULT_LOCALES, setGlobalLocale, } = core.i18n;
10
11
  function isLocaleSupported(locale, supportedLocales = DEFAULT_LOCALES) {
@@ -56,7 +57,7 @@ export function createLocaleStore(options = {}) {
56
57
  let initialLocale = defaultLocale;
57
58
  if (typeof window !== 'undefined') {
58
59
  // 1. 优先从 localStorage 读取
59
- const savedLocale = localStorage.getItem(persistKey);
60
+ const savedLocale = readStoredValue(persistKey);
60
61
  if (savedLocale && isLocaleSupported(savedLocale, supportedLocales)) {
61
62
  initialLocale = savedLocale;
62
63
  }
@@ -93,7 +94,7 @@ export function createLocaleStore(options = {}) {
93
94
  setGlobalLocale(resolved);
94
95
  // 持久化到 localStorage
95
96
  if (typeof window !== 'undefined') {
96
- localStorage.setItem(persistKey, resolved);
97
+ writeStoredValue(persistKey, resolved);
97
98
  }
98
99
  },
99
100
  /** 检查语言是否支持 */
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @h-ai/ui — 浏览器能力安全辅助
3
+ *
4
+ * 统一封装容易在受限 WebView、隐私模式或不安全上下文中失败的浏览器 API,
5
+ * 让组件在能力缺失时优雅降级而不是抛出运行时错误。
6
+ */
7
+ /**
8
+ * 安全读取 localStorage。
9
+ *
10
+ * 某些浏览器隐私模式或嵌入式 WebView 会在访问 localStorage 时直接抛出 SecurityError,
11
+ * 这里统一吞掉异常并回退为 null。
12
+ */
13
+ export declare function readStoredValue(key: string): string | null;
14
+ /**
15
+ * 安全写入 localStorage。
16
+ *
17
+ * 返回值显式告诉调用方持久化是否成功,方便调用方在失败时继续使用内存态而不必中断交互。
18
+ */
19
+ export declare function writeStoredValue(key: string, value: string): boolean;
20
+ /**
21
+ * 安全写入剪贴板。
22
+ *
23
+ * 在不安全上下文、旧 WebView 或浏览器策略禁用时直接返回 false,
24
+ * 让上层决定是否展示成功反馈。
25
+ */
26
+ export declare function writeTextToClipboard(text: string): Promise<boolean>;