@hirohsu/user-web-feedback 2.6.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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +953 -0
  3. package/dist/cli.cjs +95778 -0
  4. package/dist/index.cjs +92818 -0
  5. package/dist/static/app.js +385 -0
  6. package/dist/static/components/navbar.css +406 -0
  7. package/dist/static/components/navbar.html +49 -0
  8. package/dist/static/components/navbar.js +211 -0
  9. package/dist/static/dashboard.css +495 -0
  10. package/dist/static/dashboard.html +95 -0
  11. package/dist/static/dashboard.js +540 -0
  12. package/dist/static/favicon.svg +27 -0
  13. package/dist/static/index.html +541 -0
  14. package/dist/static/logs.html +376 -0
  15. package/dist/static/logs.js +442 -0
  16. package/dist/static/mcp-settings.html +797 -0
  17. package/dist/static/mcp-settings.js +884 -0
  18. package/dist/static/modules/app-core.js +124 -0
  19. package/dist/static/modules/conversation-panel.js +247 -0
  20. package/dist/static/modules/feedback-handler.js +1420 -0
  21. package/dist/static/modules/image-handler.js +155 -0
  22. package/dist/static/modules/log-viewer.js +296 -0
  23. package/dist/static/modules/mcp-manager.js +474 -0
  24. package/dist/static/modules/prompt-manager.js +364 -0
  25. package/dist/static/modules/settings-manager.js +299 -0
  26. package/dist/static/modules/socket-manager.js +170 -0
  27. package/dist/static/modules/state-manager.js +352 -0
  28. package/dist/static/modules/timer-controller.js +243 -0
  29. package/dist/static/modules/ui-helpers.js +246 -0
  30. package/dist/static/settings.html +355 -0
  31. package/dist/static/settings.js +425 -0
  32. package/dist/static/socket.io.min.js +7 -0
  33. package/dist/static/style.css +2157 -0
  34. package/dist/static/terminals.html +357 -0
  35. package/dist/static/terminals.js +321 -0
  36. package/package.json +91 -0
