page.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
  7. import { fetchPrompts, updatePrompt, type PromptItem } from "@/features/agent/services/promptsApi";
  8. import { toast } from "@/hooks/useToast";
  9. function getPlaceholderHint(key: string): string {
  10. switch (key) {
  11. case "rag_prompt":
  12. case "rag_prompt_with_web_optional":
  13. return "占位符:{{rag_context}} 为知识库检索内容,{{user_message}} 为用户问题。";
  14. case "no_kb_prompt":
  15. return "占位符:{{user_message}} 为用户问题。";
  16. case "web_search_result_prompt":
  17. return "占位符:{{web_context}} 为联网搜索结果,{{user_message}} 为用户问题。(当前流程未使用此模板)";
  18. case "no_source_reply":
  19. case "ai_fail_reply":
  20. return "无占位符,内容将作为完整回复语直接展示给用户。";
  21. default:
  22. return "请勿删除占位符,保存后由系统替换为实际内容。";
  23. }
  24. }
  25. /** 各提示词的使用场景说明(展示在卡片中) */
  26. function getUsageScenario(key: string): string {
  27. switch (key) {
  28. case "rag_prompt":
  29. return "有知识库检索结果,且本回合未勾选「联网搜索」时,用此模板拼成 prompt 发给模型。";
  30. case "rag_prompt_with_web_optional":
  31. return "有知识库检索结果且本回合勾选「联网搜索」时,用此模板并传入联网工具,由模型决定是否调用联网。";
  32. case "no_kb_prompt":
  33. return "没有知识库检索结果且本回合未走联网时,用此模板让模型仅凭自身知识回答。";
  34. case "web_search_result_prompt":
  35. return "预留:若将来有「先联网搜再拼成一段 prompt」的流程,会使用此模板。当前未使用。";
  36. case "no_source_reply":
  37. return "既未命中知识库、也未使用大模型或联网时(如用户关闭了所有数据源),直接向用户展示这句话。";
  38. case "ai_fail_reply":
  39. return "调用 AI 接口失败(超时、报错等)时,向用户展示这句话。";
  40. default:
  41. return "";
  42. }
  43. }
  44. function getTextareaMinHeight(key: string): string {
  45. return key === "no_source_reply" || key === "ai_fail_reply" ? "min-h-[80px]" : "min-h-[200px]";
  46. }
  47. export default function PromptsPage({ embedded = false }: { embedded?: boolean }) {
  48. const router = useRouter();
  49. const [userId, setUserId] = useState<number | null>(null);
  50. const [prompts, setPrompts] = useState<PromptItem[]>([]);
  51. const [loading, setLoading] = useState(true);
  52. const [savingKey, setSavingKey] = useState<string | null>(null);
  53. const [error, setError] = useState("");
  54. useEffect(() => {
  55. const storedUserId = localStorage.getItem("agent_user_id");
  56. if (!storedUserId) {
  57. router.push("/");
  58. return;
  59. }
  60. setUserId(Number.parseInt(storedUserId, 10));
  61. }, [router]);
  62. const loadPrompts = async () => {
  63. if (!userId) return;
  64. try {
  65. setLoading(true);
  66. setError("");
  67. const data = await fetchPrompts(userId);
  68. setPrompts(data);
  69. } catch (e) {
  70. console.error("加载提示词失败:", e);
  71. setError((e as Error).message || "加载提示词失败");
  72. } finally {
  73. setLoading(false);
  74. }
  75. };
  76. useEffect(() => {
  77. if (userId) loadPrompts();
  78. }, [userId]);
  79. const handleSave = async (key: string, content: string) => {
  80. if (!userId) return;
  81. setSavingKey(key);
  82. try {
  83. await updatePrompt(userId, key, content);
  84. toast.success("保存成功,将立即生效。");
  85. await loadPrompts();
  86. } catch (e) {
  87. toast.error((e as Error).message || "保存失败");
  88. } finally {
  89. setSavingKey(null);
  90. }
  91. };
  92. const handleContentChange = (key: string, content: string) => {
  93. setPrompts((prev) =>
  94. prev.map((p) => (p.key === key ? { ...p, content } : p))
  95. );
  96. };
  97. if (!userId) return null;
  98. const headerContent = (
  99. <div className="bg-card border-b p-4 shadow-sm">
  100. <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
  101. <div>
  102. <h1 className="text-xl font-bold text-foreground">提示词</h1>
  103. <div className="text-sm text-muted-foreground mt-1">
  104. 配置系统中使用的提示词模板,用于 RAG、联网等场景。仅管理员可修改。占位符说明见下方各卡片。
  105. </div>
  106. </div>
  107. {!embedded && (
  108. <Button
  109. onClick={() => router.push("/agent/dashboard")}
  110. variant="outline"
  111. size="sm"
  112. >
  113. 返回工作台
  114. </Button>
  115. )}
  116. </div>
  117. </div>
  118. );
  119. const mainContent = (
  120. <div className="flex-1 overflow-auto p-4 md:p-6">
  121. <div className="max-w-4xl mx-auto space-y-6">
  122. {error && (
  123. <div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
  124. {error}
  125. </div>
  126. )}
  127. {loading ? (
  128. <div className="text-center py-12 text-muted-foreground">加载中...</div>
  129. ) : (
  130. prompts.map((item) => (
  131. <Card key={item.key}>
  132. <CardHeader>
  133. <CardTitle className="text-base">{item.name}</CardTitle>
  134. {getUsageScenario(item.key) && (
  135. <p className="text-sm text-muted-foreground mt-1">
  136. <span className="font-medium">使用场景:</span>
  137. {getUsageScenario(item.key)}
  138. </p>
  139. )}
  140. <p className="text-xs text-muted-foreground mt-1">{getPlaceholderHint(item.key)}</p>
  141. </CardHeader>
  142. <CardContent className="space-y-3">
  143. <textarea
  144. className={`w-full ${getTextareaMinHeight(item.key)} px-3 py-2 border border-input rounded-md text-sm bg-background font-mono resize-y`}
  145. value={item.content}
  146. onChange={(e) => handleContentChange(item.key, e.target.value)}
  147. placeholder={item.key === "no_source_reply" || item.key === "ai_fail_reply" ? "请输入一句完整回复语" : "请输入提示词内容,保留占位符"}
  148. spellCheck={false}
  149. />
  150. <Button
  151. size="sm"
  152. onClick={() => handleSave(item.key, item.content)}
  153. disabled={savingKey === item.key}
  154. >
  155. {savingKey === item.key ? "保存中..." : "保存"}
  156. </Button>
  157. </CardContent>
  158. </Card>
  159. ))
  160. )}
  161. </div>
  162. </div>
  163. );
  164. if (embedded) {
  165. return (
  166. <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
  167. {headerContent}
  168. {mainContent}
  169. </div>
  170. );
  171. }
  172. return (
  173. <ResponsiveLayout header={headerContent} main={mainContent} />
  174. );
  175. }