diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index caf0c2ef..512b592a 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -363,6 +363,9 @@ button:disabled { cursor: default; opacity: .45; }
.message-assistant .message-content { width: auto; max-width: none; flex: 1; }
.message p { max-width: none; margin: 0; color: var(--color-ink); font-size: var(--type-body-md); line-height: 1.65; white-space: pre-wrap; }
.message-markdown { color: var(--color-ink); font-size: var(--type-body-md); line-height: 1.65; }
+/* The report body, inline where the collapsed 完整分析 control used to be. */
+.answer-report-body { margin-top: var(--space-4); }
+.answer-report-body > .message-markdown > *:first-child { margin-top: 0; }
.message-markdown > *:first-child { margin-top: 0; }
.message-markdown > *:last-child { margin-bottom: 0; }
.claim-boundary-badge { width: fit-content; margin: 0 0 var(--space-2); padding: var(--space-1) var(--space-2); border: 1px solid color-mix(in srgb, var(--color-action) 24%, transparent); border-radius: 999px; background: var(--color-action-soft); color: var(--color-action-hover); font-size: var(--type-overline); line-height: 1.45; }
@@ -1445,50 +1448,6 @@ button:disabled { cursor: default; opacity: .45; }
font-size: inherit;
font-variant-numeric: tabular-nums;
}
-.answer-detail {
- margin-top: var(--space-5);
- padding-top: var(--space-3);
- border-top: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
-}
-.answer-detail > summary {
- width: fit-content;
- display: flex;
- align-items: center;
- gap: var(--space-2);
- min-height: 32px;
- margin-inline: calc(var(--space-1) * -1);
- padding: 0 var(--space-2);
- border-radius: var(--radius-sm);
- color: var(--color-ink-tertiary);
- cursor: pointer;
- list-style: none;
- font-size: var(--type-caption);
- font-weight: 500;
-}
-.answer-detail > summary::-webkit-details-marker { display: none; }
-.answer-detail > summary::before {
- width: 6px;
- height: 6px;
- border-inline-end: 1.5px solid currentColor;
- border-bottom: 1.5px solid currentColor;
- content: "";
- transform: rotate(-45deg);
- transition: transform 120ms ease-out;
-}
-.answer-detail[open] > summary::before { transform: rotate(45deg) translate(-1px, -1px); }
-.answer-detail > summary:hover { background: var(--color-canvas-muted); color: var(--color-ink-secondary); }
-.answer-detail > summary:focus-visible {
- outline: 2px solid color-mix(in srgb, var(--color-focus) 52%, transparent);
- outline-offset: 1px;
-}
-.answer-detail > summary small {
- color: var(--color-ink-tertiary);
- font-size: inherit;
- font-variant-numeric: tabular-nums;
-}
-.answer-detail-panel {
- margin-top: var(--space-3);
-}
.technique-audit-panel {
max-width: 100%;
margin-top: var(--space-2);
@@ -4801,7 +4760,6 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
-webkit-text-fill-color: currentColor;
}
.technique-audit > summary::before { transition: none; }
- .answer-detail > summary::before { transition: none; }
}
/* ============================================================
diff --git a/frontend/src/components/app-nav-rail.tsx b/frontend/src/components/app-nav-rail.tsx
index 3edfb42e..6f2d1656 100644
--- a/frontend/src/components/app-nav-rail.tsx
+++ b/frontend/src/components/app-nav-rail.tsx
@@ -1,5 +1,6 @@
"use client";
+import Link from "next/link";
import { usePathname } from "next/navigation";
import { CalendarDays, FileText, Orbit, SquarePen, Star } from "lucide-react";
import {
@@ -10,13 +11,13 @@ import {
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
- SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
import { persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url";
import { groupSessionsByRecency } from "@/lib/session-groups";
+import { UserAvatar } from "@/components/user-avatar";
import { useNavRail, type NavRailSession } from "@/hooks/use-nav-rail";
/**
@@ -47,24 +48,26 @@ export function AppNavRail() {
const history = sessions.filter((session) => !session.pinned);
const groups = groupSessionsByRecency(history);
- /* Same navigation the chat sidebar performs when it leaves for one of these
- pages: a real document load, with the return target stored first. */
- function go(href: string) {
+ /* Real links, not buttons with a router call. `useRouter()` throws outside an
+ app-router context (it took out twelve render tests), and an gives
+ client-side navigation for free — clicking 新建对话 from a chart used to
+ reload the whole document. `/login` keeps the hard exit: it crosses an auth
+ boundary and wants the return target persisted first. */
+ function leaveForLogin() {
persistLoginSessionReturn();
if (isMobile) setOpenMobile(false);
- window.location.assign(href);
+ }
+
+ function closeDrawer() {
+ if (isMobile) setOpenMobile(false);
}
function renderRow(session: NavRailSession) {
return (
-
+
);
}
@@ -78,23 +81,24 @@ export function AppNavRail() {
- go("/")}>
+
{showExpandedContent ? 新建对话 : null}
-
+
{NAV_PAGES.map(({ href, label, icon: Icon }) => (
- go(href)}
+ data-sidebar="menu-button"
+ data-active={pathname === href || pathname.startsWith(`${href}/`)}
+ href={href}
+ title={label}
+ onClick={closeDrawer}
>
{showExpandedContent ? {label} : null}
-
+
))}
@@ -136,22 +140,32 @@ export function AppNavRail() {
{signedOut ? (
-
+
) : (
- /* Identity only. Account actions live in the chat page's own menu; a
- second copy here would need the settings dialog stack to come with it. */
-
- {account?.initial ?? "·"}
+ /* Draws the same beam avatar the chat sidebar does — the rail used to
+ show only the initial, so the same account wore two different faces
+ depending on which page you were on. It is a real control now: the
+ account menu itself lives on `/` with the settings dialogs behind
+ it, so this goes there rather than growing a second copy. */
+
+ {account?.avatar
+ ?
+ : {account?.initial ?? "·"}}
{showExpandedContent && account ? (
<>
{account.name}
{account.credits} 点
>
) : null}
-
}
-
- );
-}
diff --git a/frontend/src/components/chat-message-content.tsx b/frontend/src/components/chat-message-content.tsx
index 5f624af2..737c2209 100644
--- a/frontend/src/components/chat-message-content.tsx
+++ b/frontend/src/components/chat-message-content.tsx
@@ -2,7 +2,6 @@
import { memo, useEffect, useState, type ReactNode } from "react";
-import { AnswerDetailDisclosure } from "@/components/chat-answer-detail";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { plainParagraphs } from "@/components/chat-message-paragraphs";
import { TechniqueAuditDisclosure } from "@/components/technique-audit-disclosure";
@@ -139,13 +138,12 @@ export function ChatMessageContent({
: }
) : null}
- {report
- ? (
-
- {reportBody}
-
- )
- : foldedAudit}
+ {/* The body used to sit inside a collapsed 完整分析 control: everything
+ from the first H2 down was one click away, so the part of the answer
+ that carries the reasoning was invisible by default. The answer is the
+ product — it renders inline. The technique audit keeps its own
+ disclosure, because that one is evidence for checking, not the reply. */}
+ {report ?
{reportBody}
: foldedAudit}
);
}
diff --git a/frontend/src/hooks/use-nav-rail.ts b/frontend/src/hooks/use-nav-rail.ts
index ee007736..c8833ae3 100644
--- a/frontend/src/hooks/use-nav-rail.ts
+++ b/frontend/src/hooks/use-nav-rail.ts
@@ -2,6 +2,8 @@
import { useEffect, useState } from "react";
+import type { BeamAvatar } from "@/lib/beam-avatar";
+
/**
* Read-only nav data for the secondary pages (/chart, /ephemeris, /reports).
*
@@ -23,6 +25,8 @@ export type NavRailAccount = {
readonly name: string;
readonly initial: string;
readonly credits: number;
+ /** The same beam avatar the chat sidebar draws, so the two footers match. */
+ readonly avatar: BeamAvatar | null;
};
export type NavRailState = {
@@ -86,6 +90,7 @@ export function useNavRail(): NavRailState {
credits?: unknown;
user?: { email?: unknown };
profile?: { name?: unknown };
+ avatar?: BeamAvatar | null;
} | null;
const name = typeof body?.profile?.name === "string" ? body.profile.name.trim() : "";
const email = typeof body?.user?.email === "string" ? body.user.email : "";
@@ -94,6 +99,7 @@ export function useNavRail(): NavRailState {
name: name || email || "账户",
initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你",
credits: typeof body?.credits === "number" ? body.credits : 0,
+ avatar: body?.avatar ?? null,
});
}
} else if (accountResult.status === "fulfilled" && accountResult.value.status === 401) {
diff --git a/frontend/src/mastra/product-voice.ts b/frontend/src/mastra/product-voice.ts
index 82e3a7a4..c4e217ac 100644
--- a/frontend/src/mastra/product-voice.ts
+++ b/frontend/src/mastra/product-voice.ts
@@ -13,7 +13,7 @@ Never open with 根据系统计算, 根据当前计算结果, 综合来看, 需
export const natalSpokenReportContract = `NATAL REPORT CONTRACT (spoken reply first, then skill Level 2 skeleton)
The skill governs METHOD, TECHNIQUE INVOCATION, TRUTH BOUNDARIES, and the Level 2 skeleton — not the opening paragraphs.
-For career / wealth / marriage / family (and any natal domain reading): open with 3–6 heading-free sentences that answer the user's actual question (directional reading + one clause of context), before the first H2. Shape: one conclusion sentence, then 2–3 short point-sentences (complete sentences, not noun-phrase lists; each ≤ 30 Chinese characters), then one next-step sentence. Spoken-layer total ≤ 400 Chinese characters. Then write the skill Level 2 template. 骨架不可省略,但必须以直接回应开场. The product UI folds everything from the first H2 into a collapsed 完整分析 control; you must still write the full skeleton in the body — the fold is visibility, not omission. Do not hide tables in a collapsed control as the only copy.
+For career / wealth / marriage / family (and any natal domain reading): open with 3–6 heading-free sentences that answer the user's actual question (directional reading + one clause of context), before the first H2. Shape: one conclusion sentence, then 2–3 short point-sentences (complete sentences, not noun-phrase lists; each ≤ 30 Chinese characters), then one next-step sentence. Spoken-layer total ≤ 400 Chinese characters. Then write the skill Level 2 template. 骨架不可省略,但必须以直接回应开场. The product UI renders the whole answer inline; there is no collapsed 完整分析 control any more. Write the full skeleton in the body and assume every line of it is read. Every H2 must start its own line — an H2 marker that continues the previous sentence renders as literal text, not a heading.
Required body order after that opener, using these exact H2 headings: ## 统一参数与原始结构, then ## {Chinese domain label} for each executed domain, then ## 技法审计表, then ## 现代生活. Do not write a thinking-process checklist; Activity is server-owned.
1. Unified parameters and raw structure first (degrees, houses, vargas, Dasha boundaries, Shadbala/AV, functional benefic/malefic, Western layers the server delivered).
diff --git a/frontend/tests/chat-answer-detail.test.ts b/frontend/tests/chat-answer-detail.test.ts
index 7ce3a33e..f7310ef7 100644
--- a/frontend/tests/chat-answer-detail.test.ts
+++ b/frontend/tests/chat-answer-detail.test.ts
@@ -2,43 +2,51 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
+/**
+ * 原值:这份文件断言回答的第二层是一个默认收起的 ``
+ * 「完整分析」控件(`chat-answer-detail.tsx`),并锁住它的 caption、流式期
+ * 不渲染子节点、以及它与 technique-audit 是两套样式。
+ * 新值:控件与组件文件一并删除,报告层直接渲染进正文 `.answer-report-body`。
+ * 原因:产品在真机走查后拍板——首个 H2 之后的全部内容(也就是带推理的那一半)
+ * 默认不可见,用户「输出根本看不到」。回答本身就是产品,不该藏在一次点击后面。
+ * 技法审计表保留它自己的折叠:那是给人核对的证据,不是回复正文。
+ *
+ * 下面保留的断言是原文件里与折叠无关、仍然成立的那些(记忆化、复制取原文、
+ * onboarding 走同一渲染链),并新增两条锁住「不得再把回答折起来」。
+ */
+
const contentSource = readFileSync(new URL("../src/components/chat-message-content.tsx", import.meta.url), "utf8");
-const disclosureSource = readFileSync(new URL("../src/components/chat-answer-detail.tsx", import.meta.url), "utf8");
const transcriptSource = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8");
const onboardingSource = readFileSync(new URL("../src/components/onboarding-chat-message.tsx", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
-test("the answer fold is a closed details element with a chapter caption", () => {
- assert.match(disclosureSource, //);
- assert.doesNotMatch(disclosureSource, /]*\sopen/);
- assert.match(disclosureSource, /完整分析/);
- assert.match(disclosureSource, /answerDetailCaption\(headings\)/);
- assert.doesNotMatch(disclosureSource, />展开);
+test("the report layer renders inline; the answer is never folded away", () => {
+ assert.match(contentSource, /