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
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{ content: string }>();
const blocks = computed(() => props.content.split(/\n{2,}/).map((text) => text.trim()).filter(Boolean));
</script>
<template>
<div class="mentor-answer">
<p v-for="(block, index) in blocks" :key="index">{{ block }}</p>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from "vue";
import type { Mentor } from "../../shared/api/mentor";
const props = defineProps<{
mentors: Mentor[];
selectedId: string;
disabled: boolean;
}>();
const emit = defineEmits<{
select: [mentor: Mentor];
pin: [mentor: Mentor];
move: [mentor: Mentor, delta: number];
reorder: [sourceId: string, targetId: string];
}>();
const grade = defineModel<"all" | "A" | "B" | "C">("grade", { required: true });
const query = defineModel<string>("query", { required: true });
const filtered = computed(() => {
const needle = query.value.trim().toLocaleLowerCase();
return props.mentors.filter((mentor) => {
if (grade.value !== "all" && mentor.grade !== grade.value) return false;
const haystack = [mentor.name, mentor.description, mentor.tagline, ...mentor.focus].join(" ").toLocaleLowerCase();
return !needle || haystack.includes(needle);
});
});
</script>
<template>
<aside class="card mentor-library" :aria-disabled="disabled">
<header class="mentor-library-header">
<div><h2>思维模型</h2><span class="muted">{{ filtered.length }} </span></div>
<input v-model="query" class="input mentor-search" type="search" placeholder="搜索姓名、流派或标签" aria-label="搜索思维模型" />
<div class="seg-control mentor-grade-filter">
<button v-for="item in ['all', 'A', 'B', 'C']" :key="item" type="button" :class="{ active: grade === item }" @click="grade = item as typeof grade">{{ item === 'all' ? '全部' : `${item}` }}</button>
</div>
</header>
<div class="mentor-list">
<article
v-for="mentor in filtered"
:key="mentor.id"
class="mentor-item"
:class="{ active: selectedId === mentor.id }"
draggable="true"
@click="emit('select', mentor)"
@dragstart="($event.dataTransfer as DataTransfer).setData('text/plain', mentor.id)"
@dragover.prevent
@drop="emit('reorder', ($event.dataTransfer as DataTransfer).getData('text/plain'), mentor.id)"
>
<div class="mentor-item-main">
<strong>{{ mentor.name }}</strong><span class="tag" :class="`mentor-grade-${mentor.grade.toLowerCase()}`">{{ mentor.grade }}</span>
<span v-if="mentor.private" class="tag tag-admin">私有</span>
</div>
<p>{{ mentor.description || mentor.tagline }}</p>
<div class="mentor-item-actions">
<button class="icon-button" type="button" :title="mentor.pinned ? '取消置顶' : '置顶'" :aria-label="mentor.pinned ? '取消置顶' : '置顶'" @click.stop="emit('pin', mentor)">{{ mentor.pinned ? '' : '' }}</button>
<button class="icon-button" type="button" title="上移" aria-label="上移" @click.stop="emit('move', mentor, -1)"></button>
<button class="icon-button" type="button" title="下移" aria-label="下移" @click.stop="emit('move', mentor, 1)"></button>
</div>
</article>
<p v-if="!filtered.length" class="mentor-list-empty">没有匹配的思维模型</p>
</div>
</aside>
</template>
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { mentorApi, type Mentor, type MentorMessage, type MentorStreamEvent } from "../../shared/api/mentor";
import EmptyState from "../../shared/components/EmptyState.vue";
import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui";
import MentorAnswer from "./MentorAnswer.vue";
import MentorLibrary from "./MentorLibrary.vue";
const session = useSessionStore();
const market = useMarketStore();
const ui = useUiStore();
const mentors = ref<Mentor[]>([]);
const tradeDate = ref("");
const selectedId = ref("");
const messages = ref<MentorMessage[]>([]);
const grade = ref<"all" | "A" | "B" | "C">("all");
const query = ref("");
const question = ref("");
const lastQuestion = ref("");
const loading = ref(false);
const historyLoading = ref(false);
const generating = ref(false);
const streamError = ref("");
const mobileLibrary = ref(false);
let controller: AbortController | null = null;
const locked = computed(() => !session.account?.smart_access);
const selected = computed(() => mentors.value.find((item) => item.id === selectedId.value));
const quickPrompts = computed(() => [
"今天市场情绪处于什么位置?",
"当前最值得关注的风险是什么?",
"请按你的方法复盘今天的核心机会。",
"如果明天出现分歧,应观察哪些确认信号?",
]);
async function load(): Promise<void> {
loading.value = true;
streamError.value = "";
try {
const setup = await mentorApi.setup(market.selectedDate);
mentors.value = setup.mentors;
tradeDate.value = setup.trade_date;
if (!mentors.value.some((item) => item.id === selectedId.value)) {
selectedId.value = mentors.value[0]?.id ?? "";
}
await loadMessages();
} catch (error) {
streamError.value = error instanceof Error ? error.message : "问师数据读取失败";
} finally {
loading.value = false;
}
}
async function loadMessages(): Promise<void> {
messages.value = [];
if (locked.value || !selectedId.value || !tradeDate.value) return;
historyLoading.value = true;
try {
messages.value = await mentorApi.messages(selectedId.value, tradeDate.value);
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "历史记录读取失败");
} finally {
historyLoading.value = false;
}
}
async function selectMentor(mentor: Mentor): Promise<void> {
if (generating.value) stop();
selectedId.value = mentor.id;
mobileLibrary.value = false;
streamError.value = "";
await loadMessages();
}
async function savePreferences(): Promise<void> {
try {
await mentorApi.preferences(
mentors.value.map((item) => item.id),
mentors.value.filter((item) => item.pinned).map((item) => item.id),
);
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "排序保存失败");
await load();
}
}
async function pin(mentor: Mentor): Promise<void> {
mentor.pinned = !mentor.pinned;
mentors.value.sort((left, right) => Number(right.pinned) - Number(left.pinned));
await savePreferences();
}
async function move(mentor: Mentor, delta: number): Promise<void> {
const current = mentors.value.findIndex((item) => item.id === mentor.id);
const target = current + delta;
if (current < 0 || target < 0 || target >= mentors.value.length) return;
const [item] = mentors.value.splice(current, 1);
if (!item) return;
mentors.value.splice(target, 0, item);
mentors.value = [...mentors.value];
await savePreferences();
}
async function reorder(sourceId: string, targetId: string): Promise<void> {
if (!sourceId || sourceId === targetId) return;
const source = mentors.value.findIndex((item) => item.id === sourceId);
const target = mentors.value.findIndex((item) => item.id === targetId);
if (source < 0 || target < 0) return;
const [item] = mentors.value.splice(source, 1);
if (!item) return;
mentors.value.splice(target, 0, item);
mentors.value.sort((left, right) => Number(right.pinned) - Number(left.pinned));
mentors.value = [...mentors.value];
await savePreferences();
}
async function send(value = question.value): Promise<void> {
const normalized = value.trim();
if (!normalized || !selected.value || generating.value || locked.value) return;
question.value = "";
lastQuestion.value = normalized;
streamError.value = "";
messages.value.push({ role: "user", content: normalized, status: "complete" });
const answer: MentorMessage = { role: "assistant", content: "", status: "complete" };
messages.value.push(answer);
generating.value = true;
controller = new AbortController();
try {
await mentorApi.chat(
selected.value.id,
tradeDate.value,
normalized,
(event: MentorStreamEvent) => {
if (event.type === "delta") answer.content += event.content ?? "";
if (event.type === "error") {
answer.status = "error";
streamError.value = event.message ?? "回答生成失败";
}
},
controller.signal,
);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
answer.status = "stopped";
} else {
answer.status = "error";
streamError.value = error instanceof Error ? error.message : "回答生成失败";
}
} finally {
generating.value = false;
controller = null;
if (!answer.content && answer.status !== "complete") messages.value.pop();
}
}
function stop(): void {
controller?.abort();
}
async function clearHistory(): Promise<void> {
if (!selected.value || !messages.value.length) return;
const confirmed = await ui.askConfirmation({
title: "清空当前对话",
message: `仅清空 ${selected.value.name}${tradeDate.value} 的对话记录,其他模型和日期不受影响。`,
confirmLabel: "清空",
});
if (!confirmed) return;
await mentorApi.clear(selected.value.id, tradeDate.value);
messages.value = [];
ui.showToast("当前对话已清空");
}
function keydown(event: KeyboardEvent): void {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void send();
}
}
onMounted(load);
onBeforeUnmount(stop);
watch(() => market.selectedDate, load);
</script>
<template>
<main class="page-frame mentor-page">
<header class="page-header mentor-page-header">
<div><h1>问师</h1><p class="page-subtitle">公开资料蒸馏的思维模型 · 数据日期 {{ tradeDate || market.selectedDate }}</p></div>
<button class="btn mentor-mobile-directory" type="button" @click="mobileLibrary = !mobileLibrary">模型库</button>
</header>
<div v-if="locked" class="notice notice-warning membership-lock">
<span><strong>问师仅对会员开放</strong>开通会员后可使用思维模型复盘对话</span>
<button class="btn btn-small" type="button" @click="ui.openDialog('membership')">查看会员状态</button>
</div>
<div v-if="loading" class="card workspace-state">正在读取思维模型</div>
<EmptyState v-else-if="streamError && !mentors.length" class="card" title="问师暂不可用" :description="streamError" />
<section v-else class="mentor-workspace" :class="{ 'locked-content': locked, 'show-library': mobileLibrary }">
<MentorLibrary v-model:grade="grade" v-model:query="query" :mentors="mentors" :selected-id="selectedId" :disabled="locked" @select="selectMentor" @pin="pin" @move="move" @reorder="reorder" />
<article class="card mentor-chat">
<header v-if="selected" class="mentor-chat-header">
<div class="mentor-identity"><strong>{{ selected.name }}</strong><span class="tag" :class="`mentor-grade-${selected.grade.toLowerCase()}`">{{ selected.grade }}</span><span>{{ selected.description }}</span></div>
<button class="btn btn-small" type="button" :disabled="!messages.length || generating" @click="clearHistory">清空</button>
</header>
<div class="mentor-messages" aria-live="polite">
<p v-if="historyLoading" class="mentor-chat-empty">正在读取当前对话</p>
<div v-else-if="!messages.length" class="mentor-welcome">
<strong>{{ selected?.tagline || '从问题出发,按模型的方法拆解市场。' }}</strong>
<p>回答基于公开资料蒸馏不是真人本人不构成投资建议</p>
<div class="mentor-prompts"><button v-for="prompt in quickPrompts" :key="prompt" class="btn" type="button" @click="send(prompt)">{{ prompt }}</button></div>
</div>
<div v-for="(message, index) in messages" :key="message.id ?? index" class="mentor-message" :class="`mentor-message-${message.role}`">
<span class="mentor-role">{{ message.role === 'user' ? '我' : selected?.name }}</span>
<MentorAnswer v-if="message.role === 'assistant'" :content="message.content || (generating && index === messages.length - 1 ? '正在思考…' : '')" />
<p v-else>{{ message.content }}</p>
<small v-if="message.status === 'stopped'">已停止生成</small>
<small v-else-if="message.status === 'error'">回答未完整生成</small>
</div>
</div>
<footer class="mentor-composer">
<div v-if="streamError" class="mentor-stream-error"><span>{{ streamError }}</span><button v-if="lastQuestion && !generating" class="btn btn-small" type="button" @click="send(lastQuestion)">重试</button></div>
<textarea v-model="question" class="textarea" rows="2" maxlength="2000" placeholder="输入你的复盘问题" :disabled="locked || generating || !selected" @keydown="keydown" />
<div class="mentor-composer-actions"><span class="muted">Enter 发送 · Shift+Enter 换行</span><button v-if="generating" class="btn" type="button" @click="stop">停止</button><button v-else class="btn btn-primary" type="button" :disabled="!question.trim() || locked" @click="send()">发送</button></div>
</footer>
</article>
</section>
</main>
</template>