Complete V2 user H5 flow
This commit is contained in:
109
ai_knowledge_base_v2/apps/user-client/package-lock.json
generated
109
ai_knowledge_base_v2/apps/user-client/package-lock.json
generated
@@ -8,7 +8,9 @@
|
||||
"name": "ai-kb-user-client",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"markdown-it": "^14.3.0",
|
||||
"vite": "^7.1.0",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
@@ -822,6 +824,28 @@
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/markdown-it": {
|
||||
"version": "14.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "^5",
|
||||
"@types/mdurl": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
|
||||
@@ -990,6 +1014,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1086,6 +1116,25 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
|
||||
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/markdown-it"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -1095,6 +1144,51 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz",
|
||||
"integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/markdown-it"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.5.0",
|
||||
"linkify-it": "^5.0.2",
|
||||
"mdurl": "^2.0.0",
|
||||
"punycode.js": "^2.3.1",
|
||||
"uc.micro": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/muggle-string": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||
@@ -1173,6 +1267,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||
@@ -1256,6 +1359,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"markdown-it": "^14.3.0",
|
||||
"vite": "^7.1.0",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ const loading = ref(false);
|
||||
const booting = ref(true);
|
||||
const statusText = ref("连接后端中");
|
||||
const bottomRef = ref<HTMLElement | null>(null);
|
||||
const activeAbortController = ref<AbortController | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!getToken()) {
|
||||
@@ -82,28 +83,53 @@ async function send(message: string) {
|
||||
const assistantMessage: UiMessage = { id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true };
|
||||
messages.value.push(userMessage, assistantMessage);
|
||||
loading.value = true;
|
||||
activeAbortController.value = new AbortController();
|
||||
await scrollToBottom();
|
||||
try {
|
||||
await streamChat(activeSessionId.value, message, async (chunk) => {
|
||||
assistantMessage.content += chunk;
|
||||
await scrollToBottom();
|
||||
});
|
||||
}, activeAbortController.value.signal);
|
||||
assistantMessage.streaming = false;
|
||||
await refreshSessions(activeSessionId.value);
|
||||
user.value = await api.profile();
|
||||
} catch (error) {
|
||||
assistantMessage.streaming = false;
|
||||
assistantMessage.content = error instanceof Error ? error.message : "AI 回复失败";
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
assistantMessage.content = assistantMessage.content || "已停止生成";
|
||||
} else {
|
||||
assistantMessage.content = error instanceof Error ? error.message : "AI 回复失败";
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
activeAbortController.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!activeSessionId.value) return;
|
||||
activeAbortController.value?.abort();
|
||||
await api.stop(activeSessionId.value);
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function renameSession(sessionId: number, title: string) {
|
||||
await api.renameSession(sessionId, title);
|
||||
await refreshSessions(sessionId);
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId: number) {
|
||||
await api.deleteSession(sessionId);
|
||||
sessions.value = await api.listSessions();
|
||||
if (activeSessionId.value === sessionId) {
|
||||
messages.value = [];
|
||||
activeSessionId.value = null;
|
||||
if (sessions.value.length > 0) {
|
||||
await selectSession(sessions.value[0].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api.logout();
|
||||
@@ -175,6 +201,8 @@ async function scrollToBottom() {
|
||||
@close="drawerOpen = false"
|
||||
@select="selectSession"
|
||||
@create="createSession"
|
||||
@rename="renameSession"
|
||||
@delete="deleteSession"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
}>();
|
||||
|
||||
const markdown = new MarkdownIt({
|
||||
breaks: true,
|
||||
html: false,
|
||||
linkify: true,
|
||||
});
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
if (props.role === "user") return props.content;
|
||||
return markdown.render(props.content || "");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="message" :class="role">
|
||||
<div class="avatar">{{ role === "assistant" ? "AI" : "我" }}</div>
|
||||
<div class="bubble">
|
||||
<div class="content">{{ content }}</div>
|
||||
<div v-if="role === 'assistant'" class="content markdown-content" v-html="renderedContent"></div>
|
||||
<div v-else class="content">{{ renderedContent }}</div>
|
||||
<span v-if="streaming" class="typing">正在生成</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
import type { ChatSession } from "../types/api";
|
||||
|
||||
defineProps<{
|
||||
@@ -11,8 +13,29 @@ const emit = defineEmits<{
|
||||
close: [];
|
||||
select: [sessionId: number];
|
||||
create: [];
|
||||
rename: [sessionId: number, title: string];
|
||||
delete: [sessionId: number];
|
||||
}>();
|
||||
|
||||
const editingId = ref<number | null>(null);
|
||||
const editingTitle = ref("");
|
||||
|
||||
function startEdit(session: ChatSession) {
|
||||
editingId.value = session.id;
|
||||
editingTitle.value = session.title;
|
||||
}
|
||||
|
||||
function submitEdit(sessionId: number) {
|
||||
const title = editingTitle.value.trim();
|
||||
if (title) emit("rename", sessionId, title);
|
||||
editingId.value = null;
|
||||
editingTitle.value = "";
|
||||
}
|
||||
|
||||
function confirmDelete(session: ChatSession) {
|
||||
const ok = window.confirm(`确定删除会话「${session.title}」吗?`);
|
||||
if (ok) emit("delete", session.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,17 +47,27 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
<button type="button" class="new-session-btn" @click="emit('create')">+ 新聊天</button>
|
||||
<div class="session-list">
|
||||
<button
|
||||
<div
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
type="button"
|
||||
class="session-item"
|
||||
:class="{ active: session.id === activeSessionId }"
|
||||
@click="emit('select', session.id)"
|
||||
>
|
||||
<span>{{ session.title }}</span>
|
||||
<small>{{ session.messageCount }} 条消息</small>
|
||||
</button>
|
||||
<form v-if="editingId === session.id" class="session-edit" @submit.prevent="submitEdit(session.id)">
|
||||
<input v-model="editingTitle" maxlength="100" @keydown.escape="editingId = null" />
|
||||
<button type="submit">保存</button>
|
||||
</form>
|
||||
<template v-else>
|
||||
<button type="button" class="session-main" @click="emit('select', session.id)">
|
||||
<span>{{ session.title }}</span>
|
||||
<small>{{ session.messageCount }} 条消息</small>
|
||||
</button>
|
||||
<div class="session-actions">
|
||||
<button type="button" @click="startEdit(session)">改名</button>
|
||||
<button type="button" @click="confirmDelete(session)">删除</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -45,7 +45,12 @@ export const api = {
|
||||
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
|
||||
};
|
||||
|
||||
export async function streamChat(sessionId: number, message: string, onChunk: (chunk: string) => void) {
|
||||
export async function streamChat(
|
||||
sessionId: number,
|
||||
message: string,
|
||||
onChunk: (chunk: string) => void,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const token = getToken();
|
||||
const response = await fetch(`${API_BASE}/chat/completions`, {
|
||||
method: "POST",
|
||||
@@ -54,9 +59,11 @@ export async function streamChat(sessionId: number, message: string, onChunk: (c
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ sessionId, message }),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error("AI 回复连接失败");
|
||||
const message = await readErrorMessage(response);
|
||||
throw new Error(message || "AI 回复连接失败");
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
@@ -77,3 +84,12 @@ export async function streamChat(sessionId: number, message: string, onChunk: (c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response) {
|
||||
try {
|
||||
const body = (await response.json()) as ApiResponse<unknown>;
|
||||
return body.message;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +287,78 @@ button:disabled {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.markdown-content {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.markdown-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-content > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
margin: 8px 0 10px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.markdown-content li + li {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
padding: 2px 5px;
|
||||
border-radius: 6px;
|
||||
background: #edf5f2;
|
||||
color: #0f735d;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background: #13231e;
|
||||
color: #f4fffb;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
padding: 7px 9px;
|
||||
border: 1px solid #dbe7e3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.markdown-content a {
|
||||
color: #0f735d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.typing {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
@@ -385,7 +457,7 @@ button:disabled {
|
||||
|
||||
.session-item {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #e0ebe7;
|
||||
@@ -400,17 +472,62 @@ button:disabled {
|
||||
background: #eaf5f1;
|
||||
}
|
||||
|
||||
.session-item span {
|
||||
.session-main {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.session-main span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.session-item small {
|
||||
.session-main small {
|
||||
color: #6f817a;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-actions button,
|
||||
.session-edit button {
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: #edf5f2;
|
||||
color: #285247;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.session-edit {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 54px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-edit input {
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #cad8d2;
|
||||
border-radius: 9px;
|
||||
background: #ffffff;
|
||||
color: #17211d;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.app-shell {
|
||||
display: block;
|
||||
|
||||
Reference in New Issue
Block a user