diff --git a/CHANGELOG.md b/CHANGELOG.md index e0b974bf..c29c6a83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 印度占星 Skill 更新日志 +## 2026-10-25 — 输入框里选模型,桌面旁边可以打开本命盘 + +- 输入框里左边选模型,右边是发送。各模型的价格只写在菜单里。 +- 回答下面的赞、踩、复制、重新生成仍是原来的四个动作。「不满意原因」和兑换窗仍是原来的文案。 +- 桌面对话顶栏可以打开当前人物的本命盘,能拖宽,也能收起。刷新后还在。手机上看不到这个入口。 +- 发消息、停止、排队、撤回、退点和寒暄免费没有改。追问仍是原来的依次浮出。 +- Skill 版本不变(BUG-1434)。 + ## 2026-10-25 — 建档、清单和天空第一次出现时会动一下 - 建档的句子和卡片、起步问题、追问、档案行,第一次出现时轻轻上浮。按下按钮、问题卡和关系标签时会轻微缩小。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 0e0ba983..c9e7e3a3 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19430,3 +19430,19 @@ - 复发自:无 - 修复版本:分支 `codex/ui-kit-1-shell-20261023`。 +## BUG-1434 | 对话页换成组件库,旁边可以打开本命盘 + +- 状态:enhancement +- 首次发现:2026-10-25 +- 最近更新:2026-10-25 +- 影响面:首页对话输入框、模型选择、回答下的操作、追问、不满意原因、兑换窗、桌面对话旁边的本命盘。星盘页本身不在这次里。 +- 用户现象:输入框里左边选模型,右边发送。价格只在模型菜单里。回答下面仍是赞、踩、复制、重新生成。桌面上可以在对话旁边打开当前人物的本命盘,能拖宽,刷新后还在。手机上看不到这个入口。 +- 触发条件:打开首页对话。本命盘按钮在视口已判定为桌面(不小于 768)之后出现。打开后面板才去取本命盘。 +- 根因:不是缺陷。组件库迁移的对话页这一张(任务书 K2,决策 D4、D5、D7、D8、D9)。 +- 修复:输入框、模型菜单、回答操作、追问、不满意原因、兑换窗和跳到最新改用已有的组件库零件。当前会话、流式和反馈状态从 `Home()` 移进已有的会话 store,`Home()` 的 useState 从 33 减到 25,useRef 仍是 37。本命盘放在可拖分栏里,宽 280–560,默认 360,开着和宽度记在本机;绘制复用星盘页的图,第一次打开才载入。手机不显示入口,也不把「开着」写成关掉。流式逐字、停止、排队、撤回、退点和寒暄免费没有改。 +- 验证:见 `docs/tasks/PROGRESS-ui-kit-2-chat-20261023.md`。没有浏览器登录态,不把发过一条对话或拖过本命盘写成已经在浏览器里做过。 +- 防复发:`frontend/tests/chat-chart-panel-state.test.ts` 锁住本机记忆、宽度夹取、手机无入口,以及页面不把可拖分栏和星盘绘制打进同步包。对话回归锁住流式、停止、排队、撤回、退点和寒暄免费。增长合同锁住 `Home()` 的 hook 数不升。白名单测试锁住名单只减不增。 +- 相关记录:issue #106。任务书 `docs/tasks/TASK-ui-kit-migration-20261023.md` 的 K2。前置 #105 的外壳、#99 的启程动效。 +- 复发自:无 +- 修复版本:分支 `codex/ui-kit-2-chat-20261023`。 + diff --git a/docs/tasks/PROGRESS-ui-kit-2-chat-20261023.md b/docs/tasks/PROGRESS-ui-kit-2-chat-20261023.md new file mode 100644 index 00000000..cfa75b97 --- /dev/null +++ b/docs/tasks/PROGRESS-ui-kit-2-chat-20261023.md @@ -0,0 +1,57 @@ +# PROGRESS · 组件库 K2 对话页(2026-10-25) + +工单 #106。分支 `codex/ui-kit-2-chat-20261023`。基线 `origin/staging` `e4e240bf863b49e75bf9c7e1ba54b40cb1265f64`(#105 新侧栏和 #99 启程动效已在这个提交里)。只做任务书 **K2 对话页**。K0、K1 已完成,K3 及之后没做。 + +## 结论 + +输入框里左边选模型,右边发送。各模型的价格只写在菜单里。回答下面仍是赞、踩、复制、重新生成。桌面上可以在对话旁边打开当前人物的本命盘,能拖宽,也能收起,刷新后还在。手机上看不到这个入口。发消息、停止、排队、撤回、退点和寒暄免费没有改。 + +## 做了什么 + +- 输入框改用组件库的组合输入。底边左边是模型菜单,只显示名字;「N 点」只在菜单项里。右边是圆形发送,和框的底边对齐。停止、排队召回和「内容由 AI 生成」仍在。模型菜单走外壳已经在用的延后载入:库还没到时,按钮上仍是当前模型名,点开还没有菜单。 +- 赞、踩、复制、重新生成是图标按钮,各有提示。不满意原因和兑换窗改用对话框,句子不改。追问是描边小按钮,仍用启程进场依次浮出(260ms,间隔 64ms),没有另写时长。跳到最新和今日星语入口改用组件库按钮,原来的尺寸类还在。 +- 当前会话、咨询阶段、停止中的标记、排队中的会话和请求、流式回答、赞踩和「已复制」从 `Home()` 移进已有的会话 store。消息列表本来就在会话列表里,跟着同一份 store。`Home()` 的 useState 从 33 减到 25。useRef 开工时是 37,做完仍是 37,没有新加。`page.tsx` 从 1419 行到 1450 行,粗护栏是 1567。`useSessionManagement` 仍在 `useRectificationSurface` 前面。 +- 桌面顶栏、点数右边,是打开或收起本命盘的图标按钮,提示「打开本命盘」或「收起本命盘」。面板在可拖分栏里,宽 280–560,默认 360。开着和宽度记在本机 `chat_chart_panel_open`、`chat_chart_panel_width`。收起不把宽度写成 0。手机视口还没定、或小于 768 时不显示按钮,也不因为藏起来就把「开着」写成关掉。 +- 面板里是当前人物的本命盘,复用星盘页的绘制。只取本命分盘这一层。缓存里的盘必须是同一个人,否则重新取。没有骨架,也没有「正在加载」。不标星盘页那一次「画出来」的标记。可拖分栏和星盘绘制都在第一次打开后面才载入。外层对话网格没有改,里面用一列重复原来的行高,次级页面不包这一列。 +- 流式逐字仍在 `chat-message-row.tsx`,这个文件没有改。消息进场仍是已有的 Motion。扣点和落库没有改。 +- 白名单去掉 9 个已经不再违规的文件,没有加长,也没有改测试里写死的基线名单。 +- 没有改 `.gitea/**`,没有改生产部署,没有动数据库,没有给 `scripts/jyotish_api_server.py` 加方法。没有重做 #99 的动效时长。 + +## 断言改动(原值 / 新值 / 原因) + +| 位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| 输入框 | `(() => warmStart()?.daily ?? { kind: "pending" }); const [accountError, setAccountError] = useState(""); const [signingOut, setSigningOut] = useState(false); - const [modelCatalog, setModelCatalog] = useState(() => warmStart()?.modelCatalog ?? null); - const [activeSessionId, setActiveSessionId] = useState(() => warmStart()?.landing.activeSessionId ?? ""); + const [modelCatalog, setModelCatalog] = useState(() => { + const warm = warmStart(); + useSessionStore.getState().mountHomeConversation(warm?.landing.activeSessionId ?? ""); + return warm?.modelCatalog ?? null; + }); + const { + activeSessionId, + setActiveSessionId, + consultationPhase, + setConsultationPhase, + cancellationPending, + setCancellationPending, + pendingSessionId, + setPendingSessionId, + pendingRequestId, + setPendingRequestId, + streamingReply, + setStreamingReply, + messageFeedback, + setMessageFeedback, + copiedMessageKey, + setCopiedMessageKey, + } = useSessionStore(useShallow((state) => ({ + activeSessionId: state.activeSessionId, + setActiveSessionId: state.setActiveSessionId, + consultationPhase: state.consultationPhase, + setConsultationPhase: state.setConsultationPhase, + cancellationPending: state.cancellationPending, + setCancellationPending: state.setCancellationPending, + pendingSessionId: state.pendingSessionId, + setPendingSessionId: state.setPendingSessionId, + pendingRequestId: state.pendingRequestId, + setPendingRequestId: state.setPendingRequestId, + streamingReply: state.streamingReply, + setStreamingReply: state.setStreamingReply, + messageFeedback: state.messageFeedback, + setMessageFeedback: state.setMessageFeedback, + copiedMessageKey: state.copiedMessageKey, + setCopiedMessageKey: state.setCopiedMessageKey, + }))); const draftTheme = useRef(null); const draftEntrypoint = useRef(null); - const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null); - const [cancellationPending, setCancellationPending] = useState(false); - const [pendingSessionId, setPendingSessionId] = useState(null); - const [pendingRequestId, setPendingRequestId] = useState(null); - const [streamingReply, setStreamingReply] = useState(null); - const [messageFeedback, setMessageFeedback] = useState>({}); - const [copiedMessageKey, setCopiedMessageKey] = useState(null); const [replyOutcome, setReplyOutcome] = useState(null); const [requestError, setRequestError] = useState(null); const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState( @@ -1190,7 +1220,7 @@ export default function Home() { }; return ( - <> + openAccountDialog("billing", { returnTarget: event.currentTarget, source: "credits" })} /> + @@ -1414,6 +1445,6 @@ export default function Home() { }} /> )} - + ); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 5be2ddf6..05f32a10 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1022,6 +1022,15 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 it back. `clip` creates no scroll container, so it cannot happen again. */ .chat-panel { height: 100%; min-width: 0; min-height: 0; overflow: clip; display: grid; background: var(--color-canvas); grid-template-rows: 46px minmax(0, 1fr) auto; } .chat-panel.is-rectification { grid-template-rows: 46px minmax(0, 1fr); } +/* Home spans the outer rows with one frame, then repeats the same templates + inside `.chat-column`. Secondary pages stay direct children of `.chat-panel`. */ +.chat-chart-frame { grid-row: 1 / -1; grid-column: 1; min-width: 0; min-height: 0; height: 100%; } +.chat-chart-split { height: 100%; min-width: 0; min-height: 0; } +.chat-column { height: 100%; min-width: 0; min-height: 0; display: grid; grid-template-rows: 46px minmax(0, 1fr) auto; } +.chat-panel.is-rectification .chat-column { grid-template-rows: 46px minmax(0, 1fr); } +.chat-panel:has(.starter-balance) .chat-column { grid-template-rows: 46px minmax(0, 1fr) auto minmax(0, 1fr); } +.chat-natal-chart { height: 100%; min-width: 0; overflow: auto; padding: var(--space-3); } +.chat-header-actions .chat-chart-toggle { width: 32px; height: 32px; } .chat-header { z-index: 2; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: 0 var(--space-4); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); text-align: left; } .chat-header > div { min-width: 0; } /* One line, 46px tall. The chart chip stays beside the title and ellipsizes @@ -2343,6 +2352,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .chart-birth-line { width: 100%; min-height: 44px; justify-content: space-between; } .chat-panel.is-rectification { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr); } .chat-panel:has(.starter-balance) { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto minmax(0, 1fr); } + .chat-column { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto; } + .chat-panel.is-rectification .chat-column { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr); } + .chat-panel:has(.starter-balance) .chat-column { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto minmax(0, 1fr); } .chat-header { gap: var(--space-3); padding: max(0px, env(safe-area-inset-top)) var(--space-3) 0; } .conversation.is-empty { display: block; diff --git a/frontend/src/components/chat-chart-frame.tsx b/frontend/src/components/chat-chart-frame.tsx new file mode 100644 index 00000000..98efb645 --- /dev/null +++ b/frontend/src/components/chat-chart-frame.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { PanelRightClose, PanelRightOpen } from "lucide-react"; +import { useEffect, useState, type ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; +import { useSidebar } from "@/components/ui/sidebar"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useCurrentSubject } from "@/lib/current-subject"; +import { useUiStore } from "@/stores/ui-store"; + +type SplitComponent = (props: { + open: boolean; + width: number; + subjectId: string; + onOpenChange: (open: boolean) => void; + onWidth: (width: number) => void; + children: ReactNode; +}) => ReactNode; + +export function ChatChartToggle() { + const { isMobile, ready } = useSidebar(); + const open = useUiStore((state) => state.chatChartOpen); + const setOpen = useUiStore((state) => state.setChatChartOpen); + if (!ready || isMobile) return null; + const label = open ? "收起本命盘" : "打开本命盘"; + const Icon = open ? PanelRightClose : PanelRightOpen; + return ( + + setOpen(!open)} + > + + ); +} + +/** + * One cell spanning every row of `.chat-panel`. The inner `.chat-column` + * repeats today's row template so secondary pages can keep the outer grid. + * The split (and the chart drawing) load the first time the panel opens. + */ +export function ChatChartFrame({ children }: { children: ReactNode }) { + const { isMobile, ready } = useSidebar(); + const subjectId = useCurrentSubject(); + const open = useUiStore((state) => state.chatChartOpen); + const width = useUiStore((state) => state.chatChartWidth); + const hydrated = useUiStore((state) => state.chatChartHydrated); + const hydrate = useUiStore((state) => state.hydrateChatChart); + const setOpen = useUiStore((state) => state.setChatChartOpen); + const setWidth = useUiStore((state) => state.setChatChartWidth); + const [Split, setSplit] = useState(null); + const desktop = ready && !isMobile; + + useEffect(() => { + hydrate(); + }, [hydrate]); + + useEffect(() => { + if (!desktop || !hydrated || !open || Split) return; + let cancelled = false; + void import("@/components/chat-chart-split").then((mod) => { + if (!cancelled) setSplit(() => mod.ChatChartSplit); + }); + return () => { cancelled = true; }; + }, [Split, desktop, hydrated, open]); + + const column =
{children}
; + if (!Split || !desktop) { + return
{column}
; + } + return ( +
+ + {column} + +
+ ); +} diff --git a/frontend/src/components/chat-chart-split.tsx b/frontend/src/components/chat-chart-split.tsx new file mode 100644 index 00000000..9aeac45a --- /dev/null +++ b/frontend/src/components/chat-chart-split.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect, useRef, type ReactNode } from "react"; +import type { PanelImperativeHandle } from "react-resizable-panels"; + +import { ChatNatalChart } from "@/components/chat-natal-chart"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { CHAT_CHART_WIDTH_MAX, CHAT_CHART_WIDTH_MIN } from "@/lib/chat-chart-panel-state"; + +export function ChatChartSplit({ + open, + width, + subjectId, + onOpenChange, + onWidth, + children, +}: { + open: boolean; + width: number; + subjectId: string; + onOpenChange: (open: boolean) => void; + onWidth: (width: number) => void; + children: ReactNode; +}) { + const panelRef = useRef(null); + + useEffect(() => { + const panel = panelRef.current; + if (!panel) return; + if (open && panel.isCollapsed()) panel.expand(); + if (!open && !panel.isCollapsed()) panel.collapse(); + }, [open]); + + function onLayoutChanged(_layout: Record, meta: { isUserInteraction: boolean }) { + if (!meta.isUserInteraction) return; + const panel = panelRef.current; + if (!panel) return; + if (panel.isCollapsed()) { + onOpenChange(false); + return; + } + onOpenChange(true); + const pixels = panel.getSize().inPixels; + if (pixels >= CHAT_CHART_WIDTH_MIN) onWidth(Math.round(pixels)); + } + + return ( + + + {children} + + + + + + + ); +} diff --git a/frontend/src/components/chat-composer.tsx b/frontend/src/components/chat-composer.tsx index dab35e9d..58f64006 100644 --- a/frontend/src/components/chat-composer.tsx +++ b/frontend/src/components/chat-composer.tsx @@ -4,7 +4,11 @@ import { useSyncExternalStore } from "react"; import type { ChangeEvent, FormEvent, KeyboardEvent, ReactNode, RefObject } from "react"; import { ArrowUp, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; +import { + InputGroup, + InputGroupAddon, + InputGroupTextarea, +} from "@/components/ui/input-group"; import { CharacterRemaining } from "@/components/character-remaining"; import { characterRemainingVisible } from "@/lib/character-remaining"; import { @@ -47,8 +51,7 @@ type ChatComposerProps = { readonly queued?: { text: string; onRecall: () => void }; /** * Controls that belong to the composer itself (model picker, chart subject). - * They render on the composer's own bottom row; there is no longer a strip - * below the composer holding a permanent 44px of chrome. + * They sit on the left of the field's bottom row. The send button is on the right. */ readonly toolbar?: ReactNode; readonly onSubmit: (event: FormEvent) => void; @@ -91,48 +94,50 @@ export function ChatComposer({

{COMPOSER_QUEUE_LABEL}

{queued.text}

- + ) : null}
-