feat: 添加流式支持;教材知识库 Demo

This commit is contained in:
2025-07-09 16:34:04 +08:00
parent 5b9db644e4
commit d6e70a4ef8
11 changed files with 282 additions and 49 deletions

View File

@ -1,2 +1,2 @@
DIFY_BASE_URL=https://service3.fenshenzhike.com/v1 DIFY_BASE_URL=https://service3.fenshenzhike.com/v1
DIFY_API_KEY=app-58uPYnqyLAtVtBYELHUGkqD9 DIFY_API_KEY=app-mS8a08rlxQuxZpdqEfwxJppK

1
.gitignore vendored
View File

@ -23,3 +23,4 @@ logs
.env.* .env.*
!.env.example !.env.example
!.env.oem-hlbrzy !.env.oem-hlbrzy
!.env.textbook-demo

View File

@ -15,6 +15,10 @@ defineProps({
default: "tabler:robot-face", default: "tabler:robot-face",
}, },
}); });
defineEmits<{
(e: "select-suggestion", suggestion: string): void;
}>();
</script> </script>
<template> <template>
@ -35,7 +39,7 @@ defineProps({
<span class="text-sm font-medium">{{ name }}</span> <span class="text-sm font-medium">{{ name }}</span>
</div> </div>
<div <div
class="rounded-lg bg-white/50 p-2 text-sm dark:bg-neutral-800/50 break-all text-justify" class="rounded-lg bg-white/50 p-3 text-sm dark:bg-neutral-800/50 break-all text-justify"
> >
<div v-if="message.message" class="prose prose-sm"> <div v-if="message.message" class="prose prose-sm">
<!-- {{ message.message }} --> <!-- {{ message.message }} -->
@ -47,6 +51,24 @@ defineProps({
</span> </span>
</div> </div>
</div> </div>
<div v-else-if="message.role === 'suggestion'">
<div class="">
<ul class="flex flex-col gap-2">
<li
v-for="suggestion in message.suggestions"
class="rounded-lg bg-white/50 p-2 text-xs dark:bg-neutral-800/50 break-all w-fit inline-flex items-center gap-0.5"
:key="suggestion"
@click="$emit('select-suggestion', suggestion)"
>
<Icon
name="tabler:question-mark"
class="text-primary-500 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 text-base"
/>
<span>{{ suggestion }}</span>
</li>
</ul>
</div>
</div>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@ -58,9 +58,14 @@ useHead({
<template> <template>
<div class="w-full h-full flex flex-col dark:bg-neutral-900/80"> <div class="w-full h-full flex flex-col dark:bg-neutral-900/80">
<div <div
class="sticky top-0 w-full px-4 py-3 bg-white/30 backdrop-blur-2xl z-30 dark:bg-neutral-900/80" class="sticky top-0 w-full px-4 py-3 bg-white/50 backdrop-blur-2xl z-30 dark:bg-neutral-900/80"
> >
<h1 class="font-medium">{{ gstate.botName }}</h1> <div class="flex items-center gap-1.5">
<Icon name="tabler:book" class="-mt-0.5 text-3xl text-primary-500" />
<div class="flex items-end gap-1">
<h1 class="text-base font-semibold text-primary-500 leading-none">{{ gstate.botName }}</h1>
</div>
</div>
<!-- <USelectMenu v-model="value" color="primary" variant="none" :items="items" class="w-48" size="lg" :ui="{base: 'w-fit'}" /> --> <!-- <USelectMenu v-model="value" color="primary" variant="none" :items="items" class="w-48" size="lg" :ui="{base: 'w-fit'}" /> -->
</div> </div>
<slot></slot> <slot></slot>

View File

@ -4,7 +4,7 @@ import tailwindcss from "@tailwindcss/vite";
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: "2024-11-01", compatibilityDate: "2024-11-01",
devtools: { enabled: true }, devtools: { enabled: true },
modules: ["@nuxt/ui", "@pinia/nuxt"], modules: ["@nuxt/ui", "@pinia/nuxt", "pinia-plugin-persistedstate/nuxt"],
css: ["~/assets/css/main.css"], css: ["~/assets/css/main.css"],
runtimeConfig: { runtimeConfig: {
public: { public: {

View File

@ -4,8 +4,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
"dev": "nuxt dev", "dev": "nuxt dev --dotenv .env.textbook-demo",
"generate": "nuxt generate", "generate": "nuxt generate --dotenv .env.textbook-demo",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
@ -19,6 +19,7 @@
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"nuxt": "^3.16.2", "nuxt": "^3.16.2",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.4.1",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"

View File

@ -9,56 +9,83 @@ const getPopularInquiriesByRole = () => {
return { return {
stu: [ stu: [
{ {
label: "学籍管理", label: "物联网基础",
inquiries: [ inquiries: [
{ {
question: "学籍注册的要求", question: "物联网的定义是什么",
}, },
{ {
question: "病假请假的审批流程", question: "物联网与互联网的关系与区别",
}, },
{ {
question: "转专业有哪些条件", question: "物联网有哪些典型应用场景",
}, },
{ {
question: "办理休学的流程", question: "物联网系统的四层结构是什么",
}, },
{ {
question: "人工智能专业毕业的要求", question: "物联网的本质是什么",
},
{
question: "新基建和物联网的关系",
},
{
question: "物联网工程的生命周期包括哪些阶段",
}, },
], ],
}, },
{ {
label: "学生日常", label: "智慧工地",
inquiries: [ inquiries: [
{ {
question: "校医院服务时间和报销政策", question: "智慧工地的概念与核心目标",
}, },
{ {
question: "学工部的联系方式", question: "智慧工地系统的四层架构是什么",
}, },
{ {
question: "宿舍管理制度是怎样的", question: "施工场地工人健康检测系统包括哪些功能",
}, },
{ {
question: "助学贷款申请指南", question: "施工场地工人健康检测系统的作用是什么",
}, },
{ {
question: "成立社团的流程", question: "智慧工地中常用的传感器有哪些",
},
{
question: "选择传感器需要考虑哪些特性参数",
},
{
question: "传感器安装调试的主要步骤有哪些",
}, },
], ],
}, },
{ {
label: "职业发展", label: "智能交通",
inquiries: [ inquiries: [
{ {
question: "专升本的条件和要求", question: "智能交通系统的定义和主要目标是什么",
}, },
{ {
question: "金融相关专业实习单位有哪些", question: "智能交通系统的四层结构及其功能",
}, },
{ {
question: "创业孵化基地申请方式", question: "我国智能交通的发展现状如何",
},
{
question: "国外智能交通发展的主要特点是什么",
},
{
question: "智能交通系统中常用的感知技术有哪些",
},
{
question: "射频识别技术在智能交通中的应用有哪些",
},
{
question: "自动识别技术包括哪些分类",
},
{
question: "交通卡口监控系统需求分析包含哪些方面",
}, },
], ],
}, },
@ -177,30 +204,84 @@ const onInquirySubmit = async () => {
}); });
try { try {
const resp = await useFetch<IWorkflowResponse>( // const resp = await useFetch<IWorkflowResponse>(
`${runtimeConfig.public.DifyBaseURL}/workflows/run`, // `${runtimeConfig.public.DifyBaseURL}/workflows/run`,
{ // {
method: "post", // method: "post",
headers: { // headers: {
Authorization: `Bearer ${runtimeConfig.public.DifyApiKey}`, // Authorization: `Bearer ${runtimeConfig.public.DifyApiKey}`,
}, // },
body: JSON.stringify({ // body: JSON.stringify({
inputs: { // inputs: {
question: inquiryInput.value, // query: inquiryInput.value,
role: gstate.currentRole, // },
}, // response_mode: "blocking",
response_mode: "blocking", // user: "xsh",
user: "abc-123", // }),
}), // }
} // );
); // gstate.insertOrUpdateMessage({
gstate.insertOrUpdateMessage({ // id: botMessageId,
id: botMessageId, // role: "bot",
role: "bot", // message:
message: // resp.data.value?.data.outputs.message.match(regex)?.[1] ||
resp.data.value?.data.outputs.message.match(regex)?.[1] || // "网络繁忙,请稍后再试",
"网络繁忙,请稍后再试", // });
}); http_stream<{
query: string
response_mode: "blocking" | "streaming"
user: string
conversation_id: string
inputs: Record<string, any>
}>(`${runtimeConfig.public.DifyBaseURL}/chat-messages`, {
query: inquiryInput.value,
response_mode: "streaming",
user: "xsh",
conversation_id: "",
inputs: {}
}, {
onStart: () => {
responding.value = true;
gstate.insertOrUpdateMessage({
id: botMessageId,
role: "bot",
message: "",
});
},
onTextChunk: (message: string) => {
gstate.insertOrUpdateMessage({
id: botMessageId,
role: "bot",
message: gstate.messages.find(m => m.id === botMessageId)?.message + message,
});
},
onComplete: (id, finished_at, messageId) => {
responding.value = false;
gstate.insertOrUpdateMessage({
id: botMessageId,
role: "bot",
message: gstate.messages.find(m => m.id === botMessageId)?.message || "网络繁忙,请稍后再试",
});
scrollLastMessageIntoView();
fetch(
`${runtimeConfig.public.DifyBaseURL}/messages/${messageId}/suggested?user=xsh`,
{
method: "GET",
headers: {
Authorization: `Bearer ${runtimeConfig.public.DifyApiKey}`,
},
}
).then(res => res.json()).then(data => {
if (data?.data?.length) {
gstate.insertOrUpdateMessage({
id: uuidv4(),
role: "suggestion",
suggestions: data.data,
});
}
})
},
})
} catch (error) { } catch (error) {
gstate.insertOrUpdateMessage({ gstate.insertOrUpdateMessage({
id: botMessageId, id: botMessageId,
@ -244,6 +325,12 @@ const scrollLastMessageIntoView = () => {
:name="gstate.botName" :name="gstate.botName"
:key="message.id" :key="message.id"
:message="message" :message="message"
@select-suggestion="
(suggestion) => {
if (responding) return;
inquiryInput = suggestion;
onInquirySubmit();
}"
/> />
</div> </div>
<div <div

32
pnpm-lock.yaml generated
View File

@ -35,6 +35,9 @@ importers:
pinia: pinia:
specifier: ^3.0.2 specifier: ^3.0.2
version: 3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)) version: 3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))
pinia-plugin-persistedstate:
specifier: ^4.4.1
version: 4.4.1(@nuxt/kit@3.16.2(magicast@0.3.5))(@pinia/nuxt@0.10.1(magicast@0.3.5)(pinia@3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))))(pinia@3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)))
tailwindcss: tailwindcss:
specifier: ^4.1.4 specifier: ^4.1.4
version: 4.1.4 version: 4.1.4
@ -1920,6 +1923,9 @@ packages:
decache@4.6.2: decache@4.6.2:
resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==} resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==}
deep-pick-omit@1.2.1:
resolution: {integrity: sha512-2J6Kc/m3irCeqVG42T+SaUMesaK7oGWaedGnQQK/+O0gYc+2SP5bKh/KKTE7d7SJ+GCA9UUE1GRzh6oDe0EnGw==}
deepmerge@4.3.1: deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -3252,6 +3258,20 @@ packages:
resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
engines: {node: '>=12'} engines: {node: '>=12'}
pinia-plugin-persistedstate@4.4.1:
resolution: {integrity: sha512-lmuMPpXla2zJKjxEq34e1E9P9jxkWEhcVwwioCCE0izG45kkTOvQfCzvwhW3i38cvnaWC7T1eRdkd15Re59ldw==}
peerDependencies:
'@nuxt/kit': '>=3.0.0'
'@pinia/nuxt': '>=0.10.0'
pinia: '>=3.0.0'
peerDependenciesMeta:
'@nuxt/kit':
optional: true
'@pinia/nuxt':
optional: true
pinia:
optional: true
pinia@3.0.2: pinia@3.0.2:
resolution: {integrity: sha512-sH2JK3wNY809JOeiiURUR0wehJ9/gd9qFN2Y828jCbxEzKEmEt0pzCXwqiSTfuRsK9vQsOflSdnbdBOGrhtn+g==} resolution: {integrity: sha512-sH2JK3wNY809JOeiiURUR0wehJ9/gd9qFN2Y828jCbxEzKEmEt0pzCXwqiSTfuRsK9vQsOflSdnbdBOGrhtn+g==}
peerDependencies: peerDependencies:
@ -6511,6 +6531,8 @@ snapshots:
dependencies: dependencies:
callsite: 1.0.0 callsite: 1.0.0
deep-pick-omit@1.2.1: {}
deepmerge@4.3.1: {} deepmerge@4.3.1: {}
default-browser-id@5.0.0: {} default-browser-id@5.0.0: {}
@ -8012,6 +8034,16 @@ snapshots:
picomatch@4.0.2: {} picomatch@4.0.2: {}
pinia-plugin-persistedstate@4.4.1(@nuxt/kit@3.16.2(magicast@0.3.5))(@pinia/nuxt@0.10.1(magicast@0.3.5)(pinia@3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))))(pinia@3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))):
dependencies:
deep-pick-omit: 1.2.1
defu: 6.1.4
destr: 2.0.5
optionalDependencies:
'@nuxt/kit': 3.16.2(magicast@0.3.5)
'@pinia/nuxt': 0.10.1(magicast@0.3.5)(pinia@3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)))
pinia: 3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))
pinia@3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)): pinia@3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)):
dependencies: dependencies:
'@vue/devtools-api': 7.7.5 '@vue/devtools-api': 7.7.5