@@ -0,0 +1,364 @@
1
+ /**
2
+ * prompt-manager.js
3
+ * 提示詞管理模組
4
+ * 包含提示詞 CRUD、渲染、搜尋等功能
5
+ */
6
+
7
+ import {
8
+ getPrompts,
9
+ setPrompts,
10
+ findPromptById,
11
+ isEditingPrompt,
12
+ getEditingPromptId,
13
+ setIsEditingPrompt,
14
+ setEditingPromptId,
15
+ } from "./state-manager.js";
16
+
17
+ import {
18
+ showToast,
19
+ formatApiError,
20
+ escapeHtml,
21
+ updateCharCount,
22
+ } from "./ui-helpers.js";
23
+ import { emitUserActivity } from "./socket-manager.js";
24
+
25
+ /**
26
+ * 載入提示詞列表
27
+ */
28
+ export async function loadPrompts() {
29
+ try {
30
+ const response = await fetch("/api/prompts");
31
+ const data = await response.json();
32
+
33
+ if (data.success) {
34
+ setPrompts(data.prompts);
35
+ renderPrompts();
36
+ }
37
+ } catch (error) {
38
+ console.error("載入提示詞失敗:", error);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * 自動載入釘選提示詞
44
+ */
45
+ export async function autoLoadPinnedPrompts() {
46
+ try {
47
+ const response = await fetch("/api/prompts/pinned");
48
+ const data = await response.json();
49
+
50
+ if (data.success && data.prompts.length > 0) {
51
+ const content = data.prompts.map((p) => p.content).join("\n\n");
52
+ document.getElementById("feedbackText").value = content;
53
+ updateCharCount();
54
+
55
+ showToast(
56
+ "info",
57
+ "提示詞已載入",
58
+ `已自動載入 ${data.prompts.length} 個釘選提示詞`
59
+ );
60
+ }
61
+ } catch (error) {
62
+ console.error("自動載入釘選提示詞失敗:", error);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 獲取釘選提示詞內容
68
+ * @returns {Promise<string>} - 釘選提示詞內容
69
+ */
70
+ export async function getPinnedPromptsContent() {
71
+ try {
72
+ const response = await fetch("/api/prompts/pinned");
73
+ const data = await response.json();
74
+
75
+ if (data.success && data.prompts.length > 0) {
76
+ return data.prompts.map((p) => p.content).join("\n\n");
77
+ }
78
+ return "";
79
+ } catch (error) {
80
+ console.error("獲取釘選提示詞失敗:", error);
81
+ return "";
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 渲染提示詞列表
87
+ * @param {string} searchTerm - 搜尋關鍵字
88
+ */
89
+ export function renderPrompts(searchTerm = "") {
90
+ const listEl = document.getElementById("promptList");
91
+ if (!listEl) return;
92
+
93
+ const prompts = getPrompts();
94
+ let filteredPrompts = prompts;
95
+
96
+ if (searchTerm) {
97
+ filteredPrompts = prompts.filter(
98
+ (p) =>
99
+ p.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
100
+ p.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
101
+ (p.category &&
102
+ p.category.toLowerCase().includes(searchTerm.toLowerCase()))
103
+ );
104
+ }
105
+
106
+ if (filteredPrompts.length === 0) {
107
+ listEl.innerHTML = `
108
+ <div class="placeholder">
109
+ <span class="icon">📋</span>
110
+ <p>${searchTerm ? "找不到符合的提示詞" : "尚無提示詞"}</p>
111
+ <button id="addPromptBtn" class="btn btn-secondary btn-sm" onclick="openPromptModal()">新增提示詞</button>
112
+ </div>
113
+ `;
114
+ return;
115
+ }
116
+
117
+ listEl.innerHTML = filteredPrompts
118
+ .map(
119
+ (prompt) => `
120
+ <div class="prompt-item ${
121
+ prompt.isPinned ? "pinned" : ""
122
+ }" onclick="usePrompt(${prompt.id})">
123
+ <div class="prompt-item-header">
124
+ <div class="prompt-item-title">${escapeHtml(prompt.title)}</div>
125
+ <div class="prompt-item-actions">
126
+ <button class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); togglePinPrompt(${
127
+ prompt.id
128
+ })" title="${prompt.isPinned ? "取消釘選" : "釘選"}">
129
+ <span class="icon">${prompt.isPinned ? "📍" : "📌"}</span>
130
+ </button>
131
+ <button class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); editPrompt(${
132
+ prompt.id
133
+ })" title="編輯">
134
+ <span class="icon">✏️</span>
135
+ </button>
136
+ <button class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); deletePrompt(${
137
+ prompt.id
138
+ })" title="刪除">
139
+ <span class="icon">🗑️</span>
140
+ </button>
141
+ </div>
142
+ </div>
143
+ <div class="prompt-item-content">${escapeHtml(prompt.content)}</div>
144
+ ${
145
+ prompt.category
146
+ ? `
147
+ <div class="prompt-item-footer">
148
+ <span class="prompt-item-category">${escapeHtml(
149
+ prompt.category
150
+ )}</span>
151
+ </div>
152
+ `
153
+ : ""
154
+ }
155
+ </div>
156
+ `
157
+ )
158
+ .join("");
159
+ }
160
+
161
+ /**
162
+ * 過濾提示詞
163
+ */
164
+ export function filterPrompts() {
165
+ const searchTerm = document.getElementById("promptSearch").value;
166
+ renderPrompts(searchTerm);
167
+ }
168
+
169
+ /**
170
+ * 使用提示詞
171
+ * @param {number} id - 提示詞 ID
172
+ */
173
+ export function usePrompt(id) {
174
+ const prompt = findPromptById(id);
175
+ if (!prompt) return;
176
+
177
+ const feedbackText = document.getElementById("feedbackText");
178
+ const currentText = feedbackText.value;
179
+
180
+ if (currentText.trim()) {
181
+ feedbackText.value = currentText + "\n\n" + prompt.content;
182
+ } else {
183
+ feedbackText.value = prompt.content;
184
+ }
185
+
186
+ updateCharCount();
187
+ emitUserActivity();
188
+
189
+ showToast("success", "提示詞已使用", `已插入「${prompt.title}」`);
190
+ }
191
+
192
+ /**
193
+ * 切換提示詞釘選狀態
194
+ * @param {number} id - 提示詞 ID
195
+ */
196
+ export async function togglePinPrompt(id) {
197
+ try {
198
+ const response = await fetch(`/api/prompts/${id}/pin`, {
199
+ method: "PUT",
200
+ });
201
+
202
+ const data = await response.json();
203
+
204
+ if (data.success) {
205
+ await loadPrompts();
206
+ showToast(
207
+ "success",
208
+ "成功",
209
+ data.prompt.isPinned ? "已釘選提示詞" : "已取消釘選"
210
+ );
211
+ } else {
212
+ showToast("error", "錯誤", formatApiError(data));
213
+ }
214
+ } catch (error) {
215
+ console.error("切換釘選狀態失敗:", error);
216
+ showToast("error", "錯誤", "操作失敗");
217
+ }
218
+ }
219
+
220
+ /**
221
+ * 編輯提示詞
222
+ * @param {number} id - 提示詞 ID
223
+ */
224
+ export function editPrompt(id) {
225
+ const prompt = findPromptById(id);
226
+ if (!prompt) return;
227
+
228
+ setIsEditingPrompt(true);
229
+ setEditingPromptId(id);
230
+
231
+ document.getElementById("promptModalTitle").textContent = "編輯提示詞";
232
+ document.getElementById("promptId").value = id;
233
+ document.getElementById("promptTitle").value = prompt.title;
234
+ document.getElementById("promptContent").value = prompt.content;
235
+ document.getElementById("promptCategory").value = prompt.category || "";
236
+ document.getElementById("promptIsPinned").checked = prompt.isPinned;
237
+
238
+ openPromptModal();
239
+ }
240
+
241
+ /**
242
+ * 刪除提示詞
243
+ * @param {number} id - 提示詞 ID
244
+ */
245
+ export async function deletePrompt(id) {
246
+ if (!confirm("確定要刪除此提示詞嗎?")) return;
247
+
248
+ try {
249
+ const response = await fetch(`/api/prompts/${id}`, {
250
+ method: "DELETE",
251
+ });
252
+
253
+ const data = await response.json();
254
+
255
+ if (data.success) {
256
+ await loadPrompts();
257
+ showToast("success", "成功", "提示詞已刪除");
258
+ } else {
259
+ showToast("error", "錯誤", formatApiError(data));
260
+ }
261
+ } catch (error) {
262
+ console.error("刪除提示詞失敗:", error);
263
+ showToast("error", "錯誤", "刪除失敗");
264
+ }
265
+ }
266
+
267
+ /**
268
+ * 開啟提示詞彈窗
269
+ */
270
+ export function openPromptModal() {
271
+ if (!isEditingPrompt()) {
272
+ document.getElementById("promptModalTitle").textContent = "新增提示詞";
273
+ document.getElementById("promptForm").reset();
274
+ document.getElementById("promptId").value = "";
275
+ }
276
+
277
+ document.getElementById("promptModal").classList.add("show");
278
+ }
279
+
280
+ /**
281
+ * 關閉提示詞彈窗
282
+ */
283
+ export function closePromptModal() {
284
+ document.getElementById("promptModal").classList.remove("show");
285
+ setIsEditingPrompt(false);
286
+ setEditingPromptId(null);
287
+ }
288
+
289
+ /**
290
+ * 儲存提示詞
291
+ */
292
+ export async function savePrompt() {
293
+ const title = document.getElementById("promptTitle").value.trim();
294
+ const content = document.getElementById("promptContent").value.trim();
295
+ const category = document.getElementById("promptCategory").value.trim();
296
+ const isPinned = document.getElementById("promptIsPinned").checked;
297
+
298
+ if (!title || !content) {
299
+ showToast("error", "錯誤", "標題和內容為必填欄位");
300
+ return;
301
+ }
302
+
303
+ const promptData = {
304
+ title,
305
+ content,
306
+ category: category || undefined,
307
+ isPinned,
308
+ };
309
+
310
+ try {
311
+ let response;
312
+ const editing = isEditingPrompt();
313
+ const editingId = getEditingPromptId();
314
+
315
+ if (editing && editingId) {
316
+ response = await fetch(`/api/prompts/${editingId}`, {
317
+ method: "PUT",
318
+ headers: { "Content-Type": "application/json" },
319
+ body: JSON.stringify(promptData),
320
+ });
321
+ } else {
322
+ response = await fetch("/api/prompts", {
323
+ method: "POST",
324
+ headers: { "Content-Type": "application/json" },
325
+ body: JSON.stringify(promptData),
326
+ });
327
+ }
328
+
329
+ const data = await response.json();
330
+
331
+ if (data.success) {
332
+ await loadPrompts();
333
+ closePromptModal();
334
+ showToast("success", "成功", editing ? "提示詞已更新" : "提示詞已創建");
335
+ } else {
336
+ showToast("error", "錯誤", formatApiError(data));
337
+ }
338
+ } catch (error) {
339
+ console.error("保存提示詞失敗:", error);
340
+ showToast("error", "錯誤", "保存失敗");
341
+ }
342
+ }
343
+
344
+ // 暴露到 window 供 HTML onclick 使用
345
+ window.usePrompt = usePrompt;
346
+ window.togglePinPrompt = togglePinPrompt;
347
+ window.editPrompt = editPrompt;
348
+ window.deletePrompt = deletePrompt;
349
+ window.openPromptModal = openPromptModal;
350
+
351
+ export default {
352
+ loadPrompts,
353
+ autoLoadPinnedPrompts,
354
+ getPinnedPromptsContent,
355
+ renderPrompts,
356
+ filterPrompts,
357
+ usePrompt,
358
+ togglePinPrompt,
359
+ editPrompt,
360
+ deletePrompt,
361
+ openPromptModal,
362
+ closePromptModal,
363
+ savePrompt,
364
+ };
@@ -0,0 +1,299 @@
1
+ /**
2
+ * settings-manager.js
3
+ * AI 設定與使用者偏好管理模組
4
+ */
5
+
6
+ import {
7
+ getAISettings,
8
+ setAISettings,
9
+ setPreferences,
10
+ setAutoReplyTimerSeconds,
11
+ setMaxToolRounds,
12
+ setDebugMode,
13
+ } from "./state-manager.js";
14
+
15
+ import {
16
+ showToast,
17
+ showLoadingOverlay,
18
+ hideLoadingOverlay,
19
+ } from "./ui-helpers.js";
20
+
21
+ /**
22
+ * 載入 AI 設定
23
+ */
24
+ export async function loadAISettings() {
25
+ try {
26
+ const response = await fetch("/api/ai-settings");
27
+ const data = await response.json();
28
+
29
+ if (data.success) {
30
+ setAISettings(data.settings);
31
+
32
+ // 讀取自動回覆計時器秒數設定
33
+ if (data.settings.autoReplyTimerSeconds !== undefined) {
34
+ setAutoReplyTimerSeconds(data.settings.autoReplyTimerSeconds);
35
+ console.log(
36
+ `從 AI 設定讀取自動回覆時間: ${data.settings.autoReplyTimerSeconds}s`
37
+ );
38
+ }
39
+
40
+ // 讀取 AI 交談次數上限
41
+ if (data.settings.maxToolRounds !== undefined) {
42
+ setMaxToolRounds(data.settings.maxToolRounds);
43
+ console.log(
44
+ `從 AI 設定讀取 AI 交談次數: ${data.settings.maxToolRounds}`
45
+ );
46
+ }
47
+
48
+ // 讀取 Debug 模式
49
+ if (data.settings.debugMode !== undefined) {
50
+ setDebugMode(data.settings.debugMode);
51
+ console.log(`從 AI 設定讀取 Debug 模式: ${data.settings.debugMode}`);
52
+ }
53
+ }
54
+ } catch (error) {
55
+ console.error("載入 AI 設定失敗:", error);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * 載入使用者偏好
61
+ */
62
+ export async function loadPreferences() {
63
+ try {
64
+ const response = await fetch("/api/preferences");
65
+ const data = await response.json();
66
+
67
+ if (data.success) {
68
+ setPreferences(data.preferences);
69
+ }
70
+ } catch (error) {
71
+ console.error("載入使用者偏好失敗:", error);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * 開啟 AI 設定彈窗
77
+ */
78
+ export function openAISettingsModal() {
79
+ const aiSettings = getAISettings();
80
+ if (aiSettings) {
81
+ document.getElementById("apiUrl").value = aiSettings.apiUrl || "";
82
+ document.getElementById("model").value = aiSettings.model || "";
83
+ // API Key 欄位預設為空,不從資料庫讀取
84
+ document.getElementById("apiKey").value = "";
85
+ document.getElementById("apiKey").placeholder = "留空則保留原有 API Key";
86
+ document.getElementById("systemPrompt").value =
87
+ aiSettings.systemPrompt || "";
88
+ document.getElementById("mcpToolsPrompt").value =
89
+ aiSettings.mcpToolsPrompt || "";
90
+ document.getElementById("temperature").value =
91
+ aiSettings.temperature || 0.7;
92
+ document.getElementById("maxTokens").value = aiSettings.maxTokens || 1000;
93
+ document.getElementById("autoReplyTimerSeconds").value =
94
+ aiSettings.autoReplyTimerSeconds || 300;
95
+ document.getElementById("maxToolRounds").value =
96
+ aiSettings.maxToolRounds || 5;
97
+ document.getElementById("debugMode").checked =
98
+ aiSettings.debugMode || false;
99
+ }
100
+
101
+ document.getElementById("aiSettingsModal").classList.add("show");
102
+ }
103
+
104
+ /**
105
+ * 關閉 AI 設定彈窗
106
+ */
107
+ export function closeAISettingsModal() {
108
+ document.getElementById("aiSettingsModal").classList.remove("show");
109
+ }
110
+
111
+ /**
112
+ * 儲存 AI 設定
113
+ */
114
+ export async function saveAISettings() {
115
+ const apiUrl = document.getElementById("apiUrl").value.trim();
116
+ const model = document.getElementById("model").value.trim();
117
+ const apiKey = document.getElementById("apiKey").value.trim();
118
+ const systemPrompt = document.getElementById("systemPrompt").value.trim();
119
+ const mcpToolsPrompt = document.getElementById("mcpToolsPrompt").value.trim();
120
+ const temperature = parseFloat(document.getElementById("temperature").value);
121
+ const maxTokens = parseInt(document.getElementById("maxTokens").value);
122
+ const autoReplyTimerSeconds = parseInt(
123
+ document.getElementById("autoReplyTimerSeconds").value
124
+ );
125
+ const maxToolRoundsValue = parseInt(
126
+ document.getElementById("maxToolRounds").value
127
+ );
128
+ const debugModeValue = document.getElementById("debugMode").checked;
129
+
130
+ const settingsData = {
131
+ apiUrl: apiUrl || undefined,
132
+ model: model || undefined,
133
+ systemPrompt: systemPrompt || undefined,
134
+ mcpToolsPrompt: mcpToolsPrompt || undefined,
135
+ temperature,
136
+ maxTokens,
137
+ autoReplyTimerSeconds,
138
+ maxToolRounds: maxToolRoundsValue,
139
+ debugMode: debugModeValue,
140
+ };
141
+
142
+ // 只有當 API Key 不是遮罩格式且不為空時才更新
143
+ if (apiKey && !apiKey.startsWith("***")) {
144
+ settingsData.apiKey = apiKey;
145
+ }
146
+
147
+ try {
148
+ const response = await fetch("/api/ai-settings", {
149
+ method: "PUT",
150
+ headers: { "Content-Type": "application/json" },
151
+ body: JSON.stringify(settingsData),
152
+ });
153
+
154
+ let data;
155
+ try {
156
+ data = await response.json();
157
+ } catch (e) {
158
+ // 不是 JSON 回應,讀取純文字
159
+ const text = await response.text();
160
+ console.error("非 JSON 回應:", text);
161
+ showToast("error", "錯誤", `儲存失敗:${text}`);
162
+ return;
163
+ }
164
+
165
+ if (data && data.success) {
166
+ setAISettings(data.settings);
167
+
168
+ // 更新自動回覆計時器秒數
169
+ if (data.settings.autoReplyTimerSeconds !== undefined) {
170
+ setAutoReplyTimerSeconds(data.settings.autoReplyTimerSeconds);
171
+ console.log(
172
+ `自動回覆時間已更新為: ${data.settings.autoReplyTimerSeconds}s`
173
+ );
174
+ }
175
+
176
+ // 更新 AI 交談次數上限
177
+ if (data.settings.maxToolRounds !== undefined) {
178
+ setMaxToolRounds(data.settings.maxToolRounds);
179
+ console.log(`AI 交談次數已更新為: ${data.settings.maxToolRounds}`);
180
+ }
181
+
182
+ // 更新 Debug 模式
183
+ if (data.settings.debugMode !== undefined) {
184
+ setDebugMode(data.settings.debugMode);
185
+ console.log(`Debug 模式已更新為: ${data.settings.debugMode}`);
186
+ }
187
+
188
+ closeAISettingsModal();
189
+ showToast("success", "成功", "AI 設定已儲存");
190
+ } else {
191
+ // 儘可能顯示詳細錯誤資訊
192
+ const detailParts = [];
193
+ if (data.error) detailParts.push(data.error);
194
+ if (data.details)
195
+ detailParts.push(
196
+ typeof data.details === "string"
197
+ ? data.details
198
+ : JSON.stringify(data.details)
199
+ );
200
+ if (data.stack) detailParts.push(data.stack);
201
+ const message = detailParts.join(" \n ");
202
+ console.error("儲存 AI 設定失敗:", data);
203
+ showToast("error", "錯誤", message || "儲存 AI 設定失敗");
204
+ }
205
+ } catch (error) {
206
+ console.error("儲存 AI 設定失敗:", error);
207
+ // 如果有 response 物件,可嘗試讀取更多內容
208
+ if (error && error.response) {
209
+ try {
210
+ const text = await error.response.text();
211
+ showToast("error", "錯誤", `儲存失敗:${text}`);
212
+ return;
213
+ } catch (e) {
214
+ // ignore
215
+ }
216
+ }
217
+
218
+ showToast(
219
+ "error",
220
+ "錯誤",
221
+ error instanceof Error ? error.message : "儲存失敗"
222
+ );
223
+ }
224
+ }
225
+
226
+ /**
227
+ * 測試 API Key
228
+ */
229
+ export async function testAPIKey() {
230
+ const apiKeyInput = document.getElementById("apiKey").value.trim();
231
+ const model = document.getElementById("model").value.trim();
232
+
233
+ if (!model) {
234
+ showToast("error", "錯誤", "請輸入模型名稱");
235
+ return;
236
+ }
237
+
238
+ showLoadingOverlay("正在測試 API Key...");
239
+
240
+ try {
241
+ const requestBody = { model };
242
+
243
+ // 判斷是否使用新輸入的 API Key:
244
+ // 1. API Key 不為空
245
+ // 2. API Key 不是遮罩格式(不以 *** 開頭)
246
+ // 如果是遮罩格式或為空,後端會自動使用資料庫中解密的 API Key
247
+ if (apiKeyInput && !apiKeyInput.startsWith("***")) {
248
+ requestBody.apiKey = apiKeyInput;
249
+ console.log("使用新輸入的 API Key 進行測試");
250
+ } else {
251
+ console.log("使用資料庫中儲存的 API Key 進行測試");
252
+ }
253
+
254
+ const response = await fetch("/api/ai-settings/validate", {
255
+ method: "POST",
256
+ headers: { "Content-Type": "application/json" },
257
+ body: JSON.stringify(requestBody),
258
+ });
259
+
260
+ const data = await response.json();
261
+
262
+ if (data.valid) {
263
+ showToast("success", "測試成功", "API Key 有效");
264
+ } else {
265
+ showToast("error", "測試失敗", data.error || "API Key 無效");
266
+ }
267
+ } catch (error) {
268
+ console.error("測試 API Key 失敗:", error);
269
+ showToast("error", "錯誤", "測試失敗");
270
+ } finally {
271
+ hideLoadingOverlay();
272
+ }
273
+ }
274
+
275
+ /**
276
+ * 切換 API Key 可見性
277
+ */
278
+ export function toggleAPIKeyVisibility() {
279
+ const apiKeyInput = document.getElementById("apiKey");
280
+ const toggleBtn = document.getElementById("toggleApiKey");
281
+
282
+ if (apiKeyInput.type === "password") {
283
+ apiKeyInput.type = "text";
284
+ toggleBtn.innerHTML = '<span class="icon">🙈</span>';
285
+ } else {
286
+ apiKeyInput.type = "password";
287
+ toggleBtn.innerHTML = '<span class="icon">👁️</span>';
288
+ }
289
+ }
290
+
291
+ export default {
292
+ loadAISettings,
293
+ loadPreferences,
294
+ openAISettingsModal,
295
+ closeAISettingsModal,
296
+ saveAISettings,
297
+ testAPIKey,
298
+ toggleAPIKeyVisibility,
299
+ };