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
@@ -5,6 +5,7 @@ import { useRoute } from "vue-router";
import EmptyState from "../../shared/components/EmptyState.vue";
import MarketWorkspaceView from "../../pages/market/MarketWorkspaceView.vue";
import ScreenerPage from "../../pages/screener/ScreenerPage.vue";
import MentorPage from "../../pages/mentor/MentorPage.vue";
import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui";
@@ -31,6 +32,7 @@ const implementedMarket = computed(() =>
<template>
<MarketWorkspaceView v-if="implementedMarket" :workspace-key="workspace.key" />
<ScreenerPage v-else-if="workspace.key === 'screener'" />
<MentorPage v-else-if="workspace.key === 'mentor'" />
<main v-else class="page-frame">
<header class="page-header">
<h1>{{ workspace.title }}</h1>
+1
View File
@@ -14,6 +14,7 @@ import "./shared/styles/market.css";
import "./shared/styles/market-workspace.css";
import "./shared/styles/market-insights.css";
import "./shared/styles/screener.css";
import "./shared/styles/mentor.css";
import "./shared/styles/system.css";
import "./shared/styles/mobile.css";
@@ -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>
@@ -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;
}
}