page.tsx 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222
  1. "use client";
  2. import { useState, useEffect } from "react";
  3. import { useRouter } from "next/navigation";
  4. import { ResponsiveLayout } from "@/components/layout";
  5. import { Button } from "@/components/ui/button";
  6. import { Input } from "@/components/ui/input";
  7. import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
  8. import {
  9. fetchAIConfigs,
  10. createAIConfig,
  11. updateAIConfig,
  12. deleteAIConfig,
  13. type AIConfig,
  14. type CreateAIConfigRequest,
  15. type UpdateAIConfigRequest,
  16. } from "@/features/agent/services/aiConfigApi";
  17. import {
  18. fetchEmbeddingConfig,
  19. updateEmbeddingConfig,
  20. type EmbeddingConfig,
  21. type UpdateEmbeddingConfigRequest,
  22. } from "@/features/agent/services/embeddingConfigApi";
  23. import {
  24. deleteAutoCloseConversationDaysPolicy,
  25. fetchAutoCloseConversationDaysPolicy,
  26. putAutoCloseConversationDaysPolicy,
  27. type AutoCloseConversationDaysPolicy,
  28. } from "@/features/agent/services/conversationApi";
  29. import {
  30. fetchEmailNotificationConfig,
  31. resetEmailNotificationConfig,
  32. sendEmailNotificationTest,
  33. updateEmailNotificationConfig,
  34. type EmailNotificationConfig,
  35. } from "@/features/agent/services/emailNotificationApi";
  36. import { useProfile } from "@/features/agent/hooks/useProfile";
  37. import { apiUrl } from "@/lib/config";
  38. import { Checkbox } from "@/components/ui/checkbox";
  39. import { Label } from "@/components/ui/label";
  40. import { toast } from "@/hooks/useToast";
  41. import type { I18nKey } from "@/lib/i18n/dict";
  42. import { useI18n } from "@/lib/i18n/provider";
  43. export default function SettingsPage(props: any = {}) {
  44. const { embedded = false } = props;
  45. const router = useRouter();
  46. const { t } = useI18n();
  47. const modelTypeLabel = (mt: string) => {
  48. const map: Record<string, I18nKey> = {
  49. text: "agent.settings.modelType.text",
  50. image: "agent.settings.modelType.image",
  51. audio: "agent.settings.modelType.audio",
  52. video: "agent.settings.modelType.video",
  53. };
  54. const k = map[mt];
  55. return k ? t(k) : mt;
  56. };
  57. const [userId, setUserId] = useState<number | null>(null);
  58. const [configs, setConfigs] = useState<AIConfig[]>([]);
  59. const [loading, setLoading] = useState(true);
  60. const [editingId, setEditingId] = useState<number | null>(null);
  61. const [formData, setFormData] = useState<CreateAIConfigRequest>({
  62. provider: "",
  63. api_url: "",
  64. api_key: "",
  65. model: "",
  66. model_type: "text",
  67. is_active: true,
  68. is_public: false,
  69. description: "",
  70. });
  71. const [submitting, setSubmitting] = useState(false);
  72. const [error, setError] = useState("");
  73. // 知识库向量配置(平台级,仅管理员可修改)
  74. const [embeddingConfig, setEmbeddingConfig] = useState<EmbeddingConfig | null>(null);
  75. const [embeddingForm, setEmbeddingForm] = useState({
  76. embedding_type: "openai",
  77. api_url: "",
  78. api_key: "",
  79. model: "text-embedding-3-small",
  80. customer_can_use_kb: true,
  81. visitor_web_search_enabled: false,
  82. web_search_source: "custom" as "vendor" | "custom",
  83. });
  84. const [embeddingLoading, setEmbeddingLoading] = useState(false);
  85. const [embeddingSubmitting, setEmbeddingSubmitting] = useState(false);
  86. const [embeddingError, setEmbeddingError] = useState("");
  87. // 会话维护:自动关闭长期未活跃 open 访客会话(平台级)
  88. const [autoClosePolicy, setAutoClosePolicy] = useState<AutoCloseConversationDaysPolicy | null>(null);
  89. const [autoCloseDaysDraft, setAutoCloseDaysDraft] = useState("7");
  90. const [autoCloseLoading, setAutoCloseLoading] = useState(false);
  91. const [autoCloseSubmitting, setAutoCloseSubmitting] = useState(false);
  92. const [autoCloseError, setAutoCloseError] = useState("");
  93. // 离线邮件通知(平台级,仅管理员可修改)
  94. const [isAdmin, setIsAdmin] = useState(false);
  95. const [emailConfig, setEmailConfig] = useState<EmailNotificationConfig | null>(null);
  96. const [emailForm, setEmailForm] = useState({
  97. enabled: false,
  98. smtp_host: "",
  99. smtp_port: "465",
  100. smtp_user: "",
  101. smtp_password: "",
  102. from_email: "",
  103. from_name: "",
  104. offline_delay_seconds: "60",
  105. });
  106. const [emailTestTo, setEmailTestTo] = useState("");
  107. const [emailLoading, setEmailLoading] = useState(false);
  108. const [emailSubmitting, setEmailSubmitting] = useState(false);
  109. const [emailTesting, setEmailTesting] = useState(false);
  110. const [emailError, setEmailError] = useState("");
  111. // 检查登录状态
  112. useEffect(() => {
  113. const storedUserId = localStorage.getItem("agent_user_id");
  114. if (!storedUserId) {
  115. router.push("/");
  116. return;
  117. }
  118. setUserId(Number.parseInt(storedUserId, 10));
  119. setIsAdmin(localStorage.getItem("agent_role") === "admin");
  120. }, [router]);
  121. // 加载个人资料(用于获取和更新 AI 对话接收设置)
  122. const {
  123. profile,
  124. loading: profileLoading,
  125. update: updateProfile,
  126. } = useProfile({
  127. userId: userId ?? null,
  128. enabled: Boolean(userId),
  129. });
  130. // 加载配置列表
  131. const loadConfigs = async () => {
  132. if (!userId) return;
  133. try {
  134. setLoading(true);
  135. const data = await fetchAIConfigs(userId);
  136. setConfigs(data);
  137. } catch (error) {
  138. console.error("加载配置失败:", error);
  139. setError(t("agent.settings.error.loadConfigs"));
  140. } finally {
  141. setLoading(false);
  142. }
  143. };
  144. useEffect(() => {
  145. if (userId) {
  146. loadConfigs();
  147. }
  148. }, [userId]);
  149. // 加载知识库向量配置
  150. const loadEmbeddingConfig = async () => {
  151. if (!userId) return;
  152. try {
  153. setEmbeddingLoading(true);
  154. const data = await fetchEmbeddingConfig(userId);
  155. setEmbeddingConfig(data);
  156. setEmbeddingForm({
  157. embedding_type: data.embedding_type || "openai",
  158. api_url: data.api_url || "",
  159. api_key: "",
  160. model: data.model || "text-embedding-3-small",
  161. customer_can_use_kb: data.customer_can_use_kb ?? true,
  162. visitor_web_search_enabled: data.visitor_web_search_enabled ?? false,
  163. web_search_source: data.web_search_source === "vendor" ? "vendor" : "custom",
  164. });
  165. } catch (e) {
  166. console.error("加载知识库向量配置失败:", e);
  167. setEmbeddingError(t("agent.settings.error.loadEmbedding"));
  168. } finally {
  169. setEmbeddingLoading(false);
  170. }
  171. };
  172. useEffect(() => {
  173. if (userId) {
  174. loadEmbeddingConfig();
  175. }
  176. }, [userId]);
  177. const loadAutoClosePolicy = async () => {
  178. if (!userId) return;
  179. try {
  180. setAutoCloseLoading(true);
  181. setAutoCloseError("");
  182. const policy = await fetchAutoCloseConversationDaysPolicy();
  183. setAutoClosePolicy(policy);
  184. setAutoCloseDaysDraft(String(policy.effective_days));
  185. } catch (e) {
  186. console.error("加载会话维护配置失败:", e);
  187. setAutoCloseError(t("agent.settings.autoClose.errorLoad"));
  188. } finally {
  189. setAutoCloseLoading(false);
  190. }
  191. };
  192. useEffect(() => {
  193. if (userId) {
  194. void loadAutoClosePolicy();
  195. }
  196. }, [userId]);
  197. const loadEmailConfig = async () => {
  198. if (!userId) return;
  199. try {
  200. setEmailLoading(true);
  201. setEmailError("");
  202. const data = await fetchEmailNotificationConfig(userId);
  203. setEmailConfig(data);
  204. setEmailForm({
  205. enabled: data.enabled,
  206. smtp_host: data.smtp_host || "",
  207. smtp_port: String(data.smtp_port || 465),
  208. smtp_user: data.smtp_user || "",
  209. smtp_password: "",
  210. from_email: data.from_email || "",
  211. from_name: data.from_name || "",
  212. offline_delay_seconds: String(data.offline_delay_seconds ?? 60),
  213. });
  214. } catch (e) {
  215. console.error("加载离线邮件配置失败:", e);
  216. setEmailError(t("agent.settings.offlineEmail.errorLoad"));
  217. } finally {
  218. setEmailLoading(false);
  219. }
  220. };
  221. useEffect(() => {
  222. if (userId) {
  223. void loadEmailConfig();
  224. }
  225. }, [userId]);
  226. const handleSaveEmailConfig = async (e: React.FormEvent) => {
  227. e.preventDefault();
  228. if (!userId || !isAdmin) return;
  229. const delay = Number.parseInt(emailForm.offline_delay_seconds, 10);
  230. const port = Number.parseInt(emailForm.smtp_port, 10);
  231. if (Number.isNaN(delay) || delay < 0) {
  232. setEmailError(t("agent.settings.offlineEmail.errorInvalidDelay"));
  233. return;
  234. }
  235. setEmailSubmitting(true);
  236. setEmailError("");
  237. try {
  238. await updateEmailNotificationConfig(userId, {
  239. enabled: emailForm.enabled,
  240. smtp_host: emailForm.smtp_host || undefined,
  241. smtp_port: Number.isNaN(port) ? undefined : port,
  242. smtp_user: emailForm.smtp_user || undefined,
  243. from_email: emailForm.from_email || undefined,
  244. from_name: emailForm.from_name || undefined,
  245. offline_delay_seconds: delay,
  246. ...(emailForm.smtp_password ? { smtp_password: emailForm.smtp_password } : {}),
  247. });
  248. await loadEmailConfig();
  249. toast.success(t("agent.settings.offlineEmail.toastSaved"));
  250. } catch (err) {
  251. setEmailError((err as Error).message);
  252. } finally {
  253. setEmailSubmitting(false);
  254. }
  255. };
  256. const handleResetEmailConfig = async () => {
  257. if (!userId || !isAdmin) return;
  258. setEmailSubmitting(true);
  259. setEmailError("");
  260. try {
  261. await resetEmailNotificationConfig(userId);
  262. await loadEmailConfig();
  263. toast.success(t("agent.settings.offlineEmail.toastReset"));
  264. } catch (err) {
  265. setEmailError((err as Error).message);
  266. } finally {
  267. setEmailSubmitting(false);
  268. }
  269. };
  270. const handleSendEmailTest = async () => {
  271. if (!userId || !isAdmin) return;
  272. const to = emailTestTo.trim();
  273. if (!to) return;
  274. setEmailTesting(true);
  275. setEmailError("");
  276. try {
  277. await sendEmailNotificationTest(userId, to);
  278. toast.success(t("agent.settings.offlineEmail.toastTestSent"));
  279. } catch (err) {
  280. setEmailError((err as Error).message);
  281. } finally {
  282. setEmailTesting(false);
  283. }
  284. };
  285. const handleSaveAutoClosePolicy = async (e: React.FormEvent) => {
  286. e.preventDefault();
  287. if (!userId) return;
  288. const parsed = Number.parseInt(autoCloseDaysDraft, 10);
  289. if (Number.isNaN(parsed) || parsed < 0) {
  290. setAutoCloseError(t("agent.settings.autoClose.errorInvalid"));
  291. return;
  292. }
  293. setAutoCloseSubmitting(true);
  294. setAutoCloseError("");
  295. try {
  296. await putAutoCloseConversationDaysPolicy(parsed);
  297. await loadAutoClosePolicy();
  298. toast.success(t("agent.settings.autoClose.toastSaved"));
  299. } catch (err) {
  300. setAutoCloseError((err as Error).message);
  301. } finally {
  302. setAutoCloseSubmitting(false);
  303. }
  304. };
  305. const handleResetAutoClosePolicy = async () => {
  306. if (!userId) return;
  307. setAutoCloseSubmitting(true);
  308. setAutoCloseError("");
  309. try {
  310. await deleteAutoCloseConversationDaysPolicy();
  311. await loadAutoClosePolicy();
  312. toast.success(t("agent.settings.autoClose.toastReset"));
  313. } catch (err) {
  314. setAutoCloseError((err as Error).message);
  315. } finally {
  316. setAutoCloseSubmitting(false);
  317. }
  318. };
  319. // 保存知识库向量配置(仅管理员;保存后立即生效,无需重启)
  320. const handleSaveEmbeddingConfig = async (e: React.FormEvent) => {
  321. e.preventDefault();
  322. if (!userId) return;
  323. setEmbeddingSubmitting(true);
  324. setEmbeddingError("");
  325. try {
  326. const data: UpdateEmbeddingConfigRequest = {
  327. embedding_type: embeddingForm.embedding_type,
  328. api_url: embeddingForm.api_url || undefined,
  329. model: embeddingForm.model || undefined,
  330. customer_can_use_kb: embeddingForm.customer_can_use_kb,
  331. visitor_web_search_enabled: embeddingForm.visitor_web_search_enabled,
  332. web_search_source: embeddingForm.web_search_source,
  333. };
  334. if (embeddingForm.api_key) {
  335. data.api_key = embeddingForm.api_key;
  336. }
  337. await updateEmbeddingConfig(userId, data);
  338. await loadEmbeddingConfig();
  339. toast.success(t("agent.settings.toast.embeddingSaved"));
  340. } catch (err) {
  341. setEmbeddingError((err as Error).message);
  342. } finally {
  343. setEmbeddingSubmitting(false);
  344. }
  345. };
  346. // 重置表单
  347. const resetForm = () => {
  348. setFormData({
  349. provider: "",
  350. api_url: "",
  351. api_key: "",
  352. model: "",
  353. model_type: "text",
  354. is_active: true,
  355. is_public: false,
  356. description: "",
  357. });
  358. setEditingId(null);
  359. setError("");
  360. };
  361. // 开始编辑
  362. const handleEdit = (config: AIConfig) => {
  363. setFormData({
  364. provider: config.provider,
  365. api_url: config.api_url,
  366. api_key: "", // 不显示 API Key(已加密)
  367. model: config.model,
  368. model_type: config.model_type,
  369. is_active: config.is_active,
  370. is_public: config.is_public,
  371. description: config.description,
  372. });
  373. setEditingId(config.id);
  374. };
  375. // 提交表单
  376. const handleSubmit = async (e: React.FormEvent) => {
  377. e.preventDefault();
  378. if (!userId) return;
  379. setSubmitting(true);
  380. setError("");
  381. try {
  382. if (editingId) {
  383. // 更新配置
  384. const updateData: UpdateAIConfigRequest = {
  385. provider: formData.provider,
  386. api_url: formData.api_url,
  387. model: formData.model,
  388. model_type: formData.model_type,
  389. is_active: formData.is_active,
  390. is_public: formData.is_public,
  391. description: formData.description,
  392. };
  393. // 如果提供了新的 API Key,才更新
  394. if (formData.api_key) {
  395. updateData.api_key = formData.api_key;
  396. }
  397. await updateAIConfig(userId, editingId, updateData);
  398. } else {
  399. // 创建配置
  400. await createAIConfig(userId, formData);
  401. }
  402. resetForm();
  403. await loadConfigs();
  404. } catch (error) {
  405. setError((error as Error).message || t("agent.settings.error.operation"));
  406. } finally {
  407. setSubmitting(false);
  408. }
  409. };
  410. // 删除配置
  411. const handleDelete = async (id: number) => {
  412. if (!userId) return;
  413. if (!confirm(t("agent.settings.confirmDeleteConfig"))) return;
  414. try {
  415. await deleteAIConfig(userId, id);
  416. await loadConfigs();
  417. } catch (error) {
  418. setError((error as Error).message || t("agent.settings.error.delete"));
  419. }
  420. };
  421. // 退出登录
  422. const handleLogout = async () => {
  423. try {
  424. await fetch(apiUrl("/logout"), { method: "POST" });
  425. } catch (error) {
  426. console.error("退出登录失败:", error);
  427. } finally {
  428. localStorage.removeItem("agent_user_id");
  429. localStorage.removeItem("agent_username");
  430. localStorage.removeItem("agent_role");
  431. router.push("/");
  432. }
  433. };
  434. if (!userId) {
  435. return null;
  436. }
  437. // 构建头部内容
  438. const headerContent = (
  439. <div className="border-b bg-card p-3 shadow-sm sm:p-4">
  440. <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
  441. <div>
  442. <h1 className="text-xl font-bold text-foreground">{t("agent.settings.title")}</h1>
  443. <div className="text-sm text-muted-foreground mt-1">{t("agent.settings.subtitle")}</div>
  444. </div>
  445. {!embedded && (
  446. <div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
  447. <Button
  448. onClick={() => router.push("/agent/dashboard")}
  449. variant="outline"
  450. size="sm"
  451. className="w-full sm:w-auto"
  452. >
  453. {t("agent.settings.backDashboard")}
  454. </Button>
  455. <Button
  456. onClick={handleLogout}
  457. variant="outline"
  458. size="sm"
  459. className="w-full sm:w-auto"
  460. >
  461. {t("agent.logout")}
  462. </Button>
  463. </div>
  464. )}
  465. </div>
  466. </div>
  467. );
  468. // 构建主内容区
  469. const mainContent = (
  470. <div className="flex-1 overflow-auto p-3 sm:p-4 md:p-6">
  471. <div className="max-w-6xl mx-auto space-y-6">
  472. {/* 全局设置 */}
  473. <Card>
  474. <CardHeader>
  475. <CardTitle>{t("agent.settings.section.global")}</CardTitle>
  476. </CardHeader>
  477. <CardContent>
  478. <div className="flex items-center space-x-2">
  479. <Checkbox
  480. id="receive_ai_conversations"
  481. checked={!(profile?.receive_ai_conversations ?? false)}
  482. onCheckedChange={async (checked) => {
  483. if (userId) {
  484. try {
  485. await updateProfile({
  486. receive_ai_conversations: !checked,
  487. });
  488. } catch (error) {
  489. console.error("更新设置失败:", error);
  490. toast.error(t("agent.settings.toast.profileUpdateFailed"));
  491. }
  492. }
  493. }}
  494. disabled={profileLoading}
  495. />
  496. <Label
  497. htmlFor="receive_ai_conversations"
  498. className="text-sm font-medium cursor-pointer"
  499. >
  500. {t("agent.settings.global.noReceiveAi")}
  501. </Label>
  502. </div>
  503. <p className="text-xs text-muted-foreground mt-2">
  504. {t("agent.settings.global.noReceiveAiHint")}
  505. </p>
  506. </CardContent>
  507. </Card>
  508. {/* 会话维护:自动关闭长期未活跃 open 访客会话 */}
  509. <Card>
  510. <CardHeader>
  511. <CardTitle>{t("agent.settings.autoClose.title")}</CardTitle>
  512. <p className="text-sm text-muted-foreground mt-1">
  513. {t("agent.settings.autoClose.lead")}
  514. </p>
  515. </CardHeader>
  516. <CardContent>
  517. {autoCloseLoading ? (
  518. <div className="text-center py-6 text-muted-foreground">{t("common.loading")}</div>
  519. ) : (
  520. <form onSubmit={handleSaveAutoClosePolicy} className="space-y-4">
  521. {autoCloseError && (
  522. <div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
  523. {autoCloseError}
  524. </div>
  525. )}
  526. <div>
  527. <Label className="block text-sm font-medium mb-1">
  528. {t("agent.settings.autoClose.daysLabel")}
  529. </Label>
  530. <Input
  531. type="number"
  532. min={0}
  533. value={autoCloseDaysDraft}
  534. onChange={(e) => setAutoCloseDaysDraft(e.target.value)}
  535. className="max-w-xs"
  536. />
  537. <p className="text-xs text-muted-foreground mt-2">
  538. {t("agent.settings.autoClose.daysHint")}
  539. </p>
  540. </div>
  541. {autoClosePolicy ? (
  542. <p className="text-xs text-muted-foreground">
  543. {t("agent.settings.autoClose.statusEffective")}: {autoClosePolicy.effective_days}
  544. {" · "}
  545. {t("agent.settings.autoClose.statusEnv")}: {autoClosePolicy.env_days}
  546. {" · "}
  547. {autoClosePolicy.persisted_in_database
  548. ? t("agent.settings.autoClose.statusDb")
  549. : t("agent.settings.autoClose.statusEnvOnly")}
  550. </p>
  551. ) : null}
  552. <div className="flex flex-wrap gap-2">
  553. <Button type="submit" disabled={autoCloseSubmitting}>
  554. {autoCloseSubmitting ? t("common.saving") : t("agent.settings.autoClose.save")}
  555. </Button>
  556. {autoClosePolicy?.persisted_in_database ? (
  557. <Button
  558. type="button"
  559. variant="outline"
  560. disabled={autoCloseSubmitting}
  561. onClick={() => void handleResetAutoClosePolicy()}
  562. >
  563. {t("agent.settings.autoClose.resetEnv")}
  564. </Button>
  565. ) : null}
  566. </div>
  567. </form>
  568. )}
  569. </CardContent>
  570. </Card>
  571. {/* 离线邮件通知 */}
  572. <Card>
  573. <CardHeader>
  574. <CardTitle>{t("agent.settings.offlineEmail.title")}</CardTitle>
  575. <p className="text-sm text-muted-foreground mt-1">
  576. {t("agent.settings.offlineEmail.lead")}
  577. </p>
  578. {!isAdmin ? (
  579. <p className="text-xs text-amber-600 mt-2">
  580. {t("agent.settings.offlineEmail.adminOnly")}
  581. </p>
  582. ) : null}
  583. </CardHeader>
  584. <CardContent>
  585. {emailLoading ? (
  586. <div className="text-center py-6 text-muted-foreground">{t("common.loading")}</div>
  587. ) : (
  588. <form onSubmit={handleSaveEmailConfig} className="space-y-4">
  589. {emailError && (
  590. <div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
  591. {emailError}
  592. </div>
  593. )}
  594. <div className="flex items-center gap-2">
  595. <Checkbox
  596. id="offline_email_enabled"
  597. checked={emailForm.enabled}
  598. disabled={!isAdmin}
  599. onCheckedChange={(checked) =>
  600. setEmailForm({ ...emailForm, enabled: checked === true })
  601. }
  602. />
  603. <Label htmlFor="offline_email_enabled" className="text-sm cursor-pointer">
  604. {t("agent.settings.offlineEmail.enabled")}
  605. </Label>
  606. </div>
  607. <div>
  608. <Label className="block text-sm font-medium mb-1">
  609. {t("agent.settings.offlineEmail.delayLabel")}
  610. </Label>
  611. <Input
  612. type="number"
  613. min={0}
  614. value={emailForm.offline_delay_seconds}
  615. disabled={!isAdmin}
  616. onChange={(e) =>
  617. setEmailForm({ ...emailForm, offline_delay_seconds: e.target.value })
  618. }
  619. className="max-w-xs"
  620. />
  621. <p className="text-xs text-muted-foreground mt-2">
  622. {t("agent.settings.offlineEmail.delayHint")}
  623. </p>
  624. </div>
  625. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  626. <div>
  627. <Label className="block text-sm font-medium mb-1">
  628. {t("agent.settings.offlineEmail.smtpHost")}
  629. </Label>
  630. <Input
  631. value={emailForm.smtp_host}
  632. disabled={!isAdmin}
  633. onChange={(e) =>
  634. setEmailForm({ ...emailForm, smtp_host: e.target.value })
  635. }
  636. placeholder="smtp.example.com"
  637. />
  638. </div>
  639. <div>
  640. <Label className="block text-sm font-medium mb-1">
  641. {t("agent.settings.offlineEmail.smtpPort")}
  642. </Label>
  643. <Input
  644. type="number"
  645. min={1}
  646. value={emailForm.smtp_port}
  647. disabled={!isAdmin}
  648. onChange={(e) =>
  649. setEmailForm({ ...emailForm, smtp_port: e.target.value })
  650. }
  651. placeholder="465"
  652. />
  653. </div>
  654. <div>
  655. <Label className="block text-sm font-medium mb-1">
  656. {t("agent.settings.offlineEmail.smtpUser")}
  657. </Label>
  658. <Input
  659. value={emailForm.smtp_user}
  660. disabled={!isAdmin}
  661. onChange={(e) =>
  662. setEmailForm({ ...emailForm, smtp_user: e.target.value })
  663. }
  664. />
  665. </div>
  666. <div>
  667. <Label className="block text-sm font-medium mb-1">
  668. {t("agent.settings.offlineEmail.smtpPassword")}
  669. </Label>
  670. <Input
  671. type="password"
  672. value={emailForm.smtp_password}
  673. disabled={!isAdmin}
  674. onChange={(e) =>
  675. setEmailForm({ ...emailForm, smtp_password: e.target.value })
  676. }
  677. placeholder={
  678. emailConfig?.smtp_password_masked
  679. ? t("agent.settings.offlineEmail.smtpPasswordKeepEmpty")
  680. : undefined
  681. }
  682. />
  683. </div>
  684. <div>
  685. <Label className="block text-sm font-medium mb-1">
  686. {t("agent.settings.offlineEmail.fromEmail")}
  687. </Label>
  688. <Input
  689. type="email"
  690. value={emailForm.from_email}
  691. disabled={!isAdmin}
  692. onChange={(e) =>
  693. setEmailForm({ ...emailForm, from_email: e.target.value })
  694. }
  695. />
  696. </div>
  697. <div>
  698. <Label className="block text-sm font-medium mb-1">
  699. {t("agent.settings.offlineEmail.fromName")}
  700. </Label>
  701. <Input
  702. value={emailForm.from_name}
  703. disabled={!isAdmin}
  704. onChange={(e) =>
  705. setEmailForm({ ...emailForm, from_name: e.target.value })
  706. }
  707. />
  708. </div>
  709. </div>
  710. {emailConfig ? (
  711. <p className="text-xs text-muted-foreground">
  712. {t("agent.settings.offlineEmail.statusEffective")}:{" "}
  713. {emailConfig.effective_enabled
  714. ? t("agent.settings.offlineEmail.statusOn")
  715. : t("agent.settings.offlineEmail.statusOff")}
  716. {" · "}
  717. {t("agent.settings.offlineEmail.delayLabel")}:{" "}
  718. {emailConfig.effective_delay_seconds}s
  719. {" · "}
  720. {t("agent.settings.offlineEmail.statusEnv")}:{" "}
  721. {emailConfig.env_enabled ? "on" : "off"},{" "}
  722. {emailConfig.env_delay_seconds}s
  723. {" · "}
  724. {emailConfig.persisted_in_database
  725. ? t("agent.settings.offlineEmail.statusDb")
  726. : t("agent.settings.offlineEmail.statusEnvOnly")}
  727. </p>
  728. ) : null}
  729. <div className="flex flex-wrap gap-2">
  730. <Button type="submit" disabled={!isAdmin || emailSubmitting}>
  731. {emailSubmitting
  732. ? t("common.saving")
  733. : t("agent.settings.offlineEmail.save")}
  734. </Button>
  735. {isAdmin && emailConfig?.persisted_in_database ? (
  736. <Button
  737. type="button"
  738. variant="outline"
  739. disabled={emailSubmitting}
  740. onClick={() => void handleResetEmailConfig()}
  741. >
  742. {t("agent.settings.offlineEmail.resetEnv")}
  743. </Button>
  744. ) : null}
  745. </div>
  746. {isAdmin ? (
  747. <div className="flex flex-col sm:flex-row gap-2 pt-2 border-t">
  748. <Input
  749. type="email"
  750. value={emailTestTo}
  751. onChange={(e) => setEmailTestTo(e.target.value)}
  752. placeholder={t("agent.settings.offlineEmail.testTo")}
  753. className="sm:max-w-xs"
  754. />
  755. <Button
  756. type="button"
  757. variant="outline"
  758. disabled={emailTesting || !emailTestTo.trim()}
  759. onClick={() => void handleSendEmailTest()}
  760. >
  761. {emailTesting
  762. ? t("common.saving")
  763. : t("agent.settings.offlineEmail.testSend")}
  764. </Button>
  765. </div>
  766. ) : null}
  767. </form>
  768. )}
  769. </CardContent>
  770. </Card>
  771. {/* 知识库向量模型(平台级,仅管理员可修改;保存后立即生效) */}
  772. <Card>
  773. <CardHeader>
  774. <CardTitle>{t("agent.settings.embedding.title")}</CardTitle>
  775. <p className="text-sm text-muted-foreground mt-1">
  776. {t("agent.settings.embedding.lead")}
  777. </p>
  778. </CardHeader>
  779. <CardContent>
  780. {embeddingLoading ? (
  781. <div className="text-center py-6 text-muted-foreground">{t("common.loading")}</div>
  782. ) : (
  783. <form onSubmit={handleSaveEmbeddingConfig} className="space-y-4">
  784. {embeddingError && (
  785. <div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
  786. {embeddingError}
  787. </div>
  788. )}
  789. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  790. <div>
  791. <Label className="block text-sm font-medium mb-1">{t("agent.settings.embedding.type")}</Label>
  792. <select
  793. value={embeddingForm.embedding_type}
  794. onChange={(e) =>
  795. setEmbeddingForm({ ...embeddingForm, embedding_type: e.target.value })
  796. }
  797. className="w-full px-3 py-2 border border-input rounded-md text-sm bg-background"
  798. >
  799. <option value="openai">{t("agent.settings.embedding.openaiCompatible")}</option>
  800. <option value="bge">{t("agent.settings.embedding.bgeLocal")}</option>
  801. </select>
  802. </div>
  803. <div>
  804. <Label className="block text-sm font-medium mb-1">{t("agent.settings.embedding.apiUrl")}</Label>
  805. <Input
  806. value={embeddingForm.api_url}
  807. onChange={(e) =>
  808. setEmbeddingForm({ ...embeddingForm, api_url: e.target.value })
  809. }
  810. placeholder={t("agent.settings.embedding.apiUrlPh")}
  811. />
  812. </div>
  813. <div>
  814. <Label className="block text-sm font-medium mb-1">{t("agent.settings.embedding.apiKey")}</Label>
  815. <Input
  816. type="password"
  817. value={embeddingForm.api_key}
  818. onChange={(e) =>
  819. setEmbeddingForm({ ...embeddingForm, api_key: e.target.value })
  820. }
  821. placeholder={
  822. embeddingConfig?.api_key_masked
  823. ? t("agent.settings.embedding.apiKeyKeepEmpty")
  824. : t("agent.settings.embedding.apiKeyInput")
  825. }
  826. />
  827. </div>
  828. <div>
  829. <Label className="block text-sm font-medium mb-1">{t("agent.settings.embedding.model")}</Label>
  830. <Input
  831. value={embeddingForm.model}
  832. onChange={(e) =>
  833. setEmbeddingForm({ ...embeddingForm, model: e.target.value })
  834. }
  835. placeholder={t("agent.settings.embedding.modelPh")}
  836. />
  837. </div>
  838. </div>
  839. <div className="flex items-center gap-2">
  840. <Checkbox
  841. id="customer_can_use_kb"
  842. checked={embeddingForm.customer_can_use_kb}
  843. onCheckedChange={(checked) =>
  844. setEmbeddingForm({
  845. ...embeddingForm,
  846. customer_can_use_kb: checked === true,
  847. })
  848. }
  849. />
  850. <Label htmlFor="customer_can_use_kb" className="text-sm cursor-pointer">
  851. {t("agent.settings.embedding.customerKb")}
  852. </Label>
  853. </div>
  854. <Button type="submit" disabled={embeddingSubmitting}>
  855. {embeddingSubmitting
  856. ? t("common.saving")
  857. : t("agent.settings.embedding.save")}
  858. </Button>
  859. </form>
  860. )}
  861. </CardContent>
  862. </Card>
  863. {/* 联网搜索设置(与知识库向量模型独立;实际仍写入同一配置,仅 UI 分离) */}
  864. <Card>
  865. <CardHeader>
  866. <CardTitle>{t("agent.settings.webSearch.title")}</CardTitle>
  867. <p className="text-sm text-muted-foreground mt-1">
  868. {t("agent.settings.webSearch.lead")}
  869. </p>
  870. </CardHeader>
  871. <CardContent>
  872. {embeddingLoading ? (
  873. <div className="text-center py-6 text-muted-foreground">{t("common.loading")}</div>
  874. ) : (
  875. <form onSubmit={handleSaveEmbeddingConfig} className="space-y-4">
  876. {embeddingError && (
  877. <div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
  878. {embeddingError}
  879. </div>
  880. )}
  881. <div>
  882. <Label className="block text-sm font-medium mb-1">{t("agent.settings.webSearch.mode")}</Label>
  883. <select
  884. value={embeddingForm.web_search_source}
  885. onChange={(e) =>
  886. setEmbeddingForm({
  887. ...embeddingForm,
  888. web_search_source: e.target.value as "vendor" | "custom",
  889. })
  890. }
  891. className="w-full max-w-xs px-3 py-2 border border-input rounded-md text-sm bg-background"
  892. >
  893. <option value="custom">{t("agent.settings.webSearch.modeCustom")}</option>
  894. <option value="vendor">{t("agent.settings.webSearch.modeVendor")}</option>
  895. </select>
  896. <p className="text-xs text-muted-foreground mt-1">
  897. {t("agent.settings.webSearch.modeHint")}
  898. </p>
  899. </div>
  900. <div className="flex items-center gap-2">
  901. <Checkbox
  902. id="visitor_web_search_enabled_standalone"
  903. checked={embeddingForm.visitor_web_search_enabled}
  904. onCheckedChange={(checked) =>
  905. setEmbeddingForm({
  906. ...embeddingForm,
  907. visitor_web_search_enabled: checked === true,
  908. })
  909. }
  910. />
  911. <Label htmlFor="visitor_web_search_enabled_standalone" className="text-sm cursor-pointer">
  912. {t("agent.settings.webSearch.visitorToggle")}
  913. </Label>
  914. </div>
  915. <Button type="submit" disabled={embeddingSubmitting}>
  916. {embeddingSubmitting ? t("common.saving") : t("agent.settings.webSearch.save")}
  917. </Button>
  918. </form>
  919. )}
  920. </CardContent>
  921. </Card>
  922. {/* 配置表单 */}
  923. <Card>
  924. <CardHeader>
  925. <CardTitle>
  926. {editingId
  927. ? t("agent.settings.aiCard.titleEdit")
  928. : t("agent.settings.aiCard.titleAdd")}
  929. </CardTitle>
  930. </CardHeader>
  931. <CardContent>
  932. <form onSubmit={handleSubmit} className="space-y-4">
  933. {error && (
  934. <div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
  935. {error}
  936. </div>
  937. )}
  938. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  939. <div>
  940. <label className="block text-sm font-medium mb-1">
  941. {t("agent.settings.aiForm.provider")}{" "}
  942. <span className="text-red-500">*</span>
  943. </label>
  944. <Input
  945. value={formData.provider}
  946. onChange={(e) =>
  947. setFormData({ ...formData, provider: e.target.value })
  948. }
  949. placeholder={t("agent.settings.aiForm.providerPh")}
  950. required
  951. />
  952. </div>
  953. <div>
  954. <label className="block text-sm font-medium mb-1">
  955. {t("agent.settings.aiForm.apiUrl")}{" "}
  956. <span className="text-red-500">*</span>
  957. </label>
  958. <Input
  959. value={formData.api_url}
  960. onChange={(e) =>
  961. setFormData({ ...formData, api_url: e.target.value })
  962. }
  963. placeholder={t("agent.settings.aiForm.apiUrlPh")}
  964. required
  965. />
  966. </div>
  967. <div>
  968. <label className="block text-sm font-medium mb-1">
  969. {t("agent.settings.aiForm.apiKey")}{" "}
  970. <span className="text-red-500">*</span>
  971. </label>
  972. <Input
  973. type="password"
  974. value={formData.api_key}
  975. onChange={(e) =>
  976. setFormData({ ...formData, api_key: e.target.value })
  977. }
  978. placeholder={
  979. editingId
  980. ? t("agent.settings.embedding.apiKeyKeepEmpty")
  981. : t("agent.settings.embedding.apiKeyInput")
  982. }
  983. required={!editingId}
  984. />
  985. </div>
  986. <div>
  987. <label className="block text-sm font-medium mb-1">
  988. {t("agent.settings.aiForm.model")}{" "}
  989. <span className="text-red-500">*</span>
  990. </label>
  991. <Input
  992. value={formData.model}
  993. onChange={(e) =>
  994. setFormData({ ...formData, model: e.target.value })
  995. }
  996. placeholder={t("agent.settings.aiForm.modelPh")}
  997. required
  998. />
  999. </div>
  1000. <div>
  1001. <label className="block text-sm font-medium mb-1">
  1002. {t("agent.settings.aiForm.modelType")}
  1003. </label>
  1004. <select
  1005. value={formData.model_type}
  1006. onChange={(e) =>
  1007. setFormData({ ...formData, model_type: e.target.value })
  1008. }
  1009. className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
  1010. >
  1011. <option value="text">{t("agent.settings.modelType.text")}</option>
  1012. <option value="image">{t("agent.settings.modelType.image")}</option>
  1013. <option value="audio">{t("agent.settings.modelType.audio")}</option>
  1014. <option value="video">{t("agent.settings.modelType.video")}</option>
  1015. </select>
  1016. </div>
  1017. </div>
  1018. <div>
  1019. <label className="block text-sm font-medium mb-1">
  1020. {t("agent.settings.aiForm.description")}
  1021. </label>
  1022. <Input
  1023. value={formData.description}
  1024. onChange={(e) =>
  1025. setFormData({ ...formData, description: e.target.value })
  1026. }
  1027. placeholder={t("agent.settings.aiForm.descPh")}
  1028. />
  1029. </div>
  1030. <div className="flex items-center gap-4">
  1031. <label className="flex items-center gap-2">
  1032. <input
  1033. type="checkbox"
  1034. checked={formData.is_active}
  1035. onChange={(e) =>
  1036. setFormData({ ...formData, is_active: e.target.checked })
  1037. }
  1038. className="w-4 h-4"
  1039. />
  1040. <span className="text-sm">{t("agent.settings.aiForm.active")}</span>
  1041. </label>
  1042. <label className="flex items-center gap-2">
  1043. <input
  1044. type="checkbox"
  1045. checked={formData.is_public}
  1046. onChange={(e) =>
  1047. setFormData({ ...formData, is_public: e.target.checked })
  1048. }
  1049. className="w-4 h-4"
  1050. />
  1051. <span className="text-sm">{t("agent.settings.aiForm.public")}</span>
  1052. </label>
  1053. </div>
  1054. <div className="flex gap-2">
  1055. <Button type="submit" disabled={submitting}>
  1056. {submitting
  1057. ? t("agent.settings.aiForm.submitting")
  1058. : editingId
  1059. ? t("agent.settings.aiForm.submitUpdate")
  1060. : t("agent.settings.aiForm.submitCreate")}
  1061. </Button>
  1062. {editingId && (
  1063. <Button
  1064. type="button"
  1065. variant="outline"
  1066. onClick={resetForm}
  1067. >
  1068. {t("agent.common.cancel")}
  1069. </Button>
  1070. )}
  1071. </div>
  1072. </form>
  1073. </CardContent>
  1074. </Card>
  1075. {/* 配置列表 */}
  1076. <Card>
  1077. <CardHeader>
  1078. <CardTitle>{t("agent.settings.list.title")}</CardTitle>
  1079. </CardHeader>
  1080. <CardContent>
  1081. {loading ? (
  1082. <div className="text-center py-8 text-gray-500">
  1083. {t("common.loading")}
  1084. </div>
  1085. ) : configs.length === 0 ? (
  1086. <div className="text-center py-8 text-gray-500">
  1087. {t("agent.settings.list.empty")}
  1088. </div>
  1089. ) : (
  1090. <div className="space-y-4">
  1091. {configs.map((config) => (
  1092. <div
  1093. key={config.id}
  1094. className="p-4 border rounded-lg hover:shadow-md transition-shadow"
  1095. >
  1096. <div className="flex justify-between items-start">
  1097. <div className="flex-1">
  1098. <div className="flex items-center gap-2 mb-2">
  1099. <h3 className="font-semibold">
  1100. {config.provider} - {config.model}
  1101. </h3>
  1102. {config.is_active && (
  1103. <span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded">
  1104. {t("agent.settings.badge.active")}
  1105. </span>
  1106. )}
  1107. {config.is_public && (
  1108. <span className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded">
  1109. {t("agent.settings.badge.public")}
  1110. </span>
  1111. )}
  1112. </div>
  1113. <div className="text-sm text-gray-600 space-y-1">
  1114. <p>
  1115. <span className="font-medium">{t("agent.settings.list.apiUrlLabel")}</span>
  1116. {config.api_url}
  1117. </p>
  1118. <p>
  1119. <span className="font-medium">{t("agent.settings.list.modelTypeLabel")}</span>
  1120. {modelTypeLabel(config.model_type)}
  1121. </p>
  1122. {config.description && (
  1123. <p>
  1124. <span className="font-medium">{t("agent.settings.list.descLabel")}</span>
  1125. {config.description}
  1126. </p>
  1127. )}
  1128. </div>
  1129. </div>
  1130. <div className="flex gap-2">
  1131. <Button
  1132. size="sm"
  1133. variant="outline"
  1134. onClick={() => handleEdit(config)}
  1135. >
  1136. {t("agent.common.edit")}
  1137. </Button>
  1138. <Button
  1139. size="sm"
  1140. variant="destructive"
  1141. onClick={() => handleDelete(config.id)}
  1142. >
  1143. {t("agent.common.delete")}
  1144. </Button>
  1145. </div>
  1146. </div>
  1147. </div>
  1148. ))}
  1149. </div>
  1150. )}
  1151. </CardContent>
  1152. </Card>
  1153. </div>
  1154. </div>
  1155. );
  1156. // 如果是嵌入模式,只返回内容,不包含 ResponsiveLayout
  1157. if (embedded) {
  1158. return (
  1159. <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
  1160. {headerContent}
  1161. {mainContent}
  1162. </div>
  1163. );
  1164. }
  1165. return (
  1166. <ResponsiveLayout
  1167. main={mainContent}
  1168. header={headerContent}
  1169. />
  1170. );
  1171. }