3 Commits

11 changed files with 3349 additions and 1512 deletions

2
.env.textbook-demo Normal file
View File

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

2
.gitignore vendored
View File

@ -22,3 +22,5 @@ logs
.env
.env.*
!.env.example
!.env.oem-hlbrzy
!.env.textbook-demo

View File

@ -15,6 +15,10 @@ defineProps({
default: "tabler:robot-face",
},
});
defineEmits<{
(e: "select-suggestion", suggestion: string): void;
}>();
</script>
<template>
@ -35,7 +39,7 @@ defineProps({
<span class="text-sm font-medium">{{ name }}</span>
</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">
<!-- {{ message.message }} -->
@ -47,6 +51,24 @@ defineProps({
</span>
</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>
<style scoped></style>

View File

@ -58,9 +58,14 @@ useHead({
<template>
<div class="w-full h-full flex flex-col dark:bg-neutral-900/80">
<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'}" /> -->
</div>
<slot></slot>

View File

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

View File

@ -4,28 +4,31 @@
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"dev": "nuxt dev --dotenv .env.textbook-demo",
"generate": "nuxt generate --dotenv .env.textbook-demo",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@pinia/nuxt": "0.10.1",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.0.14",
"@tailwindcss/vite": "^4.1.4",
"@uniiem/uuid": "^0.2.1",
"dotenv": "^16.4.7",
"dotenv": "^16.5.0",
"highlight.js": "^11.11.1",
"markdown-it": "^14.1.0",
"nuxt": "^3.16.0",
"pinia": "^3.0.1",
"tailwindcss": "^4.0.14",
"nuxt": "^3.16.2",
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.4.1",
"tailwindcss": "^4.1.4",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a",
"devDependencies": {
"@nuxt/ui": "^3.0.0",
"@iconify-json/svg-spinners": "^1.2.2",
"@iconify-json/tabler": "^1.2.17",
"@nuxt/ui": "^3.0.2",
"@types/markdown-it": "^14.1.2"
}
}

View File

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

4544
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -2,8 +2,9 @@ export type VisitorRole = "stu" | "tea" | "fans";
export type LocalMessage = {
id: string;
role: "bot" | "user";
role: "bot" | "user" | "suggestion";
message?: string;
suggestions?: string[];
};
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)
}