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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 模型,前端先保证用户操作反馈真实有效。 |
## 待确认决策 ## 待确认决策

View File

@@ -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 结构化回答在手机端具备基础可读性。

View File

@@ -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 主链路补齐记录。 |