Complete V2 user H5 flow

This commit is contained in:
2026-07-06 18:00:21 +08:00
parent 207340973c
commit 86cbed2437
10 changed files with 383 additions and 15 deletions

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 "";
}
}

View File

@@ -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;