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",
|
"name": "ai-kb-user-client",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"markdown-it": "^14.3.0",
|
||||||
"vite": "^7.1.0",
|
"vite": "^7.1.0",
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
},
|
},
|
||||||
@@ -822,6 +824,28 @@
|
|||||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "6.0.7",
|
"version": "6.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
|
||||||
@@ -990,6 +1014,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"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": "^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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -1095,6 +1144,51 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/muggle-string": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||||
@@ -1173,6 +1267,15 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.62.2",
|
"version": "4.62.2",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||||
@@ -1256,6 +1359,12 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.6",
|
"version": "7.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
"preview": "vite preview --host 127.0.0.1"
|
"preview": "vite preview --host 127.0.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"markdown-it": "^14.3.0",
|
||||||
"vite": "^7.1.0",
|
"vite": "^7.1.0",
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const loading = ref(false);
|
|||||||
const booting = ref(true);
|
const booting = ref(true);
|
||||||
const statusText = ref("连接后端中");
|
const statusText = ref("连接后端中");
|
||||||
const bottomRef = ref<HTMLElement | null>(null);
|
const bottomRef = ref<HTMLElement | null>(null);
|
||||||
|
const activeAbortController = ref<AbortController | null>(null);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
@@ -82,28 +83,53 @@ async function send(message: string) {
|
|||||||
const assistantMessage: UiMessage = { id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true };
|
const assistantMessage: UiMessage = { id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true };
|
||||||
messages.value.push(userMessage, assistantMessage);
|
messages.value.push(userMessage, assistantMessage);
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
activeAbortController.value = new AbortController();
|
||||||
await scrollToBottom();
|
await scrollToBottom();
|
||||||
try {
|
try {
|
||||||
await streamChat(activeSessionId.value, message, async (chunk) => {
|
await streamChat(activeSessionId.value, message, async (chunk) => {
|
||||||
assistantMessage.content += chunk;
|
assistantMessage.content += chunk;
|
||||||
await scrollToBottom();
|
await scrollToBottom();
|
||||||
});
|
}, activeAbortController.value.signal);
|
||||||
assistantMessage.streaming = false;
|
assistantMessage.streaming = false;
|
||||||
await refreshSessions(activeSessionId.value);
|
await refreshSessions(activeSessionId.value);
|
||||||
|
user.value = await api.profile();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
assistantMessage.streaming = false;
|
assistantMessage.streaming = false;
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") {
|
||||||
|
assistantMessage.content = assistantMessage.content || "已停止生成";
|
||||||
|
} else {
|
||||||
assistantMessage.content = error instanceof Error ? error.message : "AI 回复失败";
|
assistantMessage.content = error instanceof Error ? error.message : "AI 回复失败";
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
activeAbortController.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stop() {
|
async function stop() {
|
||||||
if (!activeSessionId.value) return;
|
if (!activeSessionId.value) return;
|
||||||
|
activeAbortController.value?.abort();
|
||||||
await api.stop(activeSessionId.value);
|
await api.stop(activeSessionId.value);
|
||||||
loading.value = false;
|
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() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await api.logout();
|
await api.logout();
|
||||||
@@ -175,6 +201,8 @@ async function scrollToBottom() {
|
|||||||
@close="drawerOpen = false"
|
@close="drawerOpen = false"
|
||||||
@select="selectSession"
|
@select="selectSession"
|
||||||
@create="createSession"
|
@create="createSession"
|
||||||
|
@rename="renameSession"
|
||||||
|
@delete="deleteSession"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,16 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
import MarkdownIt from "markdown-it";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
streaming?: boolean;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="message" :class="role">
|
<article class="message" :class="role">
|
||||||
<div class="avatar">{{ role === "assistant" ? "AI" : "我" }}</div>
|
<div class="avatar">{{ role === "assistant" ? "AI" : "我" }}</div>
|
||||||
<div class="bubble">
|
<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>
|
<span v-if="streaming" class="typing">正在生成</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
import type { ChatSession } from "../types/api";
|
import type { ChatSession } from "../types/api";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -11,8 +13,29 @@ const emit = defineEmits<{
|
|||||||
close: [];
|
close: [];
|
||||||
select: [sessionId: number];
|
select: [sessionId: number];
|
||||||
create: [];
|
create: [];
|
||||||
|
rename: [sessionId: number, title: string];
|
||||||
delete: [sessionId: number];
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -24,17 +47,27 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" class="new-session-btn" @click="emit('create')">+ 新聊天</button>
|
<button type="button" class="new-session-btn" @click="emit('create')">+ 新聊天</button>
|
||||||
<div class="session-list">
|
<div class="session-list">
|
||||||
<button
|
<div
|
||||||
v-for="session in sessions"
|
v-for="session in sessions"
|
||||||
:key="session.id"
|
:key="session.id"
|
||||||
type="button"
|
|
||||||
class="session-item"
|
class="session-item"
|
||||||
:class="{ active: session.id === activeSessionId }"
|
:class="{ active: session.id === activeSessionId }"
|
||||||
@click="emit('select', session.id)"
|
|
||||||
>
|
>
|
||||||
|
<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>
|
<span>{{ session.title }}</span>
|
||||||
<small>{{ session.messageCount }} 条消息</small>
|
<small>{{ session.messageCount }} 条消息</small>
|
||||||
</button>
|
</button>
|
||||||
|
<div class="session-actions">
|
||||||
|
<button type="button" @click="startEdit(session)">改名</button>
|
||||||
|
<button type="button" @click="confirmDelete(session)">删除</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ export const api = {
|
|||||||
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
|
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 token = getToken();
|
||||||
const response = await fetch(`${API_BASE}/chat/completions`, {
|
const response = await fetch(`${API_BASE}/chat/completions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -54,9 +59,11 @@ export async function streamChat(sessionId: number, message: string, onChunk: (c
|
|||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ sessionId, message }),
|
body: JSON.stringify({ sessionId, message }),
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
if (!response.ok || !response.body) {
|
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 reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder("utf-8");
|
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;
|
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 {
|
.typing {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@@ -385,7 +457,7 @@ button:disabled {
|
|||||||
|
|
||||||
.session-item {
|
.session-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid #e0ebe7;
|
border: 1px solid #e0ebe7;
|
||||||
@@ -400,17 +472,62 @@ button:disabled {
|
|||||||
background: #eaf5f1;
|
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;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-item small {
|
.session-main small {
|
||||||
color: #6f817a;
|
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) {
|
@media (max-width: 520px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
| D-006 | 短信、飞书、大模型都先做 mock provider,再接真实服务。 | 默认采用 | 保证本地开发和测试不被外部账号、权限、费用阻塞。 |
|
| D-006 | 短信、飞书、大模型都先做 mock provider,再接真实服务。 | 默认采用 | 保证本地开发和测试不被外部账号、权限、费用阻塞。 |
|
||||||
| D-007 | RAG 一期作为后端内部 service,不单独拆微服务。 | 默认采用 | 降低一期部署和调试复杂度。 |
|
| D-007 | RAG 一期作为后端内部 service,不单独拆微服务。 | 默认采用 | 降低一期部署和调试复杂度。 |
|
||||||
| D-008 | 生产密钥不明文展示,不写入日志。 | 默认采用 | 降低模型 Key、飞书密钥、短信密钥泄露风险。 |
|
| D-008 | 生产密钥不明文展示,不写入日志。 | 默认采用 | 降低模型 Key、飞书密钥、短信密钥泄露风险。 |
|
||||||
|
| D-009 | 用户端 Markdown 渲染使用 `markdown-it`,不手写 Markdown 解析器。 | 默认采用 | 减少列表、代码块、表格等格式兼容问题,降低后续维护成本。 |
|
||||||
|
| D-010 | 用户端停止生成先使用浏览器 `AbortController` 中断 SSE 读取,后端真实中断等接入真实模型时实现。 | 默认采用 | 当前后端仍是 mock 模型,前端先保证用户操作反馈真实有效。 |
|
||||||
|
|
||||||
## 待确认决策
|
## 待确认决策
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 阶段三记录:用户端 H5 主链路补齐
|
||||||
|
|
||||||
|
日期:2026-07-06
|
||||||
|
|
||||||
|
## 本次目标
|
||||||
|
|
||||||
|
补齐阶段三用户端 H5 主链路中还缺的体验能力,让用户在手机端可以更完整地完成登录、提问、查看结构化回答、维护历史会话和停止生成。
|
||||||
|
|
||||||
|
## 已完成
|
||||||
|
|
||||||
|
- 聊天消息支持 Markdown 渲染:
|
||||||
|
- 段落
|
||||||
|
- 列表
|
||||||
|
- 行内代码
|
||||||
|
- 代码块
|
||||||
|
- 表格横向滚动
|
||||||
|
- 链接识别
|
||||||
|
- 会话抽屉支持:
|
||||||
|
- 新建会话
|
||||||
|
- 切换会话
|
||||||
|
- 修改会话标题
|
||||||
|
- 删除会话
|
||||||
|
- 停止生成体验增强:
|
||||||
|
- 前端使用 `AbortController` 中断当前 SSE 读取。
|
||||||
|
- 仍调用后端 `/chat/stop`,为后续真实模型中断保留接口。
|
||||||
|
- 发送问题成功后刷新用户额度,顶部额度展示可以及时更新。
|
||||||
|
- SSE 错误时优先展示后端返回的业务错误信息,例如额度用完。
|
||||||
|
|
||||||
|
## 当前边界
|
||||||
|
|
||||||
|
- 当前仍是 mock 登录、mock RAG 和 mock 模型。
|
||||||
|
- 后端真实停止生成尚未实现,需要等真实模型流式调用接入后补齐。
|
||||||
|
- Markdown 已禁用 HTML 解析,避免模型回答中出现 HTML 注入风险。
|
||||||
|
|
||||||
|
## 阶段三判断
|
||||||
|
|
||||||
|
阶段三可以认为主链路已具备可演示能力:
|
||||||
|
|
||||||
|
1. 用户可以通过 mock 手机号验证码登录。
|
||||||
|
2. 用户可以创建和切换会话。
|
||||||
|
3. 用户可以发送问题并接收 SSE 回答。
|
||||||
|
4. 用户可以查看历史聊天。
|
||||||
|
5. 用户可以修改和删除会话。
|
||||||
|
6. 用户可以停止当前生成。
|
||||||
|
7. AI 结构化回答在手机端具备基础可读性。
|
||||||
@@ -29,3 +29,4 @@
|
|||||||
| `2026-07-06-phase2-user-client.md` | 阶段二用户端 Vue 3 H5 第一批记录。 |
|
| `2026-07-06-phase2-user-client.md` | 阶段二用户端 Vue 3 H5 第一批记录。 |
|
||||||
| `2026-07-06-phase2-dev-compose.md` | 阶段二本地开发编排记录。 |
|
| `2026-07-06-phase2-dev-compose.md` | 阶段二本地开发编排记录。 |
|
||||||
| `2026-07-06-phase2-rag-skeleton.md` | 阶段二 RAG 问答链路骨架记录。 |
|
| `2026-07-06-phase2-rag-skeleton.md` | 阶段二 RAG 问答链路骨架记录。 |
|
||||||
|
| `2026-07-06-phase3-user-client-completion.md` | 阶段三用户端 H5 主链路补齐记录。 |
|
||||||
|
|||||||
Reference in New Issue
Block a user