feat: add save feedback prompts, featured agent fields, dynamic Dify API config, and chat improvements

- Add success/error feedback messages near submit buttons in all admin forms
- Display success prompt for 1s before redirect after save
- Show API error details on save failure
- Add isFeatured/featuredOrder fields to Agent model and admin UI
- Add difyApiUrl/difyApiKey fields for per-agent Dify API configuration
- Show featured badge column in agent admin list
- Display featured agents on homepage sorted by order
- Refactor chat page streaming with AbortController and stable userId
- Improve Dify API proxy to use per-agent credentials
This commit is contained in:
root
2026-05-08 20:15:54 +08:00
parent 362c37fb42
commit f2d7037ca2
16 changed files with 298 additions and 102 deletions
+83 -2
View File
@@ -18,7 +18,11 @@ export default function AgentForm({
features?: string
hotQuestions?: string
quickQuestions?: string
difyApiUrl?: string
difyApiKey?: string
status?: string
isFeatured?: boolean
featuredOrder?: number
}
}) {
const router = useRouter()
@@ -31,10 +35,15 @@ export default function AgentForm({
features: agent?.features || "",
hotQuestions: agent?.hotQuestions ? JSON.parse(agent.hotQuestions).join('\n') : '',
quickQuestions: agent?.quickQuestions ? JSON.parse(agent.quickQuestions).join('\n') : '',
difyApiUrl: agent?.difyApiUrl || "",
difyApiKey: agent?.difyApiKey || "",
status: agent?.status || "active",
isFeatured: agent?.isFeatured ?? false,
featuredOrder: agent?.featuredOrder ?? 0,
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -50,6 +59,8 @@ export default function AgentForm({
const body = {
...formData,
isFeatured: formData.isFeatured,
featuredOrder: formData.featuredOrder,
hotQuestions: JSON.stringify(formData.hotQuestions.split('\n').filter(q => q.trim())),
quickQuestions: JSON.stringify(formData.quickQuestions.split('\n').filter(q => q.trim())),
}
@@ -61,9 +72,12 @@ export default function AgentForm({
})
if (res.ok) {
setSuccess(true)
await new Promise(r => setTimeout(r, 1000))
router.push("/admin/agents")
} else {
setError("保存失败")
const data = await res.json().catch(() => ({}))
setError(data.error || "保存失败")
}
} catch (err) {
setError("保存失败,请稍后重试")
@@ -185,6 +199,34 @@ export default function AgentForm({
/>
</div>
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-orange-800 mb-3"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.isFeatured}
onChange={(e) => setFormData({ ...formData, isFeatured: e.target.checked })}
className="w-5 h-5 text-orange-600 border-gray-300 rounded focus:ring-orange-500"
/>
<span className="text-sm font-medium text-gray-700"></span>
</label>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="number"
min={0}
value={formData.featuredOrder}
onChange={(e) => setFormData({ ...formData, featuredOrder: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="0"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
@@ -212,10 +254,49 @@ export default function AgentForm({
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Dify API
</label>
<input
type="text"
value={formData.difyApiUrl}
onChange={(e) => setFormData({ ...formData, difyApiUrl: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="https://api.dify.ai/v1"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Dify API Key
</label>
<input
type="password"
value={formData.difyApiKey}
onChange={(e) => setFormData({ ...formData, difyApiKey: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="app-xxxxxxxxxxxx"
/>
</div>
</div>
{success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg text-sm">
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="flex gap-4">
<button
type="submit"
disabled={loading}
disabled={loading || success}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-blue-400"
>
{loading ? "保存中..." : "保存"}
+4
View File
@@ -51,7 +51,11 @@ export default async function EditAgentPage({
features: agent.features,
hotQuestions: agent.hotQuestions,
quickQuestions: agent.quickQuestions,
difyApiUrl: agent.difyApiUrl || "",
difyApiKey: agent.difyApiKey || "",
status: agent.status,
isFeatured: agent.isFeatured,
featuredOrder: agent.featuredOrder,
}}
/>
</div>
+10
View File
@@ -39,6 +39,7 @@ export default async function AdminAgentsPage() {
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">使</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>
@@ -59,6 +60,15 @@ export default async function AdminAgentsPage() {
<td className="px-6 py-4 text-sm text-gray-600">
{agent.category?.name || "-"}
</td>
<td className="px-6 py-4">
{agent.isFeatured ? (
<span className="inline-flex px-2 py-1 text-xs font-medium rounded-full bg-orange-100 text-orange-700">
</span>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
<td className="px-6 py-4">
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
agent.status === "active"
+18 -2
View File
@@ -21,6 +21,7 @@ export default function CategoryForm({
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -41,9 +42,12 @@ export default function CategoryForm({
})
if (res.ok) {
setSuccess(true)
await new Promise(r => setTimeout(r, 1000))
router.push("/admin/categories")
} else {
setError("保存失败")
const data = await res.json().catch(() => ({}))
setError(data.error || "保存失败")
}
} catch (err) {
setError("保存失败,请稍后重试")
@@ -100,10 +104,22 @@ export default function CategoryForm({
/>
</div>
{success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg text-sm">
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="flex gap-4">
<button
type="submit"
disabled={loading}
disabled={loading || success}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-blue-400"
>
{loading ? "保存中..." : "保存"}
+18 -2
View File
@@ -23,6 +23,7 @@ export default function NewsForm({
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -43,9 +44,12 @@ export default function NewsForm({
})
if (res.ok) {
setSuccess(true)
await new Promise(r => setTimeout(r, 1000))
router.push("/admin/news")
} else {
setError("保存失败")
const data = await res.json().catch(() => ({}))
setError(data.error || "保存失败")
}
} catch (err) {
setError("保存失败,请稍后重试")
@@ -121,10 +125,22 @@ export default function NewsForm({
</div>
</div>
{success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg text-sm">
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="flex gap-4">
<button
type="submit"
disabled={loading}
disabled={loading || success}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-blue-400"
>
{loading ? "保存中..." : "保存"}
+42 -42
View File
@@ -48,8 +48,10 @@ export default function AgentChatPage() {
const [currentConversationId, setCurrentConversationId] = useState<number | null>(null)
const [upstreamConversationId, setUpstreamConversationId] = useState<string>('')
const [loadingConversations, setLoadingConversations] = useState(false)
const [abortController, setAbortController] = useState<AbortController | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const userIdRef = useRef<string>('user-' + Math.random().toString(36).slice(2, 10))
const CHAT_API_URL = '/api/chat'
@@ -190,8 +192,6 @@ export default function AgentChatPage() {
})
const assistantMessageId = (Date.now() + 1).toString()
let assistantContent = ''
let upstreamConvId = upstreamConversationId
setMessages(prev => [...prev, {
id: assistantMessageId,
@@ -200,70 +200,70 @@ export default function AgentChatPage() {
timestamp: new Date(),
}])
const controller = new AbortController()
setAbortController(controller)
const response = await fetch(CHAT_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: agent.id,
inputs: {},
query: currentInput,
response_mode: 'streaming',
conversation_id: upstreamConversationId,
user: 'user-' + Date.now(),
user: userIdRef.current,
}),
signal: controller.signal,
})
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (!response.ok) {
throw new Error('Chat request failed')
}
if (reader) {
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const json = JSON.parse(line.slice(6))
if (json.conversation_id) {
upstreamConvId = json.conversation_id
}
const token = json.answer || json.message || ''
if (token) {
assistantContent += token
setMessages(prev => prev.map(msg =>
msg.id === assistantMessageId
? { ...msg, content: assistantContent }
: msg
))
}
} catch (e) {
// ignore parse errors
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
let accumulatedContent = ''
let finalConvId = upstreamConversationId
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6))
if (data.event === 'message' && data.answer) {
accumulatedContent += data.answer
setMessages(prev => prev.map(msg =>
msg.id === assistantMessageId
? { ...msg, content: accumulatedContent }
: msg
))
} else if (data.event === 'message_end') {
finalConvId = data.conversation_id || finalConvId
}
} catch (e) {
// skip invalid JSON
}
}
}
}
setUpstreamConversationId(upstreamConvId)
if (!assistantContent) {
setMessages(prev => prev.map(msg =>
msg.id === assistantMessageId
? { ...msg, content: '抱歉,我暂时无法回答这个问题。' }
: msg
))
assistantContent = '抱歉,我暂时无法回答这个问题。'
}
setUpstreamConversationId(finalConvId)
await fetch(`/api/conversations/${conversationId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role: 'assistant',
content: assistantContent,
content: accumulatedContent,
}),
})
} catch (error) {
+4
View File
@@ -17,10 +17,14 @@ export async function PUT(
description: data.description,
icon: data.icon || null,
categoryId: data.categoryId ? parseInt(data.categoryId) : null,
difyApiUrl: data.difyApiUrl || null,
difyApiKey: data.difyApiKey || null,
features: data.features || "",
hotQuestions: data.hotQuestions || "[]",
quickQuestions: data.quickQuestions || "[]",
status: data.status || "active",
isFeatured: data.isFeatured ?? false,
featuredOrder: data.featuredOrder ?? 0,
},
})
return NextResponse.json(agent)
+4
View File
@@ -12,10 +12,14 @@ export async function POST(request: NextRequest) {
description: data.description,
icon: data.icon || null,
categoryId: data.categoryId ? parseInt(data.categoryId) : null,
difyApiUrl: data.difyApiUrl || null,
difyApiKey: data.difyApiKey || null,
features: data.features || "",
hotQuestions: data.hotQuestions || "[]",
quickQuestions: data.quickQuestions || "[]",
status: data.status || "active",
isFeatured: data.isFeatured ?? false,
featuredOrder: data.featuredOrder ?? 0,
},
})
return NextResponse.json(agent)
+37 -19
View File
@@ -1,33 +1,51 @@
import { NextResponse } from "next/server"
import { prisma } from "@/app/lib/prisma"
const API_KEY = 'app-lbe2lglt7taGtZk0dG7pAhbx'
const API_URL = 'http://df.clkeji.com/v1/chat-messages'
const FALLBACK_API_KEY = 'app-lbe2lglt7taGtZk0dG7pAhbx'
const FALLBACK_API_URL = 'http://df.clkeji.com/v1/chat-messages'
export async function POST(request: Request) {
try {
const body = await request.json()
const { agentId, ...difyBody } = body
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
let apiKey = FALLBACK_API_KEY
let apiUrl = FALLBACK_API_URL
if (body.response_mode === 'streaming') {
return new NextResponse(response.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
if (agentId) {
const agent = await prisma.agent.findUnique({
where: { id: parseInt(agentId) },
})
if (agent?.difyApiUrl && agent?.difyApiKey) {
apiUrl = agent.difyApiUrl.replace(/\/+$/, '') + '/chat-messages'
apiKey = agent.difyApiKey
}
}
const data = await response.json()
return NextResponse.json(data)
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ ...difyBody, response_mode: 'streaming' }),
})
if (!response.ok) {
const text = await response.text()
console.error('Dify API error:', response.status, text.slice(0, 500))
return NextResponse.json(
{ error: 'Dify request failed' },
{ status: 502 }
)
}
const headers = new Headers()
headers.set('Content-Type', 'text/event-stream')
headers.set('Cache-Control', 'no-cache')
headers.set('Connection', 'keep-alive')
return new Response(response.body, { headers })
} catch (error) {
console.error('Chat API error:', error)
return NextResponse.json(
+2
View File
@@ -4,7 +4,9 @@ import Image from "next/image"
export default async function HomePage() {
const agents = await prisma.agent.findMany({
where: { isFeatured: true },
include: { category: true },
orderBy: { featuredOrder: "asc" },
take: 6,
})
+3
View File
@@ -21,6 +21,9 @@
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.13.1",
BIN
View File
Binary file not shown.
@@ -0,0 +1,29 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Agent" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT NOT NULL,
"icon" TEXT,
"categoryId" INTEGER,
"features" TEXT NOT NULL DEFAULT '',
"hotQuestions" TEXT NOT NULL DEFAULT '[]',
"quickQuestions" TEXT NOT NULL DEFAULT '[]',
"difyApiUrl" TEXT,
"difyApiKey" TEXT,
"usageCount" INTEGER NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'active',
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"featuredOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Agent_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Agent" ("categoryId", "createdAt", "description", "features", "icon", "id", "name", "slug", "status", "updatedAt", "usageCount") SELECT "categoryId", "createdAt", "description", "features", "icon", "id", "name", "slug", "status", "updatedAt", "usageCount" FROM "Agent";
DROP TABLE "Agent";
ALTER TABLE "new_Agent" RENAME TO "Agent";
CREATE UNIQUE INDEX "Agent_slug_key" ON "Agent"("slug");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+4
View File
@@ -39,8 +39,12 @@ model Agent {
features String @default("")
hotQuestions String @default("[]")
quickQuestions String @default("[]")
difyApiUrl String?
difyApiKey String?
usageCount Int @default(0)
status String @default("active")
isFeatured Boolean @default(false)
featuredOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
category Category? @relation(fields: [categoryId], references: [id])
+40 -21
View File
@@ -113,20 +113,37 @@ async function main() {
]),
usageCount: 1000,
status: "active",
isFeatured: true,
featuredOrder: 1,
difyApiUrl: "https://api.dify.ai/v1",
difyApiKey: "app-demo-key-123456",
},
}),
prisma.agent.upsert({
where: { slug: "writing-assistant-pro" },
where: { slug: "huaiqi-secretary" },
update: {},
create: {
name: "写作助手 Pro",
slug: "writing-assistant-pro",
description: "营销文案、博客文章、邮件草稿,输入关键词即可生成高质量内容。",
icon: "✍️",
categoryId: categories[1].id,
features: "营销文案, 博客文章, 邮件草稿",
name: "淮企小秘书",
slug: "huaiqi-secretary",
description: "专为淮安企业打造的智能秘书,提供政策解读、企业办事指南、惠企政策查询等一站式服务,助力企业高效运营。",
icon: "🏢",
categoryId: categories[6].id,
features: "政策解读, 企业办事指南, 惠企政策, 智能问答",
hotQuestions: JSON.stringify([
"到淮安这边投资,项目审批快吗?",
"目前淮安对绿色工厂有哪些支持政策?",
"请问企业实施技术改造项目,淮安市有哪些支持政策?",
"淮安有哪些产业配套服务?",
]),
quickQuestions: JSON.stringify([
"惠企政策查询",
"企业办事指南",
"政策解读",
]),
usageCount: 850,
status: "active",
isFeatured: true,
featuredOrder: 2,
},
}),
prisma.agent.upsert({
@@ -141,6 +158,8 @@ async function main() {
features: "数据清洗, 数据可视化, 分析报告",
usageCount: 620,
status: "active",
isFeatured: true,
featuredOrder: 3,
},
}),
prisma.agent.upsert({
@@ -185,20 +204,20 @@ async function main() {
status: "active",
},
}),
prisma.agent.upsert({
where: { slug: "group-policy-ai-assistant" },
update: {},
create: {
name: "集团制度AI助手",
slug: "group-policy-ai-assistant",
description: "专注于集团规章制度解读与咨询,快速查询、解读各类制度文件,助力合规管理与制度落地。",
icon: "📋",
categoryId: categories[6].id,
features: "制度解读, 合规咨询, 文件查询, 制度培训",
usageCount: 50,
status: "active",
},
}),
prisma.agent.upsert({
where: { slug: "group-policy-ai-assistant" },
update: {},
create: {
name: "集团制度AI助手",
slug: "group-policy-ai-assistant",
description: "专注于集团规章制度解读与咨询,快速查询、解读各类制度文件,助力合规管理与制度落地。",
icon: "📋",
categoryId: categories[6].id,
features: "制度解读, 合规咨询, 文件查询, 制度培训",
usageCount: 50,
status: "active",
},
}),
])
console.log("Created agents:", agents.map(a => a.name).join(", "))
-14
View File
@@ -1,14 +0,0 @@
发送对话消息接口如下,该接口能正常调用
curl -X POST 'http://df.clkeji.com/v1/chat-messages' \
--header 'Authorization: Bearer app-lbe2lglt7taGtZk0dG7pAhbx' \
--header 'Content-Type: application/json' \
--data '{
"inputs": {},
"query": "What are the specs of the iPhone 13 Pro Max?",
"response_mode": "streaming",
"conversation_id": "",
"user": "abc-123"
}'
问题描述:点击对话,功能异常,请排查原因并修复