View File

@ -5,7 +5,7 @@ export const useGState = defineStore("global_state", () => {
const botName = computed(() => { const botName = computed(() => {
switch (currentRole.value) { switch (currentRole.value) {
case "stu": case "stu":
return "AI 辅导员"; return "物联网工程导论";
case "tea": case "tea":
return "AI 教研专家"; return "AI 教研专家";
case "fans": case "fans":

View File

@ -2,8 +2,9 @@ export type VisitorRole = "stu" | "tea" | "fans";
export type LocalMessage = { export type LocalMessage = {
id: string; id: string;
role: "bot" | "user"; role: "bot" | "user" | "suggestion";
message?: string; message?: string;
suggestions?: string[];
}; };
export interface IWorkflowResponse { export interface IWorkflowResponse {

84
utils/http.ts Normal file
View File

@ -0,0 +1,84 @@
export const http_stream = async <ReqT>(
url: string,
params: ReqT,
events: {
onStart?: (id: string, created_at?: number, messageId?: string | null) => void
onTextChunk?: (message: string) => void
onComplete?: (id: string | null, finished_at?: number, messageId?: string | null) => void
}) => {
const { onStart, onTextChunk, onComplete } = events
const runtimeConfig = useRuntimeConfig()
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${runtimeConfig.public.DifyApiKey}`,
},
body: JSON.stringify(params),
})
if (!response) {
throw new Error('Network response was not ok')
}
const reader = response.body?.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
while (true) {
const { done, value } = await reader?.read() || {}
if (done) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n')
buffer = parts.pop()!
for (const part of parts) {
if (!part.trim().startsWith('data:')) continue
const payload = part.replace(/^data:\s*/, '').trim()
try {
const obj = JSON.parse(payload)
if (obj?.event && obj.event === 'workflow_started') {
onStart?.(obj?.data?.id || null, obj?.data?.created_at || 0, obj?.message_id || null)
}
if (obj?.event && obj.event === 'workflow_finished') {
onComplete?.(obj?.data?.id || null, obj?.data?.finished_at || 0, obj?.message_id || null)
return
}
// Workflow
if (obj?.event && obj.event === 'text_chunk') {
let text_chunk = obj.data?.text as string
if (text_chunk) {
if (text_chunk.startsWith('<') && text_chunk.endsWith('>')) {
const endTag = text_chunk.match(/<\/[^>]*>/)
if (endTag) {
text_chunk = text_chunk.replace(endTag[0], '\n' + endTag[0])
}
}
onTextChunk?.(text_chunk)
}
}
// Chatflow
if (obj?.event && obj.event === 'message') {
let ans = obj.answer
if (ans) {
if (ans.startsWith('<') && ans.endsWith('>')) {
const endTag = ans.match(/<\/[^>]*>/)
if (endTag) {
ans = ans.replace(endTag[0], '\n' + endTag[0])
}
}
onTextChunk?.(ans)
}
}
} catch {
// ignore
}
}
}
onComplete?.(null)
}