Commit 48cde513 authored by edy's avatar edy

Polish reasoning trace UI and mac packaging

parent 03af76f2
......@@ -16,6 +16,10 @@ Thumbs.db
.env
.env.local
coverage/
# 本地打包产物
*.dmg
*.blockmap
apps/*/node_modules/
apps/desktop/dist/
apps/ui/dist/
......
......@@ -19,9 +19,12 @@ Use Corepack so the workspace stays on the pinned `pnpm` version.
- `corepack pnpm dev`: start the UI and Electron shell together for local development.
- `corepack pnpm build`: build all workspace packages in dependency order.
- `corepack pnpm typecheck`: run `tsc --noEmit` across the workspace.
- `corepack pnpm package`: materialize the runtime payload, build packages, and create the Windows installer.
- `corepack pnpm package`: materialize the runtime payload, build packages, and create the macOS DMG.
- `corepack pnpm smoke:installer`: run the packaged installer smoke test.
## macOS Packaging Notes
Customer releases should be distributed as a compressed `.dmg`, not as a zipped DMG and not as an uncompressed DMG. The desktop electron-builder config must keep `compression: maximum`; using `compression: store` produces an uncompressed APFS DMG around 1.95 GB for the bundled runtime payload.
## Coding Style & Naming Conventions
The codebase uses strict TypeScript, ESM modules, and 2-space indentation. Prefer double quotes and trailing semicolon-free style, matching existing files.
......
appId: com.qianjiangclaw.desktop
productName: 千匠问天
icon: build/icon.png
compression: store
compression: maximum
asar: true
asarUnpack:
- node_modules/keytar/build/Release/*.node
......
......@@ -1184,6 +1184,7 @@ export default function App() {
sending,
messageLabels: {
thinking: ui.thinking,
traceTitle: ui.traceTitle,
hideTrace: ui.hideTrace,
traceCollapsed: ui.traceCollapsed
},
......
......@@ -63,9 +63,9 @@ export const ui = {
newSession: "\u65b0\u5efa\u4f1a\u8bdd",
closeSession: "\u5173\u95ed\u4f1a\u8bdd",
traceTitle: "\u601d\u8003\u8fc7\u7a0b",
hideTrace: "\u6536\u8d77\u8be6\u60c5",
hideTrace: "\u6536\u8d77",
traceEmpty: "\u8fd8\u6ca1\u6709\u53ef\u663e\u793a\u7684\u8fdb\u5ea6\u3002",
traceCollapsed: "\u67e5\u770b\u601d\u8003\u8fc7\u7a0b",
traceCollapsed: "\u5c55\u5f00",
preparingReply: "\u6b63\u5728\u7406\u89e3\u4f60\u7684\u95ee\u9898",
checkingChat: "\u6b63\u5728\u68c0\u67e5\u5bf9\u8bdd\u73af\u5883",
startingRuntime: "\u6b63\u5728\u542f\u52a8\u672c\u5730\u52a9\u624b",
......@@ -131,4 +131,3 @@ export const expertsPageCopy = {
} as const;
......@@ -76,6 +76,7 @@ interface ConversationWorkspaceViewProps {
sending: boolean
messageLabels: {
thinking: string
traceTitle: string
hideTrace: string
traceCollapsed: string
}
......
import type { ChatAttachment, ChatMessage } from "@qjclaw/shared-types"
import { useEffect, useMemo, useState, type ReactNode, type RefObject, type UIEvent } from "react"
import { desktopApi } from "../../lib/desktop-api"
import { getTraceLineClassName, getTraceLineLabels } from "./messageTraceDisplay"
import { getTraceDisplayLines, getTraceLineClassName } from "./messageTraceDisplay"
import type { MessageTraceState } from "./useMessageTraces"
type ViewMode = "chat" | "experts" | "plugins" | "settings" | "knowledge"
......@@ -22,6 +22,7 @@ interface VideoStatusCardContent {
interface MessageListLabels {
thinking: string
traceTitle: string
hideTrace: string
traceCollapsed: string
}
......@@ -208,6 +209,8 @@ export function MessageList({
const messageTrace = message.role === "assistant" ? messageTraces[message.id] : undefined
const hasTrace = Boolean(messageTrace?.items.length)
const isTraceExpanded = Boolean(messageTrace?.expanded)
const showReasoningStrip = message.role === "assistant" && !showThinking && (hasTrace || message.streamState === "error")
const traceDisplayLines = hasTrace ? getTraceDisplayLines(messageTrace?.items ?? []) : []
const canCopyMessage = Boolean(message.content.trim())
const copyToken = `message:${message.id}`
const reaction = messageReactions[message.id]
......@@ -216,6 +219,38 @@ export function MessageList({
return (
<article key={message.id} className={"message-card group " + message.role + (message.streamState ? " " + message.streamState : "")}>
<div className={"message-bubble" + (message.role === "assistant" ? " message-bubble-assistant" : " message-bubble-user")}>
{showReasoningStrip ? (
<div className="message-trace">
<button
type="button"
className={"reasoning-strip" + (isTraceExpanded ? " expanded" : "") + (message.streamState === "error" ? " error" : "")}
onClick={() => onTraceExpandedChange(message.id, !isTraceExpanded)}
aria-expanded={isTraceExpanded}
>
<span className="reasoning-strip-leading" aria-hidden="true" />
<span className="reasoning-strip-title">{labels.traceTitle}</span>
<span className="reasoning-strip-action">{isTraceExpanded ? labels.hideTrace : labels.traceCollapsed}</span>
</button>
{isTraceExpanded ? (
<div className="message-trace-content" aria-label="公开推理摘要">
{traceDisplayLines.map((line) => {
return (
<p key={line.key} className={getTraceLineClassName(line.tone, line.kind)}>
<span className="message-trace-marker" aria-hidden="true" />
<span className="message-trace-main">
<span className="message-trace-row">
<span className="message-trace-text">{line.title}</span>
<span className="message-trace-time">{line.time}</span>
</span>
{line.detail ? <span className="message-trace-detail">{line.detail}</span> : null}
</span>
</p>
)
})}
</div>
) : null}
</div>
) : null}
{showThinking ? (
videoStatusCard ? (
<div className="generation-status-card" aria-live="polite">
......@@ -255,27 +290,6 @@ export function MessageList({
)
) : null}
{message.role === "user" ? <MessageAttachmentStrip attachments={message.attachments} /> : null}
{hasTrace ? (
<div className="message-trace">
<button type="button" className="trace-inline-toggle" onClick={() => onTraceExpandedChange(message.id, !isTraceExpanded)}>
{isTraceExpanded ? labels.hideTrace : labels.traceCollapsed}
</button>
{isTraceExpanded ? (
<div className="message-trace-content">
{messageTrace?.items.map((item) => {
const traceLabels = getTraceLineLabels(item)
return (
<p key={item.id} className={getTraceLineClassName(item.tone)}>
<span className="message-trace-time">{traceLabels.time}</span>
<span className="message-trace-text">{traceLabels.label}</span>
{traceLabels.detail ? <span className="message-trace-detail">{traceLabels.detail}</span> : null}
</p>
)
})}
</div>
) : null}
</div>
) : null}
</div>
<span className="message-timestamp" aria-hidden="true">{formatMessageTimestamp(message.createdAt)}</span>
{message.role === "assistant" && canCopyMessage ? (
......
import type { ConversationTraceItem, TraceTone } from "./useMessageTraces"
export type TraceStepKind = "setup" | "routing" | "tool" | "writing" | "complete" | "error"
export interface TraceDisplayLine {
key: string
time: string
title: string
detail?: string
tone: TraceTone
kind: TraceStepKind
}
export function formatTraceTime(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
......@@ -9,18 +20,104 @@ export function formatTraceTime(value: string): string {
return date.toLocaleTimeString("zh-CN", { hour12: false })
}
export function getTraceLineClassName(tone: TraceTone): string {
return "message-trace-line " + tone
function compactText(value: string | undefined): string | undefined {
const text = value?.replace(/\s+/g, " ").trim()
return text || undefined
}
export function getTraceLineLabels(item: ConversationTraceItem): {
time: string
label: string
detail?: string
} {
return {
time: formatTraceTime(item.createdAt),
label: item.label,
detail: item.detail
function classifyTraceItem(item: ConversationTraceItem): TraceStepKind {
const stage = item.stage.toLowerCase()
const value = `${item.stage} ${item.label}`.toLowerCase()
if (item.tone === "error" || value.includes("error") || value.includes("failed") || value.includes("失败")) {
return "error"
}
if (item.tone === "success" || value.includes("completed") || value.includes("ready") || value.includes("完成")) {
return "complete"
}
if (stage.includes("route") || stage.includes("fallback")) {
return "routing"
}
if (stage.includes("prepare") || stage.includes("request") || stage.includes("await")) {
return "setup"
}
if (stage.includes("workspace") || stage.includes("runtime") || stage.includes("tool")) {
return "tool"
}
if (value.includes("route") || value.includes("skill") || value.includes("fallback") || value.includes("项目") || value.includes("专家")) {
return "routing"
}
if (value.includes("tool") || value.includes("workspace") || value.includes("runtime") || value.includes("local") || value.includes("本地")) {
return "tool"
}
if (value.includes("started") || value.includes("model") || value.includes("reply") || value.includes("answer") || value.includes("回答")) {
return "writing"
}
return "setup"
}
function getTraceStepTitle(item: ConversationTraceItem, kind: TraceStepKind): string {
if (kind === "error") {
return "遇到错误"
}
if (kind === "complete") {
return "回答完成"
}
if (kind === "routing") {
return "选择合适的处理方式"
}
if (kind === "tool") {
return "准备执行环境"
}
if (kind === "writing") {
return "整理回答内容"
}
if (item.stage === "request") {
return "理解问题"
}
return "准备上下文"
}
function getTraceStepDetail(item: ConversationTraceItem, kind: TraceStepKind): string | undefined {
const label = compactText(item.label)
const detail = compactText(item.detail)
if (kind === "error") {
return detail ?? label
}
if (detail) {
return detail
}
if (!label || label === getTraceStepTitle(item, kind)) {
return undefined
}
return label
}
export function getTraceLineClassName(tone: TraceTone, kind?: TraceStepKind): string {
return ["message-trace-line", tone, kind ? `trace-kind-${kind}` : ""].filter(Boolean).join(" ")
}
export function getTraceDisplayLines(items: ConversationTraceItem[]): TraceDisplayLine[] {
const lines: TraceDisplayLine[] = []
for (const item of items) {
const kind = classifyTraceItem(item)
const title = getTraceStepTitle(item, kind)
const detail = getTraceStepDetail(item, kind)
const previous = lines.at(-1)
if (previous && previous.title === title && previous.detail === detail && previous.tone === item.tone && previous.kind === kind) {
continue
}
lines.push({
key: item.id,
time: formatTraceTime(item.createdAt),
title,
detail,
tone: item.tone,
kind
})
}
return lines
}
......@@ -50,14 +50,14 @@ export function useMessageTraces() {
...current,
[messageId]: {
items: item ? [item] : [],
expanded: true
expanded: item?.tone === "error"
}
}))
}, [])
const appendTrace = useCallback((messageId: string, stage: string, label: string, detail?: string, tone: TraceTone = "info") => {
setMessageTraces((current) => {
const existing = current[messageId] ?? { items: [], expanded: true }
const existing = current[messageId] ?? { items: [], expanded: false }
return {
...current,
[messageId]: {
......
......@@ -663,5 +663,3 @@
padding: 24px;
}
}
......@@ -246,33 +246,117 @@
}
.message-trace {
margin-top: 10px;
margin: 0 0 10px;
display: grid;
gap: 8px;
gap: 6px;
width: min(100%, 880px);
}
.trace-inline-toggle {
width: fit-content;
min-width: 0;
padding: 0;
.reasoning-strip {
width: min(100%, 880px);
min-height: 32px;
display: grid;
grid-template-columns: 14px minmax(0, 1fr) auto;
justify-content: start;
align-items: center;
gap: 7px;
padding: 6px 9px;
border: 0;
background: transparent;
color: #4f6f98;
border-radius: 8px;
background: rgba(15, 23, 42, 0.045);
color: #46556b;
text-align: left;
transition: background-color 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.reasoning-strip:hover {
background: rgba(15, 23, 42, 0.07);
color: #263244;
}
.reasoning-strip:focus-visible {
outline: 2px solid rgba(71, 85, 105, 0.34);
outline-offset: 2px;
}
.reasoning-strip.expanded {
background: rgba(15, 23, 42, 0.07);
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.035);
}
.reasoning-strip.error {
background: rgba(239, 68, 68, 0.09);
color: #893535;
}
.reasoning-strip-leading {
width: 14px;
height: 14px;
display: grid;
place-items: center;
}
.reasoning-strip-leading::before {
content: "";
width: 5px;
height: 5px;
border-radius: var(--radius-full);
background: #64748b;
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.12);
}
.reasoning-strip.error .reasoning-strip-leading::before {
background: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12);
}
.reasoning-strip-title,
.reasoning-strip-action {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reasoning-strip-title {
min-width: 0;
color: inherit;
font-size: 13px;
line-height: 1.6;
font-weight: 650;
line-height: 1.45;
}
.reasoning-strip-action {
color: #6f8197;
font-size: 12px;
font-weight: 650;
line-height: 1.4;
}
.message-trace-content {
position: relative;
display: grid;
gap: 8px;
gap: 0;
padding: 6px 10px 6px 12px;
border: 0;
border-radius: 8px;
background: rgba(15, 23, 42, 0.04);
}
.message-trace-line {
position: relative;
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 8px;
margin: 0;
padding: 8px 0;
color: #667794;
font-size: 13px;
line-height: 1.75;
white-space: pre-wrap;
line-height: 1.55;
white-space: normal;
}
.message-trace-line + .message-trace-line {
border-top: 1px solid rgba(15, 23, 42, 0.06);
}
.message-trace-line.success {
......@@ -283,22 +367,69 @@
color: #972f2f;
}
.message-trace-marker {
position: relative;
width: 16px;
height: 20px;
}
.message-trace-marker::before {
content: "";
position: absolute;
top: 7px;
left: 4px;
width: 7px;
height: 7px;
border-radius: var(--radius-full);
background: #94a3b8;
}
.message-trace-line.success .message-trace-marker::before {
background: #10b981;
}
.message-trace-line.error .message-trace-marker::before {
background: #ef4444;
}
.message-trace-main {
min-width: 0;
display: grid;
gap: 3px;
}
.message-trace-row {
min-width: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.message-trace-time {
display: inline-block;
min-width: 56px;
margin-right: 8px;
flex: 0 0 auto;
color: #8ca0ba;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.message-trace-text {
min-width: 0;
overflow-wrap: anywhere;
color: inherit;
font-weight: 700;
}
.message-trace-detail {
display: block;
padding-left: 64px;
color: inherit;
display: -webkit-box;
max-width: 100%;
overflow: hidden;
color: #6f819a;
font-size: 12px;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow-wrap: anywhere;
}
.nav-item-icon svg,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment