Echocq commited on
Commit
db14b73
·
verified ·
1 Parent(s): b521f7b

feat: AI assistant bubble + multi-provider temp-mail + settings view

Browse files
src/server/ai-agent.ts CHANGED
@@ -310,6 +310,25 @@ async function callLLM(messages: ChatMessage[]): Promise<any> {
310
  return JSON.parse(text);
311
  }
312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  export async function runAgent(
314
  history: ChatMessage[],
315
  maxSteps = 6
 
310
  return JSON.parse(text);
311
  }
312
 
313
+ // 拉取端点真实可用模型列表(GET {base}/models)。可传入未保存的 base/key 先试。
314
+ export async function listModels(baseUrl?: string, apiKey?: string): Promise<string[]> {
315
+ const base = (baseUrl || getAiBaseUrl())?.replace(/\/+$/, "");
316
+ const key = apiKey || getAiApiKey();
317
+ if (!base) throw new Error("先填模型端点 base_url");
318
+ if (!key) throw new Error("先填 API Key");
319
+ const res = await fetch(`${base}/models`, { headers: { authorization: `Bearer ${key}` } });
320
+ const text = await res.text();
321
+ if (!res.ok) {
322
+ let msg = `拉取模型失败 HTTP ${res.status}`;
323
+ try { const j = JSON.parse(text); msg = j.error?.message || j.message || msg; } catch { /* keep */ }
324
+ throw new Error(msg);
325
+ }
326
+ let j: any; try { j = JSON.parse(text); } catch { throw new Error("端点返回非 JSON(可能不支持 /models)"); }
327
+ const arr: any[] = Array.isArray(j) ? j : (j.data ?? j.models ?? []);
328
+ const ids = arr.map((m) => (typeof m === "string" ? m : m.id ?? m.name ?? m.model)).filter(Boolean);
329
+ return Array.from(new Set(ids)).sort();
330
+ }
331
+
332
  export async function runAgent(
333
  history: ChatMessage[],
334
  maxSteps = 6
src/server/index.ts CHANGED
@@ -13,6 +13,7 @@ import { eventRoutes } from "./routes/events";
13
  import { clawAuthRoutes } from "./routes/claw-auth";
14
  import { cfMailRoutes } from "./routes/cf-mail";
15
  import { aiRoutes } from "./routes/ai";
 
16
  import { startAllMailboxListeners } from "./listener-manager";
17
  import { hasClawMailConfig } from "./runtime-config";
18
  import { hydrateFromSupabase } from "./hydrate";
@@ -60,6 +61,7 @@ await eventRoutes(app);
60
  await clawAuthRoutes(app);
61
  await cfMailRoutes(app);
62
  await aiRoutes(app);
 
63
 
64
  const __dirname = dirname(fileURLToPath(import.meta.url));
65
  const webRoot = join(__dirname, "../web");
 
13
  import { clawAuthRoutes } from "./routes/claw-auth";
14
  import { cfMailRoutes } from "./routes/cf-mail";
15
  import { aiRoutes } from "./routes/ai";
16
+ import { secretRoutes } from "./routes/secrets";
17
  import { startAllMailboxListeners } from "./listener-manager";
18
  import { hasClawMailConfig } from "./runtime-config";
19
  import { hydrateFromSupabase } from "./hydrate";
 
61
  await clawAuthRoutes(app);
62
  await cfMailRoutes(app);
63
  await aiRoutes(app);
64
+ await secretRoutes(app);
65
 
66
  const __dirname = dirname(fileURLToPath(import.meta.url));
67
  const webRoot = join(__dirname, "../web");
src/server/routes/ai.ts CHANGED
@@ -1,7 +1,7 @@
1
  // /api/ai/* —— 右下角 AI 气泡的后端:跑助手 + 配置 LLM 端点。
2
  import type { FastifyInstance } from "fastify";
3
  import { z } from "zod";
4
- import { runAgent, type ChatMessage } from "../ai-agent";
5
  import { aiConfigured, getAiConfigStatus, saveAiConfig, clearAiConfig } from "../runtime-config";
6
 
7
  const chatBody = z.object({
@@ -35,6 +35,15 @@ export async function aiRoutes(app: FastifyInstance): Promise<void> {
35
  return getAiConfigStatus();
36
  });
37
 
 
 
 
 
 
 
 
 
 
38
  app.post("/api/ai/chat", async (request, reply) => {
39
  if (!aiConfigured()) {
40
  return reply.code(409).send({ error: "AI 未配置:请先在设置里填入模型端点(base_url)和 key" });
 
1
  // /api/ai/* —— 右下角 AI 气泡的后端:跑助手 + 配置 LLM 端点。
2
  import type { FastifyInstance } from "fastify";
3
  import { z } from "zod";
4
+ import { runAgent, listModels, type ChatMessage } from "../ai-agent";
5
  import { aiConfigured, getAiConfigStatus, saveAiConfig, clearAiConfig } from "../runtime-config";
6
 
7
  const chatBody = z.object({
 
35
  return getAiConfigStatus();
36
  });
37
 
38
+ app.post("/api/ai/models", async (request, reply) => {
39
+ const body = (request.body ?? {}) as { baseUrl?: string; apiKey?: string };
40
+ try {
41
+ return { models: await listModels(body.baseUrl, body.apiKey) };
42
+ } catch (error) {
43
+ return reply.code(502).send({ error: error instanceof Error ? error.message : String(error) });
44
+ }
45
+ });
46
+
47
  app.post("/api/ai/chat", async (request, reply) => {
48
  if (!aiConfigured()) {
49
  return reply.code(409).send({ error: "AI 未配置:请先在设置里填入模型端点(base_url)和 key" });
src/server/routes/secrets.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // /api/secrets —— 在管理员门后揭示真实凭据,供设置页「凭据·可复制」用。
2
+ // 整个 /api/* 已被 ADMIN_PASSWORD 门拦住(index.ts onRequest),没登录的人取不到。
3
+ // 仅按需拉取(前端点「加载凭据」才调),平时不下发明文。
4
+ import type { FastifyInstance } from "fastify";
5
+ import { listProviders } from "../temp-providers";
6
+ import { getClawApiKey, getDashboardCookie, getAiApiKey } from "../runtime-config";
7
+
8
+ export async function secretRoutes(app: FastifyInstance): Promise<void> {
9
+ app.get("/api/secrets", async () => {
10
+ return {
11
+ temp: listProviders().map((p) => ({
12
+ id: p.id,
13
+ name: p.name,
14
+ type: p.type,
15
+ endpoint: p.endpoint,
16
+ domain: p.domain,
17
+ password: p.password || null
18
+ })),
19
+ claw: {
20
+ apiKey: getClawApiKey() ?? null,
21
+ hasCookie: Boolean(getDashboardCookie())
22
+ },
23
+ ai: {
24
+ apiKey: getAiApiKey() ?? null
25
+ }
26
+ };
27
+ });
28
+ }
src/web/src/api.ts CHANGED
@@ -468,6 +468,24 @@ export async function saveAiConfig(input: {
468
  });
469
  }
470
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  export async function aiChat(
472
  messages: AiChatMessage[]
473
  ): Promise<{ reply: string; toolTrace: Array<{ name: string; args: any }> }> {
 
468
  });
469
  }
470
 
471
+ export type SecretBundle = {
472
+ temp: Array<{ id: string; name: string; type: "php" | "cf"; endpoint: string; domain: string; password: string | null }>;
473
+ claw: { apiKey: string | null; hasCookie: boolean };
474
+ ai: { apiKey: string | null };
475
+ };
476
+
477
+ export async function fetchSecrets(): Promise<SecretBundle> {
478
+ return requestJson<SecretBundle>("/api/secrets");
479
+ }
480
+
481
+ export async function fetchAiModels(baseUrl?: string, apiKey?: string): Promise<string[]> {
482
+ const data = await requestJson<{ models: string[] }>("/api/ai/models", {
483
+ method: "POST",
484
+ body: JSON.stringify({ baseUrl, apiKey })
485
+ });
486
+ return data.models;
487
+ }
488
+
489
  export async function aiChat(
490
  messages: AiChatMessage[]
491
  ): Promise<{ reply: string; toolTrace: Array<{ name: string; args: any }> }> {
src/web/src/components/SettingsView.tsx CHANGED
@@ -1,9 +1,12 @@
1
  import { useEffect, useState } from "react";
2
  import {
3
  fetchAiConfig,
 
4
  fetchCfStatus,
 
5
  saveAiConfig,
6
  type AiConfigStatus,
 
7
  type TempProviderPublic
8
  } from "../api";
9
  import { usePrefs } from "../i18n";
@@ -89,11 +92,75 @@ export function SettingsView({
89
  const [aiModel, setAiModel] = useState("");
90
  const [aiKey, setAiKey] = useState("");
91
  const [aiBusy, setAiBusy] = useState(false);
 
 
92
 
93
  useEffect(() => {
94
  fetchAiConfig().then((c) => { setAi(c); setAiBase(c.baseUrl); setAiModel(c.model); }).catch(() => {});
95
  }, []);
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  async function saveAi() {
98
  setAiBusy(true);
99
  try {
@@ -177,8 +244,17 @@ export function SettingsView({
177
  <label className="wide"><span>Base URL</span>
178
  <input value={aiBase} onChange={(e) => setAiBase(e.target.value)} placeholder="https://api.example.com/v1" spellCheck={false} />
179
  </label>
180
- <label><span>{L("模型", "Model")}</span>
181
- <input value={aiModel} onChange={(e) => setAiModel(e.target.value)} placeholder="gpt-4o-mini" spellCheck={false} />
 
 
 
 
 
 
 
 
 
182
  </label>
183
  <label><span>API Key {ai?.hasKey ? L("(已存,留空不改)", "(saved)") : ""}</span>
184
  <input type="password" value={aiKey} onChange={(e) => setAiKey(e.target.value)} placeholder="sk-…" autoComplete="new-password" />
@@ -191,6 +267,39 @@ export function SettingsView({
191
  </div>
192
  </div>
193
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  </div>
195
  );
196
 
@@ -213,9 +322,13 @@ export function SettingsView({
213
  <label><span>{L("域名", "Domain")}</span>
214
  <input value={domain} onChange={(e) => setDomain(e.target.value)} placeholder="x.xyz" spellCheck={false} />
215
  </label>
216
- <label><span>{L("管理员密码 / auth", "Admin password / auth")}</span>
 
 
 
 
217
  <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password"
218
- placeholder={editingExisting ? (editingProvider?.hasPassword ? L("已设 · 留空不改", "set · leave blank to keep") : L("未设", "not set")) : L("必填", "required")} />
219
  </label>
220
  <div className="settings-form-foot">
221
  {type === "cf" && (
 
1
  import { useEffect, useState } from "react";
2
  import {
3
  fetchAiConfig,
4
+ fetchAiModels,
5
  fetchCfStatus,
6
+ fetchSecrets,
7
  saveAiConfig,
8
  type AiConfigStatus,
9
+ type SecretBundle,
10
  type TempProviderPublic
11
  } from "../api";
12
  import { usePrefs } from "../i18n";
 
92
  const [aiModel, setAiModel] = useState("");
93
  const [aiKey, setAiKey] = useState("");
94
  const [aiBusy, setAiBusy] = useState(false);
95
+ const [aiModels, setAiModels] = useState<string[]>([]);
96
+ const [modelsBusy, setModelsBusy] = useState(false);
97
 
98
  useEffect(() => {
99
  fetchAiConfig().then((c) => { setAi(c); setAiBase(c.baseUrl); setAiModel(c.model); }).catch(() => {});
100
  }, []);
101
 
102
+ async function loadModels() {
103
+ if (!aiBase.trim()) { onError(L("先填 Base URL", "Fill Base URL first")); return; }
104
+ setModelsBusy(true);
105
+ try {
106
+ const list = await fetchAiModels(aiBase.trim(), aiKey || undefined);
107
+ setAiModels(list);
108
+ onStatus(L(`拉到 ${list.length} 个模型`, `${list.length} models`));
109
+ } catch (e) {
110
+ onError(e instanceof Error ? e.message : String(e));
111
+ } finally {
112
+ setModelsBusy(false);
113
+ }
114
+ }
115
+
116
+ // ---- 凭据·可复制(B 方案:揭示真实密钥/口令)----
117
+ const [secrets, setSecrets] = useState<SecretBundle | null>(null);
118
+ const [secretsBusy, setSecretsBusy] = useState(false);
119
+ const [revealed, setRevealed] = useState<Record<string, boolean>>({});
120
+
121
+ async function loadSecrets() {
122
+ setSecretsBusy(true);
123
+ try {
124
+ setSecrets(await fetchSecrets());
125
+ } catch (e) {
126
+ onError(e instanceof Error ? e.message : String(e));
127
+ } finally {
128
+ setSecretsBusy(false);
129
+ }
130
+ }
131
+
132
+ function copyText(text: string) {
133
+ const done = () => onStatus(L("已复制到剪贴板", "Copied"));
134
+ if (navigator.clipboard?.writeText) {
135
+ navigator.clipboard.writeText(text).then(done).catch(() => onError(L("复制失败,手动选中吧", "Copy failed")));
136
+ } else {
137
+ onError(L("此浏览器不支持自动复制", "Clipboard unavailable"));
138
+ }
139
+ }
140
+
141
+ function maskSecret(v: string) {
142
+ if (v.length <= 8) return "••••••";
143
+ return `${v.slice(0, 4)}••••••${v.slice(-3)}`;
144
+ }
145
+
146
+ function secretRow(key: string, label: string, value: string | null) {
147
+ const shown = revealed[key];
148
+ return (
149
+ <div className="secret-row" key={key}>
150
+ <span className="secret-label">{label}</span>
151
+ <code className="secret-val">{value ? (shown ? value : maskSecret(value)) : L("未设", "not set")}</code>
152
+ {value && (
153
+ <div className="secret-ops">
154
+ <button className="mini-link" onClick={() => setRevealed((r) => ({ ...r, [key]: !r[key] }))}>
155
+ {shown ? L("🙈 隐藏", "🙈 hide") : L("👁 查看", "👁 show")}
156
+ </button>
157
+ <button className="mini-link" onClick={() => copyText(value)}>{L("复制", "copy")}</button>
158
+ </div>
159
+ )}
160
+ </div>
161
+ );
162
+ }
163
+
164
  async function saveAi() {
165
  setAiBusy(true);
166
  try {
 
244
  <label className="wide"><span>Base URL</span>
245
  <input value={aiBase} onChange={(e) => setAiBase(e.target.value)} placeholder="https://api.example.com/v1" spellCheck={false} />
246
  </label>
247
+ <label><span>
248
+ {L("模型", "Model")}
249
+ <button type="button" className="mini-link" onClick={loadModels} disabled={modelsBusy || !aiBase.trim()}>
250
+ {modelsBusy ? L("获取中…", "fetching…") : L("↻ 获取模型", "↻ fetch models")}
251
+ </button>
252
+ </span>
253
+ <input list="ai-model-list" value={aiModel} onChange={(e) => setAiModel(e.target.value)}
254
+ placeholder={aiModels.length ? L("选择或输入", "pick or type") : L("先点「获取模型」", "click fetch first")} spellCheck={false} />
255
+ <datalist id="ai-model-list">
256
+ {aiModels.map((m) => <option key={m} value={m} />)}
257
+ </datalist>
258
  </label>
259
  <label><span>API Key {ai?.hasKey ? L("(已存,留空不改)", "(saved)") : ""}</span>
260
  <input type="password" value={aiKey} onChange={(e) => setAiKey(e.target.value)} placeholder="sk-…" autoComplete="new-password" />
 
267
  </div>
268
  </div>
269
  </div>
270
+
271
+ {/* ===== 凭据·可复制 ===== */}
272
+ <div className="settings-card">
273
+ <div className="settings-head">
274
+ <div>
275
+ <h3>{L("凭据 · 可复制", "Credentials · copy")}</h3>
276
+ <p>{L("你存的真实密钥/口令:临时源密码、claw 出口 API key、AI key。点「查看」看明文、点「复制」进剪贴板。仅本后台(已过管理员门)可见。", "Real stored secrets: temp-source passwords, claw API key, AI key. Click show / copy. Visible only inside this admin panel.")}</p>
277
+ </div>
278
+ {!secrets && (
279
+ <button className="primary" onClick={loadSecrets} disabled={secretsBusy}>
280
+ {secretsBusy ? L("加载中…", "loading…") : L("🔓 加载凭据", "🔓 Load")}
281
+ </button>
282
+ )}
283
+ </div>
284
+ {secrets && (
285
+ <div className="secret-list">
286
+ {secrets.temp.map((t) =>
287
+ secretRow(
288
+ `t:${t.id}`,
289
+ `${t.name} · ${t.type === "cf" ? L("x-admin-auth 口令", "x-admin-auth") : L("管理员密码", "admin password")}`,
290
+ t.password
291
+ )
292
+ )}
293
+ {secretRow("claw", L("claw 出口 · CLAW_API_KEY", "claw · CLAW_API_KEY"), secrets.claw.apiKey)}
294
+ {secretRow("ai", L("AI 助手 · API Key", "AI · API Key"), secrets.ai.apiKey)}
295
+ <div className="settings-form-foot">
296
+ <span className="settings-note">{L("提醒:这些是真实密钥,复制后别贴到公开地方。", "These are real secrets — don't paste them anywhere public.")}</span>
297
+ <span style={{ flex: 1 }} />
298
+ <button onClick={() => { setSecrets(null); setRevealed({}); }}>{L("收起", "Hide all")}</button>
299
+ </div>
300
+ </div>
301
+ )}
302
+ </div>
303
  </div>
304
  );
305
 
 
322
  <label><span>{L("域名", "Domain")}</span>
323
  <input value={domain} onChange={(e) => setDomain(e.target.value)} placeholder="x.xyz" spellCheck={false} />
324
  </label>
325
+ <label><span>
326
+ {type === "cf"
327
+ ? L("管理员 auth(x-admin-auth 口令)", "Admin auth (x-admin-auth)")
328
+ : L("管理员密码(X-Admin-Password)", "Admin password (X-Admin-Password)")}
329
+ </span>
330
  <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password"
331
+ placeholder={editingExisting ? (editingProvider?.hasPassword ? L("已设 · 留空不改(出于安全不回显)", "set · blank = keep (never echoed)") : L("未设", "not set")) : L("必填", "required")} />
332
  </label>
333
  <div className="settings-form-foot">
334
  {type === "cf" && (
src/web/src/styles.css CHANGED
@@ -2201,7 +2201,24 @@ a:hover { text-decoration: underline; text-underline-offset: 2px; }
2201
  }
2202
  .settings-form label { display: flex; flex-direction: column; gap: 5px; }
2203
  .settings-form label.wide { grid-column: 1 / -1; }
2204
- .settings-form label span { font-size: 11.5px; color: var(--text-3); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2205
  .settings-form input,
2206
  .settings-form select {
2207
  background: var(--surface-2);
 
2201
  }
2202
  .settings-form label { display: flex; flex-direction: column; gap: 5px; }
2203
  .settings-form label.wide { grid-column: 1 / -1; }
2204
+ .settings-form label span { font-size: 11.5px; color: var(--text-3); display: flex; align-items: center; gap: 8px; }
2205
+ .mini-link {
2206
+ background: none; border: none; padding: 0; cursor: pointer;
2207
+ color: var(--accent); font-size: 11px; font-weight: 500;
2208
+ }
2209
+ .mini-link:disabled { color: var(--text-3); cursor: default; }
2210
+ .secret-list { display: flex; flex-direction: column; gap: 2px; margin-top: 12px; }
2211
+ .secret-row {
2212
+ display: flex; align-items: center; gap: 12px;
2213
+ padding: 9px 10px; border-radius: var(--radius);
2214
+ }
2215
+ .secret-row:hover { background: var(--surface-2); }
2216
+ .secret-label { font-size: 12px; color: var(--text-2); min-width: 190px; flex-shrink: 0; }
2217
+ .secret-val {
2218
+ flex: 1; font-family: var(--mono, ui-monospace, monospace); font-size: 12px;
2219
+ color: var(--text); word-break: break-all; user-select: all;
2220
+ }
2221
+ .secret-ops { display: flex; gap: 12px; flex-shrink: 0; }
2222
  .settings-form input,
2223
  .settings-form select {
2224
  background: var(--surface-2);