rebuild(stage-10): deliver mentor and unified llm streaming
This commit is contained in:
@@ -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,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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user