page.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  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 { useProfile } from "@/features/agent/hooks/useProfile";
  24. import { API_BASE_URL } from "@/lib/config";
  25. import { Checkbox } from "@/components/ui/checkbox";
  26. import { Label } from "@/components/ui/label";
  27. import { toast } from "@/hooks/useToast";
  28. export default function SettingsPage(props: any = {}) {
  29. const { embedded = false } = props;
  30. const router = useRouter();
  31. const [userId, setUserId] = useState<number | null>(null);
  32. const [configs, setConfigs] = useState<AIConfig[]>([]);
  33. const [loading, setLoading] = useState(true);
  34. const [editingId, setEditingId] = useState<number | null>(null);
  35. const [formData, setFormData] = useState<CreateAIConfigRequest>({
  36. provider: "",
  37. api_url: "",
  38. api_key: "",
  39. model: "",
  40. model_type: "text",
  41. is_active: true,
  42. is_public: false,
  43. description: "",
  44. });
  45. const [submitting, setSubmitting] = useState(false);
  46. const [error, setError] = useState("");
  47. // 知识库向量配置(平台级,仅管理员可修改)
  48. const [embeddingConfig, setEmbeddingConfig] = useState<EmbeddingConfig | null>(null);
  49. const [embeddingForm, setEmbeddingForm] = useState({
  50. embedding_type: "openai",
  51. api_url: "",
  52. api_key: "",
  53. model: "text-embedding-3-small",
  54. customer_can_use_kb: true,
  55. });
  56. const [embeddingLoading, setEmbeddingLoading] = useState(false);
  57. const [embeddingSubmitting, setEmbeddingSubmitting] = useState(false);
  58. const [embeddingError, setEmbeddingError] = useState("");
  59. // 检查登录状态
  60. useEffect(() => {
  61. const storedUserId = localStorage.getItem("agent_user_id");
  62. if (!storedUserId) {
  63. router.push("/");
  64. return;
  65. }
  66. setUserId(Number.parseInt(storedUserId, 10));
  67. }, [router]);
  68. // 加载个人资料(用于获取和更新 AI 对话接收设置)
  69. const {
  70. profile,
  71. loading: profileLoading,
  72. update: updateProfile,
  73. } = useProfile({
  74. userId: userId ?? null,
  75. enabled: Boolean(userId),
  76. });
  77. // 加载配置列表
  78. const loadConfigs = async () => {
  79. if (!userId) return;
  80. try {
  81. setLoading(true);
  82. const data = await fetchAIConfigs(userId);
  83. setConfigs(data);
  84. } catch (error) {
  85. console.error("加载配置失败:", error);
  86. setError("加载配置失败");
  87. } finally {
  88. setLoading(false);
  89. }
  90. };
  91. useEffect(() => {
  92. if (userId) {
  93. loadConfigs();
  94. }
  95. }, [userId]);
  96. // 加载知识库向量配置
  97. const loadEmbeddingConfig = async () => {
  98. if (!userId) return;
  99. try {
  100. setEmbeddingLoading(true);
  101. const data = await fetchEmbeddingConfig(userId);
  102. setEmbeddingConfig(data);
  103. setEmbeddingForm({
  104. embedding_type: data.embedding_type || "openai",
  105. api_url: data.api_url || "",
  106. api_key: "",
  107. model: data.model || "text-embedding-3-small",
  108. customer_can_use_kb: data.customer_can_use_kb ?? true,
  109. });
  110. } catch (e) {
  111. console.error("加载知识库向量配置失败:", e);
  112. setEmbeddingError("加载失败");
  113. } finally {
  114. setEmbeddingLoading(false);
  115. }
  116. };
  117. useEffect(() => {
  118. if (userId) {
  119. loadEmbeddingConfig();
  120. }
  121. }, [userId]);
  122. // 保存知识库向量配置(仅管理员;保存后立即生效,无需重启)
  123. const handleSaveEmbeddingConfig = async (e: React.FormEvent) => {
  124. e.preventDefault();
  125. if (!userId) return;
  126. setEmbeddingSubmitting(true);
  127. setEmbeddingError("");
  128. try {
  129. const data: UpdateEmbeddingConfigRequest = {
  130. embedding_type: embeddingForm.embedding_type,
  131. api_url: embeddingForm.api_url || undefined,
  132. model: embeddingForm.model || undefined,
  133. customer_can_use_kb: embeddingForm.customer_can_use_kb,
  134. };
  135. if (embeddingForm.api_key) {
  136. data.api_key = embeddingForm.api_key;
  137. }
  138. await updateEmbeddingConfig(userId, data);
  139. await loadEmbeddingConfig();
  140. toast.success("保存成功,配置已立即生效。");
  141. } catch (err) {
  142. setEmbeddingError((err as Error).message);
  143. } finally {
  144. setEmbeddingSubmitting(false);
  145. }
  146. };
  147. // 重置表单
  148. const resetForm = () => {
  149. setFormData({
  150. provider: "",
  151. api_url: "",
  152. api_key: "",
  153. model: "",
  154. model_type: "text",
  155. is_active: true,
  156. is_public: false,
  157. description: "",
  158. });
  159. setEditingId(null);
  160. setError("");
  161. };
  162. // 开始编辑
  163. const handleEdit = (config: AIConfig) => {
  164. setFormData({
  165. provider: config.provider,
  166. api_url: config.api_url,
  167. api_key: "", // 不显示 API Key(已加密)
  168. model: config.model,
  169. model_type: config.model_type,
  170. is_active: config.is_active,
  171. is_public: config.is_public,
  172. description: config.description,
  173. });
  174. setEditingId(config.id);
  175. };
  176. // 提交表单
  177. const handleSubmit = async (e: React.FormEvent) => {
  178. e.preventDefault();
  179. if (!userId) return;
  180. setSubmitting(true);
  181. setError("");
  182. try {
  183. if (editingId) {
  184. // 更新配置
  185. const updateData: UpdateAIConfigRequest = {
  186. provider: formData.provider,
  187. api_url: formData.api_url,
  188. model: formData.model,
  189. model_type: formData.model_type,
  190. is_active: formData.is_active,
  191. is_public: formData.is_public,
  192. description: formData.description,
  193. };
  194. // 如果提供了新的 API Key,才更新
  195. if (formData.api_key) {
  196. updateData.api_key = formData.api_key;
  197. }
  198. await updateAIConfig(userId, editingId, updateData);
  199. } else {
  200. // 创建配置
  201. await createAIConfig(userId, formData);
  202. }
  203. resetForm();
  204. await loadConfigs();
  205. } catch (error) {
  206. setError((error as Error).message || "操作失败");
  207. } finally {
  208. setSubmitting(false);
  209. }
  210. };
  211. // 删除配置
  212. const handleDelete = async (id: number) => {
  213. if (!userId) return;
  214. if (!confirm("确定要删除这个配置吗?")) return;
  215. try {
  216. await deleteAIConfig(userId, id);
  217. await loadConfigs();
  218. } catch (error) {
  219. setError((error as Error).message || "删除失败");
  220. }
  221. };
  222. // 退出登录
  223. const handleLogout = async () => {
  224. try {
  225. await fetch(`${API_BASE_URL}/logout`, { method: "POST" });
  226. } catch (error) {
  227. console.error("退出登录失败:", error);
  228. } finally {
  229. localStorage.removeItem("agent_user_id");
  230. localStorage.removeItem("agent_username");
  231. localStorage.removeItem("agent_role");
  232. router.push("/");
  233. }
  234. };
  235. if (!userId) {
  236. return null;
  237. }
  238. // 构建头部内容
  239. const headerContent = (
  240. <div className="bg-card border-b p-4 shadow-sm">
  241. <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
  242. <div>
  243. <h1 className="text-xl font-bold text-foreground">AI 配置管理</h1>
  244. <div className="text-sm text-muted-foreground mt-1">管理 AI 服务商配置</div>
  245. </div>
  246. {!embedded && (
  247. <div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
  248. <Button
  249. onClick={() => router.push("/agent/dashboard")}
  250. variant="outline"
  251. size="sm"
  252. className="w-full sm:w-auto"
  253. >
  254. 返回工作台
  255. </Button>
  256. <Button
  257. onClick={handleLogout}
  258. variant="outline"
  259. size="sm"
  260. className="w-full sm:w-auto"
  261. >
  262. 退出登录
  263. </Button>
  264. </div>
  265. )}
  266. </div>
  267. </div>
  268. );
  269. // 构建主内容区
  270. const mainContent = (
  271. <div className="flex-1 overflow-auto p-4 md:p-6">
  272. <div className="max-w-6xl mx-auto space-y-6">
  273. {/* 全局设置 */}
  274. <Card>
  275. <CardHeader>
  276. <CardTitle>全局设置</CardTitle>
  277. </CardHeader>
  278. <CardContent>
  279. <div className="flex items-center space-x-2">
  280. <Checkbox
  281. id="receive_ai_conversations"
  282. checked={!(profile?.receive_ai_conversations ?? false)}
  283. onCheckedChange={async (checked) => {
  284. if (userId) {
  285. try {
  286. await updateProfile({
  287. receive_ai_conversations: !checked,
  288. });
  289. } catch (error) {
  290. console.error("更新设置失败:", error);
  291. toast.error("更新设置失败,请重试");
  292. }
  293. }
  294. }}
  295. disabled={profileLoading}
  296. />
  297. <Label
  298. htmlFor="receive_ai_conversations"
  299. className="text-sm font-medium cursor-pointer"
  300. >
  301. 客服不接收 AI 对话
  302. </Label>
  303. </div>
  304. <p className="text-xs text-muted-foreground mt-2">
  305. 开启后,AI 对话将不会显示在对话列表中,也不会收到 AI 消息通知。
  306. 但您仍可以在会话页面手动开启&quot;显示 AI 消息&quot;来查看 AI 对话历史。
  307. </p>
  308. </CardContent>
  309. </Card>
  310. {/* 知识库向量模型(平台级,仅管理员可修改;保存后立即生效) */}
  311. <Card>
  312. <CardHeader>
  313. <CardTitle>知识库向量模型</CardTitle>
  314. <p className="text-sm text-muted-foreground mt-1">
  315. 用于知识库文档向量化与 RAG 检索。仅管理员可修改;保存后立即生效,无需重启。
  316. </p>
  317. </CardHeader>
  318. <CardContent>
  319. {embeddingLoading ? (
  320. <div className="text-center py-6 text-muted-foreground">加载中...</div>
  321. ) : (
  322. <form onSubmit={handleSaveEmbeddingConfig} className="space-y-4">
  323. {embeddingError && (
  324. <div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
  325. {embeddingError}
  326. </div>
  327. )}
  328. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  329. <div>
  330. <Label className="block text-sm font-medium mb-1">类型</Label>
  331. <select
  332. value={embeddingForm.embedding_type}
  333. onChange={(e) =>
  334. setEmbeddingForm({ ...embeddingForm, embedding_type: e.target.value })
  335. }
  336. className="w-full px-3 py-2 border border-input rounded-md text-sm bg-background"
  337. >
  338. <option value="openai">OpenAI / 兼容 API</option>
  339. <option value="bge">BGE 本地</option>
  340. </select>
  341. </div>
  342. <div>
  343. <Label className="block text-sm font-medium mb-1">API 地址</Label>
  344. <Input
  345. value={embeddingForm.api_url}
  346. onChange={(e) =>
  347. setEmbeddingForm({ ...embeddingForm, api_url: e.target.value })
  348. }
  349. placeholder="https://api.openai.com/v1 或兼容地址"
  350. />
  351. </div>
  352. <div>
  353. <Label className="block text-sm font-medium mb-1">API Key</Label>
  354. <Input
  355. type="password"
  356. value={embeddingForm.api_key}
  357. onChange={(e) =>
  358. setEmbeddingForm({ ...embeddingForm, api_key: e.target.value })
  359. }
  360. placeholder={embeddingConfig?.api_key_masked ? "留空则不更新" : "输入 API Key"}
  361. />
  362. </div>
  363. <div>
  364. <Label className="block text-sm font-medium mb-1">模型</Label>
  365. <Input
  366. value={embeddingForm.model}
  367. onChange={(e) =>
  368. setEmbeddingForm({ ...embeddingForm, model: e.target.value })
  369. }
  370. placeholder="text-embedding-3-small"
  371. />
  372. </div>
  373. </div>
  374. <div className="flex items-center gap-2">
  375. <Checkbox
  376. id="customer_can_use_kb"
  377. checked={embeddingForm.customer_can_use_kb}
  378. onCheckedChange={(checked) =>
  379. setEmbeddingForm({
  380. ...embeddingForm,
  381. customer_can_use_kb: checked === true,
  382. })
  383. }
  384. />
  385. <Label htmlFor="customer_can_use_kb" className="text-sm cursor-pointer">
  386. 开放知识库给客服使用(允许创建知识库、上传文档、对话中引用)
  387. </Label>
  388. </div>
  389. <Button type="submit" disabled={embeddingSubmitting}>
  390. {embeddingSubmitting ? "保存中..." : "保存配置"}
  391. </Button>
  392. </form>
  393. )}
  394. </CardContent>
  395. </Card>
  396. {/* 配置表单 */}
  397. <Card>
  398. <CardHeader>
  399. <CardTitle>
  400. {editingId ? "编辑 AI 配置" : "添加 AI 配置"}
  401. </CardTitle>
  402. </CardHeader>
  403. <CardContent>
  404. <form onSubmit={handleSubmit} className="space-y-4">
  405. {error && (
  406. <div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
  407. {error}
  408. </div>
  409. )}
  410. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  411. <div>
  412. <label className="block text-sm font-medium mb-1">
  413. 服务商名称 <span className="text-red-500">*</span>
  414. </label>
  415. <Input
  416. value={formData.provider}
  417. onChange={(e) =>
  418. setFormData({ ...formData, provider: e.target.value })
  419. }
  420. placeholder="例如:OpenAI、Claude、自定义"
  421. required
  422. />
  423. </div>
  424. <div>
  425. <label className="block text-sm font-medium mb-1">
  426. API 地址 <span className="text-red-500">*</span>
  427. </label>
  428. <Input
  429. value={formData.api_url}
  430. onChange={(e) =>
  431. setFormData({ ...formData, api_url: e.target.value })
  432. }
  433. placeholder="https://api.openai.com/v1/chat/completions"
  434. required
  435. />
  436. </div>
  437. <div>
  438. <label className="block text-sm font-medium mb-1">
  439. API Key <span className="text-red-500">*</span>
  440. </label>
  441. <Input
  442. type="password"
  443. value={formData.api_key}
  444. onChange={(e) =>
  445. setFormData({ ...formData, api_key: e.target.value })
  446. }
  447. placeholder={editingId ? "留空则不更新" : "输入 API Key"}
  448. required={!editingId}
  449. />
  450. </div>
  451. <div>
  452. <label className="block text-sm font-medium mb-1">
  453. 模型名称 <span className="text-red-500">*</span>
  454. </label>
  455. <Input
  456. value={formData.model}
  457. onChange={(e) =>
  458. setFormData({ ...formData, model: e.target.value })
  459. }
  460. placeholder="例如:gpt-3.5-turbo、gpt-4"
  461. required
  462. />
  463. </div>
  464. <div>
  465. <label className="block text-sm font-medium mb-1">
  466. 模型类型
  467. </label>
  468. <select
  469. value={formData.model_type}
  470. onChange={(e) =>
  471. setFormData({ ...formData, model_type: e.target.value })
  472. }
  473. className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
  474. >
  475. <option value="text">文本</option>
  476. <option value="image">图片</option>
  477. <option value="audio">语音</option>
  478. <option value="video">视频</option>
  479. </select>
  480. </div>
  481. </div>
  482. <div>
  483. <label className="block text-sm font-medium mb-1">
  484. 配置描述
  485. </label>
  486. <Input
  487. value={formData.description}
  488. onChange={(e) =>
  489. setFormData({ ...formData, description: e.target.value })
  490. }
  491. placeholder="例如:OpenAI GPT-3.5 Turbo 模型"
  492. />
  493. </div>
  494. <div className="flex items-center gap-4">
  495. <label className="flex items-center gap-2">
  496. <input
  497. type="checkbox"
  498. checked={formData.is_active}
  499. onChange={(e) =>
  500. setFormData({ ...formData, is_active: e.target.checked })
  501. }
  502. className="w-4 h-4"
  503. />
  504. <span className="text-sm">启用配置</span>
  505. </label>
  506. <label className="flex items-center gap-2">
  507. <input
  508. type="checkbox"
  509. checked={formData.is_public}
  510. onChange={(e) =>
  511. setFormData({ ...formData, is_public: e.target.checked })
  512. }
  513. className="w-4 h-4"
  514. />
  515. <span className="text-sm">开放给访客使用</span>
  516. </label>
  517. </div>
  518. <div className="flex gap-2">
  519. <Button type="submit" disabled={submitting}>
  520. {submitting
  521. ? "提交中..."
  522. : editingId
  523. ? "更新配置"
  524. : "创建配置"}
  525. </Button>
  526. {editingId && (
  527. <Button
  528. type="button"
  529. variant="outline"
  530. onClick={resetForm}
  531. >
  532. 取消
  533. </Button>
  534. )}
  535. </div>
  536. </form>
  537. </CardContent>
  538. </Card>
  539. {/* 配置列表 */}
  540. <Card>
  541. <CardHeader>
  542. <CardTitle>已配置的 AI 服务</CardTitle>
  543. </CardHeader>
  544. <CardContent>
  545. {loading ? (
  546. <div className="text-center py-8 text-gray-500">
  547. 加载中...
  548. </div>
  549. ) : configs.length === 0 ? (
  550. <div className="text-center py-8 text-gray-500">
  551. 暂无配置,请添加
  552. </div>
  553. ) : (
  554. <div className="space-y-4">
  555. {configs.map((config) => (
  556. <div
  557. key={config.id}
  558. className="p-4 border rounded-lg hover:shadow-md transition-shadow"
  559. >
  560. <div className="flex justify-between items-start">
  561. <div className="flex-1">
  562. <div className="flex items-center gap-2 mb-2">
  563. <h3 className="font-semibold">
  564. {config.provider} - {config.model}
  565. </h3>
  566. {config.is_active && (
  567. <span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded">
  568. 启用
  569. </span>
  570. )}
  571. {config.is_public && (
  572. <span className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded">
  573. 开放
  574. </span>
  575. )}
  576. </div>
  577. <div className="text-sm text-gray-600 space-y-1">
  578. <p>
  579. <span className="font-medium">API 地址:</span>
  580. {config.api_url}
  581. </p>
  582. <p>
  583. <span className="font-medium">模型类型:</span>
  584. {config.model_type}
  585. </p>
  586. {config.description && (
  587. <p>
  588. <span className="font-medium">描述:</span>
  589. {config.description}
  590. </p>
  591. )}
  592. </div>
  593. </div>
  594. <div className="flex gap-2">
  595. <Button
  596. size="sm"
  597. variant="outline"
  598. onClick={() => handleEdit(config)}
  599. >
  600. 编辑
  601. </Button>
  602. <Button
  603. size="sm"
  604. variant="destructive"
  605. onClick={() => handleDelete(config.id)}
  606. >
  607. 删除
  608. </Button>
  609. </div>
  610. </div>
  611. </div>
  612. ))}
  613. </div>
  614. )}
  615. </CardContent>
  616. </Card>
  617. </div>
  618. </div>
  619. );
  620. // 如果是嵌入模式,只返回内容,不包含 ResponsiveLayout
  621. if (embedded) {
  622. return (
  623. <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
  624. {headerContent}
  625. {mainContent}
  626. </div>
  627. );
  628. }
  629. return (
  630. <ResponsiveLayout
  631. main={mainContent}
  632. header={headerContent}
  633. />
  634. );
  635. }