Commit 0a161bc8 by lijiabin

chore: 加入聊天

parent 01ec7a6c
import 'dotenv/config'
import cors from 'cors'
import express from 'express'
import OpenAI from 'openai'
import { zodTextFormat } from 'openai/helpers/zod'
import { z } from 'zod'
import dotenv from 'dotenv'
import { setAuthorization } from './utils/headers'
import { addTag } from './utils'
dotenv.config({ path: '.env.local', override: true })
const apiKey = process.env.MY_OPENAI_API_KEY
const baseURL = process.env.MY_OPENAI_BASE_URL
const client = new OpenAI({ apiKey, baseURL })
import { createOpenAIClient } from './utils/openai'
import { registerChatRoutes } from './route/chat'
import { registerBasicRoutes } from './route/basic'
const client = createOpenAIClient()
const app = express()
const PORT = 4000
......@@ -24,125 +19,8 @@ app.use((req, res, next) => {
next()
})
// 润色标题和内容
app.post('/api/polish-title', async (req, res) => {
const { title, content } = req.body
const InputSchema = z.object({
title: z.string(),
content: z.string(),
})
const response = await client.responses.create({
model: 'gpt-5-mini',
input: [
{
role: 'user',
content: `你是一个企业文化内容编辑专家,请遵循以下规则:
1. 标题要更有传播性,但不能夸张
2. 内容要更通顺、有感染力
3. 保留原意,不要编造
输入:
标题:${title}
内容:${content}`,
},
],
text: {
format: zodTextFormat(InputSchema, 'Input'),
},
})
const data = InputSchema.parse(JSON.parse(response.output_text))
console.log(data)
res.json({
message: 'AI润色标题和内容成功',
data,
})
})
// ai生成标签3条标签
app.post('/api/generate-tags', async (req, res) => {
const Schema = z.object({
tags: z
.array(
z.object({
color: z.string().default('#000000'),
description: z.string(),
title: z.string(),
type: z.literal('culture'),
}),
)
.length(3),
})
const response = await client.responses.create({
model: 'gpt-5-mini',
input: [
{
role: 'user',
content: '请生成5条标签,关于企业文化相关的标签',
},
],
text: {
format: zodTextFormat(Schema, 'Input'),
},
})
const tags = Schema.parse(JSON.parse(response.output_text)).tags
// 调用后端接口
await Promise.all(tags.map((tag) => addTag(tag)))
res.json({ message: 'AI生成标签成功', data: tags })
})
// 根据标题和内容 帮我选取标签
app.post('/api/select-tags-by-ai', async (req, res) => {
const { title, content, tagList } = req.body
const Schema = z.object({
mainTagId: z.string(),
subTagIdList: z.array(z.number()).max(3).min(0),
})
const response = await client.responses.create({
model: 'gpt-5-mini',
input: [
{
role: 'user',
content: `根据用户给出的标题:${title}和内容:${content},以及用户给出的标签列表:${JSON.stringify(tagList)},请根据标题和内容 帮我选取主、副标签,并返回对应的格式`,
},
],
text: {
format: zodTextFormat(Schema, 'Input'),
},
})
console.log(response.output_text)
const data = Schema.parse(JSON.parse(response.output_text))
res.json({ message: 'AI选取标签成功', data })
})
// ai生成评论
app.post('/api/generate-comment', async (req, res) => {
const { content } = req.body
const Schema = z.object({ content: z.string() })
const response = await client.responses.create({
model: 'gpt-5-mini',
input: [
{
role: 'user',
content: `你是一个评论专家
请遵循以下规则:
1. 评论要更通顺、有感染力
2. 保留原意,不要编造
3. 评论要更符合用户给出的内容
输入:
内容:${content}`,
},
],
text: {
format: zodTextFormat(Schema, 'Input'),
},
})
const data = Schema.parse(JSON.parse(response.output_text))
res.json({ message: 'AI生成评论成功', data: data.content })
})
registerBasicRoutes(app, client)
registerChatRoutes(app, client)
app.use((_req, res) => {
res.status(404).json({ error: 'not found' })
})
......
import { z } from 'zod'
import { zodTextFormat } from 'openai/helpers/zod'
import type { Express } from 'express'
import { addTag } from '../utils'
import type { OpenAI } from 'openai'
export const registerBasicRoutes = (app: Express, client: OpenAI) => {
// 润色标题和内容
app.post('/api/polish-title', async (req, res) => {
const { title, content } = req.body
const InputSchema = z.object({
title: z.string(),
content: z.string(),
})
const response = await client.responses.create({
model: 'gpt-5-mini',
input: [
{
role: 'user',
content: `你是一个企业文化内容编辑专家,请遵循以下规则:
1. 标题要更有传播性,但不能夸张
2. 内容要更通顺、有感染力
3. 保留原意,不要编造
输入:
标题:${title}
内容:${content}`,
},
],
text: {
format: zodTextFormat(InputSchema, 'Input'),
},
})
const data = InputSchema.parse(JSON.parse(response.output_text))
res.json({
message: 'AI润色标题和内容成功',
data,
})
})
// ai生成标签(默认 3 条)
app.post('/api/generate-tags', async (_req, res) => {
const Schema = z.object({
tags: z
.array(
z.object({
color: z.string().default('#000000'),
description: z.string(),
title: z.string(),
type: z.literal('culture'),
}),
)
.length(3),
})
const response = await client.responses.create({
model: 'gpt-5-mini',
input: [
{
role: 'user',
content: '请生成3条标签,关于企业文化相关的标签',
},
],
text: {
format: zodTextFormat(Schema, 'Input'),
},
})
const tags = Schema.parse(JSON.parse(response.output_text)).tags
// 调用后端接口
await Promise.all(tags.map((tag) => addTag(tag)))
res.json({ message: 'AI生成标签成功', data: tags })
})
// 根据标题和内容 帮我选取标签
app.post('/api/select-tags-by-ai', async (req, res) => {
const { title, content, tagList } = req.body
const Schema = z.object({
mainTagId: z.string(),
subTagIdList: z.array(z.number()).max(3).min(0),
})
const response = await client.responses.create({
model: 'gpt-5-mini',
input: [
{
role: 'user',
content: `根据用户给出的标题:${title}和内容:${content},以及用户给出的标签列表:${JSON.stringify(
tagList,
)},请根据标题和内容 帮我选取主、副标签,并返回对应的格式`,
},
],
text: {
format: zodTextFormat(Schema, 'Input'),
},
})
const data = Schema.parse(JSON.parse(response.output_text))
res.json({ message: 'AI选取标签成功', data })
})
// ai生成评论
app.post('/api/generate-comment', async (req, res) => {
const { content } = req.body
const Schema = z.object({ content: z.string() })
const response = await client.responses.create({
model: 'gpt-5-mini',
input: [
{
role: 'user',
content: `你是一个评论专家
请遵循以下规则:
1. 评论要更通顺、有感染力
2. 保留原意,不要编造
3. 评论要更符合用户给出的内容
输入:
内容:${content}`,
},
],
text: {
format: zodTextFormat(Schema, 'Input'),
},
})
const data = Schema.parse(JSON.parse(response.output_text))
res.json({ message: 'AI生成评论成功', data: data.content })
})
}
// ai聊天相关的
import type { Express } from 'express'
import type { OpenAI } from 'openai'
const memoryList: { role: 'user' | 'assistant'; content: string }[] = []
export const registerChatRoutes = (app: Express, client: OpenAI) => {
// ai聊天
app.post('/api/chat', async (req, res) => {
const { content } = req.body
memoryList.push({
role: 'user',
content: content,
})
const response = await client.responses.create({
model: 'gpt-5-mini',
input: memoryList,
stream: true,
})
for await (const event of response) {
// 说明是文本
console.log(event)
if (event.type === 'response.output_text.delta') {
res.write(event.delta)
} else if (event.type === 'response.output_text.done') {
res.end()
}
}
})
}
import dotenv from 'dotenv'
import OpenAI from 'openai'
export function createOpenAIClient() {
// 只在这里加载 .env.local,避免启动时重复/冲突
dotenv.config({ path: '.env.local', override: true })
const apiKey = process.env.MY_OPENAI_API_KEY
const baseURL = process.env.MY_OPENAI_BASE_URL
if (!apiKey) {
console.error('[server] Missing MY_OPENAI_API_KEY in .env.local')
process.exit(1)
}
if (!baseURL) {
console.error('[server] Missing MY_OPENAI_BASE_URL in .env.local')
process.exit(1)
}
return new OpenAI({ apiKey, baseURL })
}
import { useRouter } from 'vue-router'
export default function AiIcon() {
const router = useRouter()
return (
<button
type="button"
aria-label="跳转到 AI 页面"
class="fixed bottom-20 right-10 z-50 inline-flex items-center justify-center rounded-2xl p-2.5 cursor-pointer bg-white shadow-lg shadow-indigo-500/20 ring-1 ring-slate-200/90 hover:shadow-xl hover:shadow-indigo-500/25 hover:ring-indigo-200/80 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] transition-all duration-200"
onClick={() => router.push('/ai')}
>
<div class="w-11 h-11 rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-500 shadow-lg shadow-indigo-200/40 flex items-center justify-center">
<span class="text-white font-bold">AI</span>
</div>
</button>
)
}
......@@ -155,6 +155,7 @@
<CgGuide />
<RewardToast />
<!-- <SatisfactionSurvey /> -->
<AiIcon />
</template>
<script setup lang="tsx" name="CultureLayout">
......@@ -170,7 +171,7 @@ import { useUserStore } from '@/stores/user'
import { useActivityStore } from '@/stores/activity'
import RewardButton from '@/components/common/RewardButton/index.vue'
import { RewardButtonEnum } from '@/constants'
import AiIcon from '@/components/common/AiIcon/index.tsx'
const userStore = useUserStore()
const activityStore = useActivityStore()
......
......@@ -189,6 +189,11 @@ export const constantsRoute = [
name: 'Auction',
component: () => import('@/views/auction/index.vue'),
},
{
path: 'ai',
name: 'Ai',
component: () => import('@/views/ai/index.vue'),
},
],
},
......
<script lang="ts" setup>
type ChatRole = 'user' | 'assistant'
interface ChatMessage {
id: string
role: ChatRole
content: string
createdAt: number
}
const messages = ref<ChatMessage[]>([
{
id: 'm1',
role: 'assistant',
content:
'欢迎来到企业文化 AI 助手。\n你可以直接输入:\n1) 帮我润色标题和内容\n2) 生成与内容匹配的标签\n3) 从候选标签里选主/副标签',
createdAt: Date.now() - 1000 * 60 * 2,
},
])
const input = ref('')
const sending = ref(false)
const canSend = computed(() => input.value.trim().length > 0 && !sending.value)
function uid() {
return `${Date.now()}_${Math.random().toString(16).slice(2)}`
}
const handleSend = async () => {
const text = input.value.trim()
if (!text || sending.value) return
sending.value = true
const userMsg: ChatMessage = { id: uid(), role: 'user', content: text, createdAt: Date.now() }
messages.value.push(userMsg)
messages.value.push({
id: uid(),
role: 'assistant',
content: '正在思考.......',
createdAt: Date.now(),
})
scrollToBottom()
const response = await fetch(`${import.meta.env.VITE_AI_API_URL}/api/chat`, {
method: 'POST',
body: JSON.stringify({ content: text }),
headers: {
'Content-Type': 'application/json',
},
})
// 流式读取
const reader = response.body?.getReader()
if (!reader) return
while (true) {
const { done, value } = await reader.read()
if (done) break
const content = new TextDecoder().decode(value)
messages.value[messages.value.length - 1]!.content += content
scrollToBottom()
}
input.value = ''
sending.value = false
}
const scrollToBottom = () => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })
}
onMounted(() => {
scrollToBottom()
})
</script>
<template>
<div class="min-h-screen bg-gradient-to-b flex flex-col">
<div class="max-w-4xl mx-auto w-full px-4 pt-6 flex flex-col flex-1">
<!-- 顶部说明 -->
<div class="mb-4">
<div class="flex items-center gap-3">
<div
class="w-11 h-11 rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-500 shadow-lg shadow-indigo-200/40 flex items-center justify-center"
>
<span class="text-white font-bold">AI</span>
</div>
<div class="flex-1">
<div class="text-lg font-semibold text-slate-900">企业文化 AI 助手</div>
<div class="text-sm text-slate-500">
先用假数据体验对话交互,后续可接你的 AI 后端接口
</div>
</div>
</div>
</div>
<!-- 消息区(占满剩余高度) -->
<el-card
class="rounded-2xl border border-slate-200/70 bg-white shadow-sm flex-1 flex flex-col min-h-0"
>
<div
ref="scrollEl"
class="flex-1 overflow-auto p-4 pr-1 min-h-0"
style="scrollbar-gutter: stable"
>
<div class="space-y-4">
<div
v-for="m in messages"
:key="m.id"
class="flex"
:class="m.role === 'user' ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[72%] whitespace-pre-wrap break-words rounded-2xl px-4 py-3 text-[15px] leading-relaxed shadow-sm"
:class="
m.role === 'user' ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-800'
"
>
{{ m.content }}
</div>
</div>
</div>
</div>
</el-card>
<!-- 输入区(放在同一列容器内:宽度/边距对齐消息卡片) -->
<div class="mt-auto sticky bottom-0 z-10 py-4 backdrop-blur">
<div
class="bg-white border border-slate-200/70 rounded-2xl shadow-sm px-3 py-3 flex gap-3 items-center"
>
<el-input
v-model="input"
type="textarea"
:rows="2"
resize="none"
placeholder="输入你的问题,比如:润色标题和内容 / 生成标签 / 选取主副标签..."
class="flex-1"
@keyup.enter.exact.prevent="handleSend"
/>
<el-button
type="primary"
:disabled="!canSend"
:loading="sending"
class="min-w-20 rounded-xl"
@click="handleSend"
>
发送
</el-button>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
/* 让 Element Plus textarea 输入更接近你的卡片圆角与“聊天”风格 */
:deep(.el-input__wrapper) {
border-radius: 16px !important;
box-shadow: none !important;
}
:deep(.el-textarea__inner) {
border-radius: 16px !important;
padding: 10px 12px !important;
}
</style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment