Commit 48cde513 authored by edy's avatar edy

Polish reasoning trace UI and mac packaging

parent 03af76f2
...@@ -16,6 +16,10 @@ Thumbs.db ...@@ -16,6 +16,10 @@ Thumbs.db
.env .env
.env.local .env.local
coverage/ coverage/
# 本地打包产物
*.dmg
*.blockmap
apps/*/node_modules/ apps/*/node_modules/
apps/desktop/dist/ apps/desktop/dist/
apps/ui/dist/ apps/ui/dist/
......
...@@ -19,9 +19,12 @@ Use Corepack so the workspace stays on the pinned `pnpm` version. ...@@ -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 dev`: start the UI and Electron shell together for local development.
- `corepack pnpm build`: build all workspace packages in dependency order. - `corepack pnpm build`: build all workspace packages in dependency order.
- `corepack pnpm typecheck`: run `tsc --noEmit` across the workspace. - `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. - `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 ## 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. 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 appId: com.qianjiangclaw.desktop
productName: 千匠问天 productName: 千匠问天
icon: build/icon.png icon: build/icon.png
compression: store compression: maximum
asar: true asar: true
asarUnpack: asarUnpack:
- node_modules/keytar/build/Release/*.node - node_modules/keytar/build/Release/*.node
......
...@@ -1184,6 +1184,7 @@ export default function App() { ...@@ -1184,6 +1184,7 @@ export default function App() {
sending, sending,
messageLabels: { messageLabels: {
thinking: ui.thinking, thinking: ui.thinking,
traceTitle: ui.traceTitle,
hideTrace: ui.hideTrace, hideTrace: ui.hideTrace,
traceCollapsed: ui.traceCollapsed traceCollapsed: ui.traceCollapsed
}, },
......
...@@ -63,9 +63,9 @@ export const ui = { ...@@ -63,9 +63,9 @@ export const ui = {
newSession: "\u65b0\u5efa\u4f1a\u8bdd", newSession: "\u65b0\u5efa\u4f1a\u8bdd",
closeSession: "\u5173\u95ed\u4f1a\u8bdd", closeSession: "\u5173\u95ed\u4f1a\u8bdd",
traceTitle: "\u601d\u8003\u8fc7\u7a0b", traceTitle: "\u601d\u8003\u8fc7\u7a0b",
hideTrace: "\u6536\u8d77\u8be6\u60c5", hideTrace: "\u6536\u8d77",
traceEmpty: "\u8fd8\u6ca1\u6709\u53ef\u663e\u793a\u7684\u8fdb\u5ea6\u3002", 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", preparingReply: "\u6b63\u5728\u7406\u89e3\u4f60\u7684\u95ee\u9898",
checkingChat: "\u6b63\u5728\u68c0\u67e5\u5bf9\u8bdd\u73af\u5883", checkingChat: "\u6b63\u5728\u68c0\u67e5\u5bf9\u8bdd\u73af\u5883",
startingRuntime: "\u6b63\u5728\u542f\u52a8\u672c\u5730\u52a9\u624b", startingRuntime: "\u6b63\u5728\u542f\u52a8\u672c\u5730\u52a9\u624b",
...@@ -131,4 +131,3 @@ export const expertsPageCopy = { ...@@ -131,4 +131,3 @@ export const expertsPageCopy = {
} as const; } as const;
...@@ -76,6 +76,7 @@ interface ConversationWorkspaceViewProps { ...@@ -76,6 +76,7 @@ interface ConversationWorkspaceViewProps {
sending: boolean sending: boolean
messageLabels: { messageLabels: {
thinking: string thinking: string
traceTitle: string
hideTrace: string hideTrace: string
traceCollapsed: string traceCollapsed: string
} }
......
import type { ChatAttachment, ChatMessage } from "@qjclaw/shared-types" import type { ChatAttachment, ChatMessage } from "@qjclaw/shared-types"
import { useEffect, useMemo, useState, type ReactNode, type RefObject, type UIEvent } from "react" import { useEffect, useMemo, useState, type ReactNode, type RefObject, type UIEvent } from "react"
import { desktopApi } from "../../lib/desktop-api" import { desktopApi } from "../../lib/desktop-api"
import { getTraceLineClassName, getTraceLineLabels } from "./messageTraceDisplay" import { getTraceDisplayLines, getTraceLineClassName } from "./messageTraceDisplay"
import type { MessageTraceState } from "./useMessageTraces" import type { MessageTraceState } from "./useMessageTraces"
type ViewMode = "chat" | "experts" | "plugins" | "settings" | "knowledge" type ViewMode = "chat" | "experts" | "plugins" | "settings" | "knowledge"
...@@ -22,6 +22,7 @@ interface VideoStatusCardContent { ...@@ -22,6 +22,7 @@ interface VideoStatusCardContent {
interface MessageListLabels { interface MessageListLabels {
thinking: string thinking: string
traceTitle: string
hideTrace: string hideTrace: string
traceCollapsed: string traceCollapsed: string
} }
...@@ -208,6 +209,8 @@ export function MessageList({ ...@@ -208,6 +209,8 @@ export function MessageList({
const messageTrace = message.role === "assistant" ? messageTraces[message.id] : undefined const messageTrace = message.role === "assistant" ? messageTraces[message.id] : undefined
const hasTrace = Boolean(messageTrace?.items.length) const hasTrace = Boolean(messageTrace?.items.length)
const isTraceExpanded = Boolean(messageTrace?.expanded) 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 canCopyMessage = Boolean(message.content.trim())
const copyToken = `message:${message.id}` const copyToken = `message:${message.id}`
const reaction = messageReactions[message.id] const reaction = messageReactions[message.id]
...@@ -216,6 +219,38 @@ export function MessageList({ ...@@ -216,6 +219,38 @@ export function MessageList({
return ( return (
<article key={message.id} className={"message-card group " + message.role + (message.streamState ? " " + message.streamState : "")}> <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")}> <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 ? ( {showThinking ? (
videoStatusCard ? ( videoStatusCard ? (
<div className="generation-status-card" aria-live="polite"> <div className="generation-status-card" aria-live="polite">
...@@ -255,27 +290,6 @@ export function MessageList({ ...@@ -255,27 +290,6 @@ export function MessageList({
) )
) : null} ) : null}
{message.role === "user" ? <MessageAttachmentStrip attachments={message.attachments} /> : 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> </div>
<span className="message-timestamp" aria-hidden="true">{formatMessageTimestamp(message.createdAt)}</span> <span className="message-timestamp" aria-hidden="true">{formatMessageTimestamp(message.createdAt)}</span>
{message.role === "assistant" && canCopyMessage ? ( {message.role === "assistant" && canCopyMessage ? (
......
import type { ConversationTraceItem, TraceTone } from "./useMessageTraces" 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 { export function formatTraceTime(value: string): string {
const date = new Date(value) const date = new Date(value)
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
...@@ -9,18 +20,104 @@ export function formatTraceTime(value: string): string { ...@@ -9,18 +20,104 @@ export function formatTraceTime(value: string): string {
return date.toLocaleTimeString("zh-CN", { hour12: false }) return date.toLocaleTimeString("zh-CN", { hour12: false })
} }
export function getTraceLineClassName(tone: TraceTone): string { function compactText(value: string | undefined): string | undefined {
return "message-trace-line " + tone const text = value?.replace(/\s+/g, " ").trim()
return text || undefined
} }
export function getTraceLineLabels(item: ConversationTraceItem): { function classifyTraceItem(item: ConversationTraceItem): TraceStepKind {
time: string const stage = item.stage.toLowerCase()
label: string const value = `${item.stage} ${item.label}`.toLowerCase()
detail?: string if (item.tone === "error" || value.includes("error") || value.includes("failed") || value.includes("失败")) {
} { return "error"
return { }
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), time: formatTraceTime(item.createdAt),
label: item.label, title,
detail: item.detail detail,
tone: item.tone,
kind
})
} }
return lines
} }
...@@ -50,14 +50,14 @@ export function useMessageTraces() { ...@@ -50,14 +50,14 @@ export function useMessageTraces() {
...current, ...current,
[messageId]: { [messageId]: {
items: item ? [item] : [], items: item ? [item] : [],
expanded: true expanded: item?.tone === "error"
} }
})) }))
}, []) }, [])
const appendTrace = useCallback((messageId: string, stage: string, label: string, detail?: string, tone: TraceTone = "info") => { const appendTrace = useCallback((messageId: string, stage: string, label: string, detail?: string, tone: TraceTone = "info") => {
setMessageTraces((current) => { setMessageTraces((current) => {
const existing = current[messageId] ?? { items: [], expanded: true } const existing = current[messageId] ?? { items: [], expanded: false }
return { return {
...current, ...current,
[messageId]: { [messageId]: {
......
...@@ -663,5 +663,3 @@ ...@@ -663,5 +663,3 @@
padding: 24px; padding: 24px;
} }
} }
...@@ -246,33 +246,117 @@ ...@@ -246,33 +246,117 @@
} }
.message-trace { .message-trace {
margin-top: 10px; margin: 0 0 10px;
display: grid; display: grid;
gap: 8px; gap: 6px;
width: min(100%, 880px);
} }
.trace-inline-toggle { .reasoning-strip {
width: fit-content; width: min(100%, 880px);
min-width: 0; min-height: 32px;
padding: 0; display: grid;
grid-template-columns: 14px minmax(0, 1fr) auto;
justify-content: start;
align-items: center;
gap: 7px;
padding: 6px 9px;
border: 0; border: 0;
background: transparent; border-radius: 8px;
color: #4f6f98; 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; 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 { .message-trace-content {
position: relative;
display: grid; 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 { .message-trace-line {
position: relative;
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 8px;
margin: 0; margin: 0;
padding: 8px 0;
color: #667794; color: #667794;
font-size: 13px; font-size: 13px;
line-height: 1.75; line-height: 1.55;
white-space: pre-wrap; white-space: normal;
}
.message-trace-line + .message-trace-line {
border-top: 1px solid rgba(15, 23, 42, 0.06);
} }
.message-trace-line.success { .message-trace-line.success {
...@@ -283,22 +367,69 @@ ...@@ -283,22 +367,69 @@
color: #972f2f; 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 { .message-trace-time {
display: inline-block; flex: 0 0 auto;
min-width: 56px;
margin-right: 8px;
color: #8ca0ba; color: #8ca0ba;
font-size: 12px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.message-trace-text { .message-trace-text {
min-width: 0;
overflow-wrap: anywhere;
color: inherit; color: inherit;
font-weight: 700;
} }
.message-trace-detail { .message-trace-detail {
display: block; display: -webkit-box;
padding-left: 64px; max-width: 100%;
color: inherit; 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, .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