rebuild(stage-10): deliver mentor and unified llm streaming

This commit is contained in:
leefer
2026-07-30 05:52:12 +08:00
parent 532f0cfc11
commit f1fa104641
62 changed files with 6880 additions and 7 deletions
@@ -64,4 +64,28 @@ describe("api client", () => {
expect(headers.get("X-CSRF-Token")).toBe("csrf-token-123");
expect(headers.get("Content-Type")).toBe("application/json");
});
it("parses split NDJSON events through the single streaming client", async () => {
const encoder = new TextEncoder();
const body = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('{"type":"delta","content":"回'));
controller.enqueue(encoder.encode('答"}\n{"type":"done"}\n'));
controller.close();
},
});
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(body, { status: 200 })));
const events: Array<{ type: string; content?: string }> = [];
await api.stream<{ type: string; content?: string }>(
"/mentors/chat",
{},
(event) => events.push(event),
);
expect(events).toEqual([
{ type: "delta", content: "回答" },
{ type: "done" },
]);
});
});
+44
View File
@@ -44,6 +44,47 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return payload;
}
async function streamNdjson<T>(
path: string,
body: unknown,
onEvent: (event: T) => void,
signal?: AbortSignal,
): Promise<void> {
const csrfToken = readCookie("xiaobai_csrf");
const headers = new Headers({ Accept: "application/x-ndjson", "Content-Type": "application/json" });
if (csrfToken) headers.set("X-CSRF-Token", csrfToken);
const response = await fetch(`/api${path}`, {
method: "POST",
credentials: "same-origin",
headers,
body: JSON.stringify(body),
signal,
});
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as ApiErrorPayload;
throw new ApiError(
payload.error?.message ?? "请求失败,请稍后重试。",
response.status,
payload.error?.code ?? "request_failed",
payload.error?.request_id,
);
}
if (!response.body) throw new ApiError("响应内容不可用。", 503, "stream_unavailable");
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let pending = "";
while (true) {
const { done, value } = await reader.read();
pending += value ?? "";
const lines = pending.split("\n");
pending = lines.pop() ?? "";
for (const line of lines) {
if (line.trim()) onEvent(JSON.parse(line) as T);
}
if (done) break;
}
if (pending.trim()) onEvent(JSON.parse(pending) as T);
}
function readCookie(name: string): string | undefined {
if (typeof document === "undefined") return undefined;
const prefix = `${encodeURIComponent(name)}=`;
@@ -71,4 +112,7 @@ export const api = {
delete<T>(path: string): Promise<T> {
return request<T>(path, json("DELETE"));
},
stream<T>(path: string, body: unknown, onEvent: (event: T) => void, signal?: AbortSignal): Promise<void> {
return streamNdjson(path, body, onEvent, signal);
},
};
+55
View File
@@ -0,0 +1,55 @@
import { api } from "./client";
export type Mentor = {
id: string;
name: string;
description: string;
tagline: string;
focus: string[];
grade: "A" | "B" | "C";
evidence_label: string;
evidence_note: string;
private: boolean;
pinned: boolean;
sort_order: number;
};
export type MentorSetup = { trade_date: string; mentors: Mentor[] };
export type MentorMessage = {
id?: number;
role: "user" | "assistant";
content: string;
status: "complete" | "stopped" | "error";
created_at?: string;
};
export type MentorStreamEvent = {
type: "delta" | "done" | "error";
content?: string;
code?: string;
message?: string;
partial?: boolean;
request_id: string;
};
export const mentorApi = {
setup(date: string): Promise<MentorSetup> {
return api.get(`/mentors/setup?date=${encodeURIComponent(date)}`);
},
preferences(order: string[], pinned: string[]): Promise<{ order: string[]; pinned: string[] }> {
return api.put("/mentors/preferences", { order, pinned });
},
messages(mentorId: string, date: string): Promise<MentorMessage[]> {
return api.get(`/mentors/messages?mentor_id=${encodeURIComponent(mentorId)}&date=${encodeURIComponent(date)}`);
},
clear(mentorId: string, date: string): Promise<{ deleted: number }> {
return api.delete(`/mentors/messages?mentor_id=${encodeURIComponent(mentorId)}&date=${encodeURIComponent(date)}`);
},
chat(
mentorId: string,
tradeDate: string,
question: string,
onEvent: (event: MentorStreamEvent) => void,
signal: AbortSignal,
): Promise<void> {
return api.stream("/mentors/chat", { mentor_id: mentorId, trade_date: tradeDate, question }, onEvent, signal);
},
};
@@ -17,4 +17,13 @@ const titles = { profile: "个人资料", membership: "会员状态", password:
<PasswordPanel v-else-if="ui.dialog === 'password'" />
<SearchPanel v-else />
</BaseDialog>
<BaseDialog v-else-if="ui.confirmation" :title="ui.confirmation.title" @close="ui.resolveConfirmation(false)">
<div class="form-grid">
<p class="muted">{{ ui.confirmation.message }}</p>
<div class="form-actions">
<button class="btn" type="button" @click="ui.resolveConfirmation(false)">取消</button>
<button class="btn btn-primary" type="button" @click="ui.resolveConfirmation(true)">{{ ui.confirmation.confirmLabel }}</button>
</div>
</div>
</BaseDialog>
</template>
+23 -1
View File
@@ -3,6 +3,7 @@ import { ref } from "vue";
export type DialogName = "profile" | "membership" | "password" | "search" | null;
export type Theme = "light" | "dark";
export type Confirmation = { title: string; message: string; confirmLabel: string };
const THEME_KEY = "xiaobai-theme";
@@ -20,8 +21,10 @@ export function applyTheme(theme: Theme): void {
export const useUiStore = defineStore("ui", () => {
const theme = ref<Theme>(preferredTheme());
const dialog = ref<DialogName>(null);
const confirmation = ref<Confirmation | null>(null);
const toast = ref("");
let toastTimer: ReturnType<typeof setTimeout> | undefined;
let confirmationResolve: ((value: boolean) => void) | undefined;
function setTheme(next: Theme): void {
theme.value = next;
@@ -34,6 +37,7 @@ export const useUiStore = defineStore("ui", () => {
}
function openDialog(name: Exclude<DialogName, null>): void {
resolveConfirmation(false);
dialog.value = name;
}
@@ -41,6 +45,21 @@ export const useUiStore = defineStore("ui", () => {
dialog.value = null;
}
function askConfirmation(value: Confirmation): Promise<boolean> {
resolveConfirmation(false);
dialog.value = null;
confirmation.value = value;
return new Promise((resolve) => {
confirmationResolve = resolve;
});
}
function resolveConfirmation(value: boolean): void {
confirmation.value = null;
confirmationResolve?.(value);
confirmationResolve = undefined;
}
function showToast(message: string): void {
toast.value = message;
if (toastTimer) clearTimeout(toastTimer);
@@ -49,5 +68,8 @@ export const useUiStore = defineStore("ui", () => {
}, 2200);
}
return { theme, dialog, toast, setTheme, toggleTheme, openDialog, closeDialog, showToast };
return {
theme, dialog, confirmation, toast, setTheme, toggleTheme, openDialog, closeDialog,
askConfirmation, resolveConfirmation, showToast,
};
});
+289
View File
@@ -0,0 +1,289 @@
.mentor-page {
height: calc(100vh - var(--shell-topbar-height) - var(--shell-summary-height) - var(--shell-status-height));
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
overflow: hidden;
}
.mentor-page-header {
min-height: var(--s-44);
}
.mentor-mobile-directory {
display: none;
}
.mentor-page > .membership-lock {
grid-row: 2;
}
.mentor-page > .workspace-state,
.mentor-page > .empty-state,
.mentor-workspace {
grid-row: 3;
}
.mentor-workspace {
min-height: var(--s-400);
display: grid;
grid-template-columns: var(--s-320) minmax(0, 1fr);
gap: var(--layout-gap);
}
.mentor-library,
.mentor-chat {
min-height: 0;
overflow: hidden;
}
.mentor-library {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
}
.mentor-library-header {
display: grid;
gap: var(--s-8);
padding: var(--s-12);
border-bottom: var(--s-1) solid var(--color-divider);
}
.mentor-library-header > div:first-child,
.mentor-chat-header,
.mentor-identity,
.mentor-item-main,
.mentor-item-actions,
.mentor-composer-actions,
.mentor-stream-error {
display: flex;
align-items: center;
gap: var(--s-8);
}
.mentor-library-header h2,
.mentor-chat-header strong {
font-size: var(--font-14);
}
.mentor-library-header > div:first-child .muted {
margin-left: auto;
font-size: var(--font-11);
}
.mentor-search {
min-height: var(--s-32);
}
.mentor-grade-filter {
width: 100%;
}
.mentor-grade-filter button {
flex: 1;
}
.mentor-list,
.mentor-messages {
overflow-y: auto;
}
.mentor-list {
padding: var(--s-6);
}
.mentor-item {
position: relative;
display: grid;
gap: var(--s-4);
padding: var(--s-10);
border: var(--s-1) solid var(--c-transparent);
border-radius: var(--control-radius);
cursor: pointer;
transition: color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard);
}
.mentor-item:hover {
background: var(--color-surface-muted);
}
.mentor-item.active {
border-color: var(--color-primary-border);
background: var(--color-primary-soft);
}
.mentor-item-main strong {
font-size: var(--font-13);
}
.mentor-item p {
overflow: hidden;
color: var(--color-text-secondary);
font-size: var(--font-11-5);
text-overflow: ellipsis;
white-space: nowrap;
}
.mentor-item-actions {
position: absolute;
top: var(--s-6);
right: var(--s-6);
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.mentor-item:hover .mentor-item-actions,
.mentor-item:focus-within .mentor-item-actions {
opacity: 1;
}
.mentor-item-actions .icon-button {
width: var(--s-26);
height: var(--s-26);
min-height: var(--s-26);
background: var(--color-surface-raised);
}
.mentor-grade-a {
color: var(--color-up);
border-color: var(--color-up);
background: var(--color-up-soft);
}
.mentor-grade-b {
color: var(--color-primary);
border-color: var(--color-primary-border);
background: var(--color-primary-soft);
}
.mentor-grade-c {
color: var(--color-warning);
border-color: var(--color-warning);
background: var(--color-warning-soft);
}
.mentor-list-empty,
.mentor-chat-empty {
padding: var(--s-24);
color: var(--color-text-secondary);
text-align: center;
}
.mentor-chat {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
}
.mentor-chat-header {
min-height: var(--s-44);
padding: var(--s-8) var(--s-14);
border-bottom: var(--s-1) solid var(--color-divider);
}
.mentor-identity {
min-width: 0;
flex: 1;
}
.mentor-identity > span:last-child {
overflow: hidden;
color: var(--color-text-secondary);
font-size: var(--font-12);
text-overflow: ellipsis;
white-space: nowrap;
}
.mentor-messages {
display: grid;
align-content: start;
gap: var(--s-12);
padding: var(--s-16);
}
.mentor-welcome {
max-width: var(--s-dialog-wide);
display: grid;
gap: var(--s-10);
margin: auto;
padding: var(--s-24) 0;
color: var(--color-text-secondary);
text-align: center;
}
.mentor-welcome strong {
color: var(--color-text);
font-size: var(--font-15);
}
.mentor-prompts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--s-8);
}
.mentor-prompts .btn {
justify-content: flex-start;
color: var(--color-text-secondary);
text-align: left;
}
.mentor-message {
max-width: min(var(--s-dialog-wide), 88%);
display: grid;
gap: var(--s-4);
}
.mentor-message-user {
justify-self: end;
padding: var(--s-9) var(--s-12);
border-radius: var(--control-radius);
background: var(--color-primary-soft);
}
.mentor-role {
color: var(--color-text-faint);
font-size: var(--font-11);
}
.mentor-message small {
color: var(--color-warning);
font-size: var(--font-11);
}
.mentor-answer {
display: grid;
gap: var(--s-8);
color: var(--color-text);
font-size: var(--font-13);
line-height: var(--s-20);
white-space: pre-wrap;
}
.mentor-composer {
display: grid;
gap: var(--s-6);
padding: var(--s-10) var(--s-12);
border-top: var(--s-1) solid var(--color-divider);
background: var(--color-surface);
}
.mentor-composer .textarea {
min-height: var(--s-64);
max-height: var(--s-200);
}
.mentor-composer-actions {
justify-content: flex-end;
}
.mentor-composer-actions .muted {
margin-right: auto;
font-size: var(--font-11);
}
.mentor-stream-error {
justify-content: space-between;
color: var(--color-warning);
font-size: var(--font-12);
}
@@ -333,4 +333,56 @@
height: auto;
overflow: visible;
}
.mentor-page {
height: auto;
display: block;
overflow: visible;
}
.mentor-page-header {
flex-direction: row;
align-items: center;
}
.mentor-mobile-directory {
display: inline-flex;
margin-left: auto;
}
.mentor-workspace {
min-height: calc(100vh - var(--shell-topbar-height) - var(--shell-summary-height) - var(--shell-mobile-nav-height) - var(--s-200));
display: block;
}
.mentor-library {
display: none;
margin-bottom: var(--layout-gap);
}
.mentor-workspace.show-library .mentor-library {
max-height: var(--s-400);
display: grid;
}
.mentor-chat {
min-height: var(--s-400);
max-height: none;
}
.mentor-messages {
max-height: var(--s-400);
}
.mentor-prompts {
grid-template-columns: minmax(0, 1fr);
}
.mentor-message {
max-width: 96%;
}
.mentor-composer-actions .muted {
display: none;
}
}