feat: enhance markdown rendering and document handling

- Improved CSS styles for better overflow handling in markdown and document components.
- Updated MarkdownRenderer to ensure proper text wrapping and overflow behavior.
- Enhanced file rendering styles for .docx files to prevent horizontal overflow.
- Refactored AssistantTurnCard to manage artifact visibility and improve rendering logic.
- Modified WorkbenchComposer to include content nodes in draft changes.
- Updated apply target utilities to include source paths for better tracking.
- Enhanced reference icon components to support inline sizing.
- Introduced new ChatMessageContentNode type for better message structure.
- Improved e2e tests for document apply and revert functionality.
This commit is contained in:
hy committed 2026-06-25 12:43:21 +08:00
1 parent 1ff1d2592f
commit 5e77ef867a
21 files changed
+385 -197

No files matched your search

+2
View File
@@ -270,6 +270,7 @@ export class ChatRunService {
role: 'user',
content: payload.input,
timestamp: createdAt,
contentNodes: payload.contentNodes,
selectedReferences: normalizedReferences
}
await this.saveUserMessageAndTitle(userMessage)
@@ -345,6 +346,7 @@ export class ChatRunService {
role: 'user',
content: payload.input,
timestamp: createdAt,
contentNodes: payload.contentNodes,
selectedReferences: normalizedReferences,
error: false
}
@@ -377,6 +377,7 @@ export class LanceDbCore {
new arrow.Field('role', new arrow.Utf8()),
new arrow.Field('content', new arrow.Utf8()),
new arrow.Field('timestamp', new arrow.Int64()),
new arrow.Field('content_nodes_json', new arrow.Utf8(), true),
new arrow.Field('selected_references_json', new arrow.Utf8(), true),
new arrow.Field('artifacts_json', new arrow.Utf8(), true),
new arrow.Field('error', new arrow.Bool(), true)
@@ -20,6 +20,7 @@ type ChatMessageRow = {
role?: unknown
content?: unknown
timestamp?: unknown
content_nodes_json?: unknown
selected_references_json?: unknown
artifacts_json?: unknown
error?: unknown
@@ -51,6 +52,16 @@ const parseSelectedReferences = (value: unknown): ChatMessage['selectedReference
}
}
const parseContentNodes = (value: unknown): ChatMessage['contentNodes'] => {
if (!value) return undefined
try {
const parsed = JSON.parse(String(value))
return Array.isArray(parsed) ? parsed : undefined
} catch {
return undefined
}
}
export class ChatStore {
public constructor(private readonly core: LanceDbCore) {}
@@ -193,6 +204,7 @@ export class ChatStore {
role: message.role,
content: message.content,
timestamp: message.timestamp,
content_nodes_json: message.contentNodes ? JSON.stringify(message.contentNodes) : undefined,
selected_references_json: message.selectedReferences
? JSON.stringify(message.selectedReferences)
: undefined,
@@ -217,6 +229,7 @@ export class ChatStore {
role: toChatRole(row.role),
content: String(row.content || ''),
timestamp: Number(row.timestamp || 0),
contentNodes: parseContentNodes(row.content_nodes_json),
selectedReferences: parseSelectedReferences(row.selected_references_json),
artifacts: parseArtifacts(row.artifacts_json),
error: typeof row.error === 'boolean' ? row.error : undefined
+9 -1
View File
@@ -581,7 +581,7 @@ textarea,
}
.nc-document-scroll {
@apply min-h-0 flex-1 overflow-y-auto px-6 pt-5;
@apply min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-5;
}
.nc-document {
@@ -896,6 +896,8 @@ mark {
color: var(--ink);
font-size: 14px;
line-height: 1.78;
min-width: 0;
overflow-wrap: anywhere;
}
.markdown-body > *:first-child {
@@ -949,6 +951,7 @@ mark {
.markdown-body a {
color: var(--notion-accent-blue);
text-decoration: none;
overflow-wrap: anywhere;
transition:
background-color var(--ui-transition),
color var(--ui-transition),
@@ -963,10 +966,13 @@ mark {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
max-width: 100%;
min-width: 0;
vertical-align: middle;
color: var(--notion-accent-blue);
text-decoration: none;
text-underline-offset: 3px;
overflow-wrap: anywhere;
}
.markdown-body .markdown-reference-icon {
@@ -983,6 +989,8 @@ mark {
text-decoration: none;
text-underline-offset: 3px;
vertical-align: middle;
word-break: break-word;
overflow-wrap: anywhere;
}
.markdown-body .markdown-reference-link:hover,
@@ -65,10 +65,7 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code
{...props}
className={`markdown-inline-code ${className || ''}`}
>
<code {...props} className={`markdown-inline-code ${className || ''}`}>
{children}
</code>
)
@@ -108,7 +105,7 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
resourceType: parsed.resourceType
})}
</span>
<span>{children}</span>
<span className="min-w-0 break-words [overflow-wrap:anywhere]">{children}</span>
</a>
)
}
@@ -124,9 +121,7 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
</a>
)
},
blockquote: ({ children }) => (
<blockquote>{children}</blockquote>
),
blockquote: ({ children }) => <blockquote>{children}</blockquote>,
table: ({ children }) => (
<div className="markdown-table-wrap">
<table>{children}</table>
@@ -1,15 +1,37 @@
.vue-office-docx {
overflow-y: unset !important;
overflow-y: unset !important;
}
.docx-content {
overflow-x: hidden;
}
.docx-content .docx-wrapper {
box-sizing: border-box !important;
width: 100% !important;
min-width: 0 !important;
overflow-x: hidden !important;
background: #f7f7f5 !important;
padding: 16px !important;
padding-bottom: 0 !important;
}
.docx-content .docx-wrapper > section.docx,
.docx-content section.docx {
box-sizing: border-box !important;
max-width: 100% !important;
margin-left: auto !important;
margin-right: auto !important;
}
.docx-content ins {
background: #F4F4F2 !important;
color: #2F3437 !important;
text-decoration: none !important;
background: #f4f4f2 !important;
color: #2f3437 !important;
text-decoration: none !important;
}
.docx-content del {
background: #F8EBE9 !important;
color: #B3261E !important;
text-decoration: line-through !important;
background: #f8ebe9 !important;
color: #b3261e !important;
text-decoration: line-through !important;
}
@@ -1,8 +1,5 @@
import React from 'react'
import {
FileTextOutlined,
GlobalOutlined
} from '@ant-design/icons'
import { FileTextOutlined, GlobalOutlined } from '@ant-design/icons'
export type ParsedNcuratorRefKind = 'reference' | 'normal'
export type ParsedNcuratorRefResourceType = 'file' | 'web'
@@ -29,22 +26,27 @@ export const parseRefHref = (href?: string): ParsedRefLink => {
}
try {
const url = new URL(href)
const kind = url.searchParams.get('kind')
const resourceType = url.searchParams.get('resourceType')
const label = url.searchParams.get('label') || ''
const path = url.searchParams.get('path') || ''
if (
(kind !== 'reference' && kind !== 'normal') ||
(resourceType !== 'file' && resourceType !== 'web') ||
!label ||
!path
) {
const params = new URLSearchParams(href.slice('ncurator://ref?'.length))
const rawKind = params.get('kind')
const rawResourceType = params.get('resourceType')
const path = params.get('path') || ''
if (!path) {
return {
type: 'normal',
href
}
}
const kind: ParsedNcuratorRefKind =
rawKind === 'reference' || rawKind === 'normal' ? rawKind : 'normal'
const resourceType: ParsedNcuratorRefResourceType =
rawResourceType === 'file' || rawResourceType === 'web'
? rawResourceType
: /^https?:\/\//i.test(path)
? 'web'
: 'file'
const normalizedPath = path.replace(/\\/g, '/')
const fallbackLabel = normalizedPath.split('/').filter(Boolean).pop() || normalizedPath || path
const label = params.get('label') || fallbackLabel
return {
type: 'reference',
kind,
@@ -11,7 +11,6 @@ import {
HiOutlinePencilSquare
} from 'react-icons/hi2'
import MarkdownRenderer from '../MarkdownRenderer'
import { FILE_CHANGE_STATUS_META } from './constants'
import type {
AssistantArtifactItem,
AssistantProcessGroup,
@@ -22,10 +21,7 @@ import type {
} from './types'
import type { ParsedNcuratorRefKind, ParsedNcuratorRefResourceType } from '../markdownReference'
import { workbenchSelectors } from '../../testing/selectors'
import {
buildAssistantExecutionSummaryText,
resolveAssistantExecutionStatus
} from './utils'
import { buildAssistantExecutionSummaryText, resolveAssistantExecutionStatus } from './utils'
type AssistantTurnCardProps = {
messageId: string
@@ -238,7 +234,9 @@ const AssistantExecutionGroupView: React.FC<{ groups: AssistantProcessGroup[]; i
}) => {
const status = resolveAssistantExecutionStatus(groups)
const isRunning = status === 'running'
const items = groups.flatMap((group) => group.items).sort((left, right) => left.createdAt - right.createdAt)
const items = groups
.flatMap((group) => group.items)
.sort((left, right) => left.createdAt - right.createdAt)
const summaryText = buildAssistantExecutionSummaryText(groups)
return (
<details
@@ -286,18 +284,33 @@ const AssistantExecutionGroupView: React.FC<{ groups: AssistantProcessGroup[]; i
const renderArtifactChip = (
file: AssistantArtifactItem,
onOpenFile: (file: AssistantArtifactItem) => void
): React.ReactNode => (
<Tooltip key={`${file.origin}-${file.path}-${file.applyId || ''}`} title={file.path}>
<button
type="button"
onClick={() => onOpenFile(file)}
className="ui-hover-lift inline-flex max-w-full items-center gap-2 rounded-full border border-[#EEEEEC] bg-white px-3 py-1.5 text-left text-xs text-[#787774] hover:border-[#37352F] hover:text-[#37352F]"
>
<HiOutlineDocumentText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{file.fileName}</span>
</button>
</Tooltip>
)
): React.ReactNode => {
const testIdSuffix = (file.applyId || file.path).replace(/[^a-zA-Z0-9_-]/g, '-')
return (
<Tooltip key={`${file.origin}-${file.path}-${file.applyId || ''}`} title={file.path}>
<button
type="button"
onClick={() => onOpenFile(file)}
data-testid={`${workbenchSelectors.assistantTurnArtifactPrefix}-${testIdSuffix}`}
className="ui-hover-lift inline-flex max-w-full items-center gap-2 rounded-full border border-[#EEEEEC] bg-white px-3 py-1.5 text-left text-xs text-[#787774] hover:border-[#37352F] hover:text-[#37352F]"
>
<HiOutlineDocumentText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{file.fileName}</span>
</button>
</Tooltip>
)
}
const getCompactSourcePath = (sourcePath?: string): string => {
if (!sourcePath) return ''
const normalized = sourcePath.replace(/\\/g, '/')
const workspaceMarker = '/workspace/'
const workspaceIndex = normalized.lastIndexOf(workspaceMarker)
if (workspaceIndex >= 0) return normalized.slice(workspaceIndex + workspaceMarker.length)
const parts = normalized.split('/').filter(Boolean)
if (parts.length <= 2) return normalized
return parts.slice(-3).join('/')
}
const AssistantTurnCard: React.FC<AssistantTurnCardProps> = ({
messageId,
@@ -310,6 +323,18 @@ const AssistantTurnCard: React.FC<AssistantTurnCardProps> = ({
const processThoughts = useMemo(() => turn?.processThoughts || [], [turn?.processThoughts])
const finalAnswerMarkdown = turn?.finalAnswerMarkdown || ''
const hasFinalAnswer = finalAnswerMarkdown.trim().length > 0
const visibleArtifacts = useMemo(() => {
if (!turn) return []
const applyIds = new Set(turn.applyTargets.map((target) => target.changeId))
const applyPaths = new Set(
turn.applyTargets
.map((target) => target.sourcePath)
.filter((path): path is string => Boolean(path))
)
return turn.artifacts.filter(
(file) => !(file.applyId && applyIds.has(file.applyId)) && !applyPaths.has(file.path)
)
}, [turn])
const timeline = useMemo(
() =>
buildAssistantTimeline({
@@ -376,7 +401,7 @@ const AssistantTurnCard: React.FC<AssistantTurnCardProps> = ({
</div>
<div>
{turn.applyTargets.map((apply) => {
const statusMeta = apply.status ? FILE_CHANGE_STATUS_META[apply.status] : null
const compactPath = getCompactSourcePath(apply.sourcePath)
return (
<button
key={apply.changeId}
@@ -385,20 +410,19 @@ const AssistantTurnCard: React.FC<AssistantTurnCardProps> = ({
data-testid={`${workbenchSelectors.assistantTurnApplyPrefix}-${apply.changeId}`}
className="w-full rounded-[6px] px-3 py-2.5 text-left transition-colors duration-200 hover:bg-[#F7F7F5]"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[13px] font-medium text-[#37352F]">{apply.title}</div>
<div className="mt-1 text-xs text-[#787774]">
{apply.changeCount} 处修改
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[13px] font-medium text-[#37352F]">
{apply.title}
</div>
{compactPath ? (
<div className="mt-1 truncate text-xs text-[#787774]">{compactPath}</div>
) : null}
</div>
{statusMeta ? (
<span
className={`rounded-full border px-2.5 py-1 text-[11px] ${statusMeta.className}`}
>
{statusMeta.label}
</span>
) : null}
<span className="inline-flex shrink-0 items-center gap-1 pt-0.5 text-xs font-medium text-[#D14343]">
<HiOutlinePencilSquare className="h-3.5 w-3.5" />
{apply.changeCount}
</span>
</div>
</button>
)
@@ -407,9 +431,9 @@ const AssistantTurnCard: React.FC<AssistantTurnCardProps> = ({
</div>
) : null}
{turn.artifacts.length > 0 ? (
{visibleArtifacts.length > 0 ? (
<div className="flex flex-wrap gap-2">
{turn.artifacts.map((file) => renderArtifactChip(file, onOpenFile))}
{visibleArtifacts.map((file) => renderArtifactChip(file, onOpenFile))}
</div>
) : null}
@@ -368,7 +368,7 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
renderComposerNodesToEditor(editor, [])
ensureEditorHasCaretTarget()
setHasContent(false)
onDraftChange({ input: '', selectedReferences: [] })
onDraftChange({ input: '', contentNodes: [], selectedReferences: [] })
closeMention()
}, [closeMention, ensureEditorHasCaretTarget, onDraftChange, resetKey])
@@ -4,6 +4,7 @@ import type { AssistantTurnApplyTarget } from './types'
export const buildApplyTarget = (record: AiFileChangeRecord): AssistantTurnApplyTarget => ({
changeId: record.id,
title: record.fileName,
sourcePath: record.path,
changeCount: record.changeCount,
status: record.status
})
@@ -29,7 +30,17 @@ export const groupApplyTargetsByRunId = (
return new Map(
[...grouped.entries()].map(([runId, runRecords]) => [
runId,
runRecords
Array.from(
runRecords
.slice()
.sort((left, right) => right.updatedAt - left.updatedAt)
.reduce((latestByPath, record) => {
const key = record.path || record.id
if (!latestByPath.has(key)) latestByPath.set(key, record)
return latestByPath
}, new Map<string, AiFileChangeRecord>())
.values()
)
.slice()
.sort((left, right) => right.updatedAt - left.updatedAt)
.map((record) => buildApplyTarget(record))
@@ -4,17 +4,19 @@ import { getReferenceVisualSpec } from './referenceVisuals'
type ReferenceIconProps = {
reference: SelectedReference
size?: 'sm' | 'md'
size?: 'sm' | 'md' | 'inline'
}
const iconSizeClassName = {
sm: 'h-3.5 w-3.5',
md: 'h-4 w-4'
md: 'h-4 w-4',
inline: 'h-[1em] w-[1em]'
} as const
const containerSizeClassName = {
sm: 'h-[18px] w-[18px]',
md: 'h-7 w-7'
md: 'h-7 w-7',
inline: 'h-[1.15em] w-[1.15em]'
} as const
const DocumentGlyph: React.FC<{
@@ -18,10 +18,10 @@ export const ReferenceInlineToken: React.FC<ReferenceInlineTokenProps> = ({
return (
<span
className={`inline-flex max-w-full items-center gap-1 align-middle text-[13px] font-medium leading-5 ${visualSpec.labelClassName} ${className}`}
className={`inline-flex max-w-full items-center gap-1 align-[-0.08em] text-[0.9em] font-medium leading-[inherit] ${visualSpec.labelClassName} ${className}`}
data-role={role}
>
<ReferenceIcon reference={reference} size="sm" />
<ReferenceIcon reference={reference} size="inline" />
<span className="min-w-0 max-w-[180px] truncate">{reference.name}</span>
</span>
)
@@ -1,4 +1,4 @@
import type { SelectedReference } from '../../../../../shared/types'
import type { ChatMessageContentNode, SelectedReference } from '../../../../../shared/types'
export type ComposerTextNode = {
id: string
@@ -16,5 +16,6 @@ export type ComposerNode = ComposerTextNode | ComposerScopeNode
export type ComposerDraftValue = {
input: string
contentNodes: ChatMessageContentNode[]
selectedReferences: SelectedReference[]
}
@@ -1,4 +1,4 @@
import type { SelectedReference } from '../../../../../shared/types'
import type { ChatMessageContentNode, SelectedReference } from '../../../../../shared/types'
import type { ComposerDraftValue, ComposerNode } from './types'
import { getReferenceKey, pruneReferencesByWorkspace } from '../../../utils/reference'
import { getReferenceVisualSpec } from './referenceVisuals'
@@ -35,6 +35,8 @@ export const normalizeComposerNodes = (nodes: ComposerNode[]): ComposerNode[] =>
export const extractComposerDraftValue = (nodes: ComposerNode[]): ComposerDraftValue => {
const scopeMap = new Map<string, SelectedReference>()
const seenScopeKeys = new Set<string>()
const contentNodes: ChatMessageContentNode[] = []
const input = nodes
.filter((node): node is Extract<ComposerNode, { kind: 'text' }> => node.kind === 'text')
.map((node) => node.text)
@@ -43,12 +45,35 @@ export const extractComposerDraftValue = (nodes: ComposerNode[]): ComposerDraftV
.trim()
nodes.forEach((node) => {
if (node.kind !== 'scope') return
scopeMap.set(getScopeKey(node.scope), node.scope)
if (node.kind === 'text') {
const text = node.text.replace(/\u00a0/g, ' ')
if (text) contentNodes.push({ kind: 'text', text })
return
}
const scopeKey = getScopeKey(node.scope)
if (seenScopeKeys.has(scopeKey)) return
seenScopeKeys.add(scopeKey)
scopeMap.set(scopeKey, node.scope)
contentNodes.push({ kind: 'reference', reference: node.scope })
})
const firstText = contentNodes.find((node) => node.kind === 'text')
if (firstText?.kind === 'text') firstText.text = firstText.text.replace(/^\s+/, '')
let lastText: ChatMessageContentNode | undefined
for (let index = contentNodes.length - 1; index >= 0; index -= 1) {
if (contentNodes[index].kind === 'text') {
lastText = contentNodes[index]
break
}
}
if (lastText?.kind === 'text') lastText.text = lastText.text.replace(/\s+$/, '')
const normalizedContentNodes = contentNodes.filter(
(node) => node.kind !== 'text' || node.text.length > 0
)
return {
input,
contentNodes: normalizedContentNodes,
selectedReferences: Array.from(scopeMap.values())
}
}
@@ -90,7 +115,7 @@ export const readComposerNodesFromEditor = (
const appendReferenceIcon = (container: HTMLElement, scope: SelectedReference): void => {
const visualSpec = getReferenceVisualSpec(scope)
const icon = document.createElement('span')
icon.className = `inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center ${visualSpec.iconClassName}`
icon.className = `inline-flex h-[1.15em] w-[1.15em] shrink-0 items-center justify-center ${visualSpec.iconClassName}`
icon.dataset.referenceIconKind = visualSpec.kind
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
@@ -99,7 +124,7 @@ const appendReferenceIcon = (container: HTMLElement, scope: SelectedReference):
visualSpec.kind === 'directory' || visualSpec.kind === 'web' ? '0 0 24 24' : '0 0 18 18'
)
svg.setAttribute('aria-hidden', 'true')
svg.setAttribute('class', `h-3.5 w-3.5 ${visualSpec.iconClassName}`)
svg.setAttribute('class', `h-[1em] w-[1em] ${visualSpec.iconClassName}`)
if (visualSpec.kind === 'directory' || visualSpec.kind === 'web') {
svg.setAttribute('fill', 'none')
@@ -172,7 +197,7 @@ const appendReferenceIcon = (container: HTMLElement, scope: SelectedReference):
export const createScopeTokenElement = (scope: SelectedReference): HTMLElement => {
const visualSpec = getReferenceVisualSpec(scope)
const token = document.createElement('span')
token.className = `inline-flex max-w-full items-center gap-1 align-middle text-[13px] font-medium leading-5 ${visualSpec.labelClassName}`
token.className = `inline-flex max-w-full items-center gap-1 align-[-0.08em] text-[0.9em] font-medium leading-[inherit] ${visualSpec.labelClassName}`
token.contentEditable = 'false'
token.dataset.role = 'scope-token'
token.dataset.scopeKey = getScopeKey(scope)
@@ -166,6 +166,7 @@ export type FileChangeWorkspaceTab = {
export type AssistantTurnApplyTarget = {
changeId: string
title: string
sourcePath?: string
changeCount: number
status?: AiFileChangeStatus
}
+48 -26
View File
@@ -21,6 +21,7 @@ import type {
AiRunEvent,
AiRunRecord,
ChatMessage,
ChatMessageContentNode,
ChatSession,
ChatSessionRunSnapshot,
DocxApplyRecord,
@@ -158,6 +159,7 @@ const artifactToApplyTarget = (
return {
changeId: artifact.applyId,
title: artifact.fileName,
sourcePath: artifact.path,
changeCount: 0,
status: 'applied'
}
@@ -236,6 +238,7 @@ const reduceAssistantTurnByRunEvent = (
? mergeApplyTargets(next.applyTargets, {
changeId: event.applyId,
title: event.fileName,
sourcePath: event.path,
changeCount: 0,
status: 'applied'
})
@@ -335,14 +338,10 @@ const applyRunRecordToTurn = (
format: artifact.capability === 'docx' ? ('docx' as const) : undefined
}))
]),
applyTargets: [
...turn.applyTargets,
...artifacts
.map(artifactToApplyTarget)
.filter((target): target is AssistantTurnViewModel['applyTargets'][number] =>
Boolean(target)
)
],
applyTargets: artifacts
.map(artifactToApplyTarget)
.filter((target): target is AssistantTurnViewModel['applyTargets'][number] => Boolean(target))
.reduce((items, target) => mergeApplyTargets(items, target), turn.applyTargets),
blockers: run.pendingApproval
? [
{
@@ -489,6 +488,7 @@ const ChatPage: React.FC = () => {
const [loading, setLoading] = useState(false)
const [aiRunId, setAiRunId] = useState<string | null>(null)
const [cancellingRun, setCancellingRun] = useState(false)
const [composerContentNodes, setComposerContentNodes] = useState<ChatMessageContentNode[]>([])
const [selectedReferences, setSelectedReferences] = useState<SelectedReference[]>([])
const [workspaces, setWorkspaces] = useState<WorkspaceSource[]>([])
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(() =>
@@ -1274,6 +1274,7 @@ const ChatPage: React.FC = () => {
setAiRunId(null)
setCancellingRun(false)
setInput('')
setComposerContentNodes([])
setSelectedReferences([])
setComposerResetKey((value) => value + 1)
setPendingApprovalBatch(null)
@@ -1638,6 +1639,7 @@ const ChatPage: React.FC = () => {
}
const userQuery = input.trim()
const submittedContentNodes = composerContentNodes
const submittedReferences = selectedReferences
const likelyExecutable =
/\b(cmd|bash|shell|terminal|command|run)\b/i.test(userQuery) ||
@@ -1648,6 +1650,7 @@ const ChatPage: React.FC = () => {
}
setInput('')
setComposerContentNodes([])
setSelectedReferences([])
setComposerResetKey((value) => value + 1)
shouldAutoScrollRef.current = true
@@ -1663,6 +1666,7 @@ const ChatPage: React.FC = () => {
mode: 'ai',
sessionId: currentSessionId,
input: userQuery,
contentNodes: submittedContentNodes.length > 0 ? submittedContentNodes : undefined,
selectedReferences: submittedReferences.length > 0 ? submittedReferences : undefined,
modelName: selectedModelName,
workspace: createWorkspaceBinding(selectedWorkspace)
@@ -1708,6 +1712,7 @@ const ChatPage: React.FC = () => {
}
const userQuery = input.trim()
const submittedContentNodes = composerContentNodes
const submittedReferences = selectedReferences
const hasStrictScope = selectedReferences.length > 0 || Boolean(selectedWorkspace)
if (hasStrictScope && kbScopeDocumentIds.length === 0) {
@@ -1716,6 +1721,7 @@ const ChatPage: React.FC = () => {
}
setInput('')
setComposerContentNodes([])
setSelectedReferences([])
setComposerResetKey((value) => value + 1)
shouldAutoScrollRef.current = true
@@ -1731,6 +1737,7 @@ const ChatPage: React.FC = () => {
mode: 'search',
sessionId: currentSessionId,
input: userQuery,
contentNodes: submittedContentNodes.length > 0 ? submittedContentNodes : undefined,
selectedReferences: submittedReferences.length > 0 ? submittedReferences : undefined,
workspace: createWorkspaceBinding(selectedWorkspace),
searchDocumentIds: hasStrictScope ? kbScopeDocumentIds : undefined
@@ -2011,29 +2018,39 @@ const ChatPage: React.FC = () => {
}
const renderUserMessage = (messageItem: ChatMessage): React.ReactNode => {
const messageReferences = messageItem.selectedReferences || []
const contentNodes: ChatMessageContentNode[] =
messageItem.contentNodes && messageItem.contentNodes.length > 0
? messageItem.contentNodes
: [
...(messageItem.selectedReferences || []).map(
(reference): ChatMessageContentNode => ({ kind: 'reference', reference })
),
...(messageItem.content ? [{ kind: 'text' as const, text: messageItem.content }] : [])
]
return (
<div key={messageItem.id} className="flex justify-end">
<div className="max-w-[66%]">
<div className="rounded-[22px] bg-[#F4F4F2] px-4 py-2.5 text-[14px] leading-6 text-[#37352F]">
{messageReferences.length > 0 ? (
<div
className="mb-1.5 flex flex-wrap gap-1"
data-testid={workbenchSelectors.userMessageReferences}
>
{messageReferences.map((reference) => (
<div className="min-w-0 max-w-[66%]">
<div className="min-w-0 rounded-[22px] bg-[#F4F4F2] px-4 py-2.5 text-[14px] leading-6 text-[#37352F] [overflow-wrap:anywhere]">
<div
className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
data-testid={workbenchSelectors.userMessageReferences}
>
{contentNodes.map((node, index) =>
node.kind === 'reference' ? (
<ReferenceInlineToken
key={
reference.kind === 'web'
? `web:${reference.workspaceId}:${reference.webSourceId}`
: `${reference.kind}:${reference.workspaceId}:${reference.nodeId}`
node.reference.kind === 'web'
? `${index}:web:${node.reference.workspaceId}:${node.reference.webSourceId}`
: `${index}:${node.reference.kind}:${node.reference.workspaceId}:${node.reference.nodeId}`
}
reference={reference}
reference={node.reference}
className="mx-[1px]"
/>
))}
</div>
) : null}
{messageItem.content}
) : (
<span key={`${index}:text`}>{node.text}</span>
)
)}
</div>
</div>
</div>
</div>
@@ -2075,8 +2092,13 @@ const ChatPage: React.FC = () => {
}
const handleComposerDraftChange = useCallback(
(draft: { input: string; selectedReferences: SelectedReference[] }) => {
(draft: {
input: string
contentNodes: ChatMessageContentNode[]
selectedReferences: SelectedReference[]
}) => {
setInput(draft.input)
setComposerContentNodes(draft.contentNodes)
setSelectedReferences(draft.selectedReferences)
},
[]
+1
View File
@@ -130,6 +130,7 @@ export const workbenchSelectors = {
assistantProcessItemPrefix: 'workbench-assistant-process-item',
assistantFinalAnswerPrefix: 'workbench-assistant-final-answer',
assistantTurnApplyPrefix: 'workbench-assistant-turn-apply',
assistantTurnArtifactPrefix: 'workbench-assistant-turn-artifact',
searchResults: 'workbench-search-results',
documentView: 'workbench-document-view',
markdownViewer: 'workbench-markdown-viewer',
+12
View File
@@ -502,12 +502,23 @@ export type AiLlmUsage = {
rawUsage?: JsonObject
}
export type ChatMessageContentNode =
| {
kind: 'text'
text: string
}
| {
kind: 'reference'
reference: SelectedReference
}
export interface ChatMessage {
id: string
session_id: string
role: 'user' | 'assistant' | 'system'
content: string
timestamp: number
contentNodes?: ChatMessageContentNode[]
selectedReferences?: SelectedReference[]
artifacts?: AiArtifactRef[]
error?: boolean
@@ -784,6 +795,7 @@ export type ChatSubmitRequest = {
mode: ChatSubmitMode
sessionId: string
input: string
contentNodes?: ChatMessageContentNode[]
selectedReferences?: SelectedReference[]
modelName?: string
workspace?: WorkspaceRuntimeBinding
@@ -252,6 +252,11 @@ describe('workbench conversation e2e', () => {
await submitWorkbenchCurrentDraft(page)
const latestUserMessageReferenceTokens =
await getWorkbenchLatestUserMessageReferenceTokenTexts(page)
const latestUserMessageText = (
(await getWorkbenchLocator(page, workbenchSelectors.userMessageReferences)
.last()
.textContent()) || ''
).trim()
const latestUserMessageReferenceIconKinds = await getWorkbenchLocator(
page,
workbenchSelectors.userMessageReferences
@@ -309,6 +314,7 @@ describe('workbench conversation e2e', () => {
composerTokenCloseButtonVisible,
tokensBeforeDelete,
latestUserMessageReferenceTokens,
latestUserMessageText,
latestUserMessageReferenceIconKinds,
afterDelete,
afterWorkspaceSwitch,
@@ -321,7 +327,12 @@ describe('workbench conversation e2e', () => {
},
assert: async (result, runtime) => {
await runtime.assertThat(
result.afterFile.some((text) => text.includes('sample-edit.docx')) &&
result.latestUserMessageText.indexOf('sample-edit.docx') >= 0 &&
result.latestUserMessageText.indexOf('工作') >
result.latestUserMessageText.indexOf('sample-edit.docx') &&
result.latestUserMessageText.indexOf('请用一句话说明这些资料。') >
result.latestUserMessageText.indexOf('工作') &&
result.afterFile.some((text) => text.includes('sample-edit.docx')) &&
result.afterDirectory.some((text) => text.includes('工作')) &&
result.afterWeb.some((text) => text.includes('sample-edit.docx')) &&
result.afterWeb.some((text) => text.includes('工作')) &&
@@ -20,86 +20,116 @@ const CASE_ID = 'docx-apply-panel-preview-and-revert'
const MARKER = 'cedar-apply-731'
describe('workbench result e2e', () => {
it(CASE_ID, async () => {
await runE2ECase({
caseId: CASE_ID,
domain: 'workbench',
suite: 'result',
groups: ['apply'],
description: '真实 AI 修改真实 docx 后,验证 assistant 修改入口、右侧预览和干净回退。',
fixtures: ['tests/fixtures/workspace'],
launchMode: 'real',
windowMode: 'visible',
runMode: 'single',
execute: async (runtime) => {
const { page } = runtime
const setup = await setupWorkbenchWithTempWorkspace(page, { caseId: CASE_ID })
try {
const targetPath = path.join(setup.workspace.rootPath, 'sample-edit.docx')
const beforeText = await readDocxPlainText(targetPath)
await insertWorkbenchReferenceToken(page, 'sample-edit.docx')
const startedAtMs = Date.now()
await sendWorkbenchMessage(
page,
`请直接修改我刚选中的 sample-edit.docx,在文末新增一段:“Workbench apply marker ${MARKER}”。完成后告诉我已写回。`
)
const evidence = await waitForWorkbenchAiRunCompletion(
runtime,
startedAtMs,
setup.env.modelName
)
recordWorkbenchAiEvidence(runtime, evidence, 0.8)
const afterApplyText = await readDocxPlainText(targetPath)
const applyEntry = await waitForApplyEntry(page)
await applyEntry.click()
await getWorkbenchLocator(page, workbenchSelectors.applyPanel).waitFor({
state: 'visible'
})
const applyPanelText =
((await getWorkbenchLocator(page, workbenchSelectors.applyPanel).textContent()) ||
'').trim()
const docxViewerVisible = await getWorkbenchLocator(page, workbenchSelectors.docxViewer)
.isVisible()
.catch(() => false)
await getWorkbenchLocator(page, workbenchSelectors.applyRevertButton).click()
await waitForWorkbenchApplyRecordStatus(page, 'reverted')
const afterRevertText = await readDocxPlainText(targetPath)
const revertedPanelText =
((await getWorkbenchLocator(page, workbenchSelectors.applyPanel).textContent()) ||
'').trim()
it(
CASE_ID,
async () => {
await runE2ECase({
caseId: CASE_ID,
domain: 'workbench',
suite: 'result',
groups: ['apply'],
description: '真实 AI 修改真实 docx 后,验证 assistant 修改入口、右侧预览和干净回退。',
fixtures: ['tests/fixtures/workspace'],
launchMode: 'real',
windowMode: 'visible',
runMode: 'single',
execute: async (runtime) => {
const { page } = runtime
const setup = await setupWorkbenchWithTempWorkspace(page, { caseId: CASE_ID })
try {
const targetPath = path.join(setup.workspace.rootPath, 'sample-edit.docx')
const beforeText = await readDocxPlainText(targetPath)
await insertWorkbenchReferenceToken(page, 'sample-edit.docx')
const startedAtMs = Date.now()
await sendWorkbenchMessage(
page,
`请直接修改我刚选中的 sample-edit.docx,在文末新增一段:“Workbench apply marker ${MARKER}”。完成后告诉我已写回。`
)
const evidence = await waitForWorkbenchAiRunCompletion(
runtime,
startedAtMs,
setup.env.modelName,
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS,
{ page, blockerPolicy: 'auto_approve' }
)
recordWorkbenchAiEvidence(runtime, evidence, 0.8)
const afterApplyText = await readDocxPlainText(targetPath)
const applyEntry = await waitForApplyEntry(page)
const applyEntryText = ((await applyEntry.textContent()) || '').trim()
const artifactChipCount = await getWorkbenchLocator(page, workbenchSelectors.page)
.locator(`[data-testid^="${workbenchSelectors.assistantTurnArtifactPrefix}-"]`)
.count()
await applyEntry.click()
await getWorkbenchLocator(page, workbenchSelectors.applyPanel).waitFor({
state: 'visible'
})
const applyPanelText = (
(await getWorkbenchLocator(page, workbenchSelectors.applyPanel).textContent()) || ''
).trim()
const docxViewerVisible = await getWorkbenchLocator(page, workbenchSelectors.docxViewer)
.isVisible()
.catch(() => false)
const docxViewerNoHorizontalOverflow = await getWorkbenchLocator(
page,
workbenchSelectors.docxViewer
)
.evaluate((element) => {
if (!(element instanceof HTMLElement)) return false
const rootFits = element.scrollWidth <= element.clientWidth + 1
const renderedPagesFit = Array.from(
element.querySelectorAll<HTMLElement>('.docx-wrapper, section.docx')
).every((node) => node.scrollWidth <= node.clientWidth + 1)
return rootFits && renderedPagesFit
})
.catch(() => false)
await getWorkbenchLocator(page, workbenchSelectors.applyRevertButton).click()
await waitForWorkbenchApplyRecordStatus(page, 'reverted')
const afterRevertText = await readDocxPlainText(targetPath)
const revertedPanelText = (
(await getWorkbenchLocator(page, workbenchSelectors.applyPanel).textContent()) || ''
).trim()
return {
beforeText,
afterApplyText,
afterRevertText,
applyPanelText,
revertedPanelText,
docxViewerVisible,
fileArtifactCount: evidence.events.filter((event) => event.type === 'file_artifact')
.length
return {
beforeText,
afterApplyText,
afterRevertText,
applyEntryText,
artifactChipCount,
applyPanelText,
revertedPanelText,
docxViewerVisible,
docxViewerNoHorizontalOverflow,
fileArtifactCount: evidence.events.filter((event) => event.type === 'file_artifact')
.length
}
} finally {
await setup.workspace.cleanup()
}
} finally {
await setup.workspace.cleanup()
},
assert: async (result, runtime) => {
await runtime.assertThat(
!result.beforeText.includes(MARKER) &&
result.afterApplyText.includes(MARKER) &&
result.afterRevertText === result.beforeText &&
result.applyEntryText.includes('sample-edit.docx') &&
!result.applyEntryText.includes('已修改') &&
result.artifactChipCount === 0 &&
result.applyPanelText.includes('回退本次修改') &&
result.revertedPanelText.includes('已回退') &&
result.docxViewerVisible &&
result.docxViewerNoHorizontalOverflow &&
result.fileArtifactCount >= 1,
{
name: 'workbench-docx-apply-preview-revert-real',
message:
'AI 修改真实 docx 后应展示修改文件入口和右侧 apply 工作区;无后续修改时回退应恢复 checkpoint 并刷新状态,对齐 WB-APPLY-001、WB-APPLY-002、WB-APPLY-003、WB-PANEL-003。',
data: result
}
)
await assertNoWorkbenchFakeSignals(runtime)
}
},
assert: async (result, runtime) => {
await runtime.assertThat(
!result.beforeText.includes(MARKER) &&
result.afterApplyText.includes(MARKER) &&
result.afterRevertText === result.beforeText &&
result.applyPanelText.includes('回退本次修改') &&
result.revertedPanelText.includes('已回退') &&
result.docxViewerVisible &&
result.fileArtifactCount >= 1,
{
name: 'workbench-docx-apply-preview-revert-real',
message:
'AI 修改真实 docx 后应展示修改文件入口和右侧 apply 工作区;无后续修改时回退应恢复 checkpoint 并刷新状态,对齐 WB-APPLY-001、WB-APPLY-002、WB-APPLY-003、WB-PANEL-003。',
data: result
}
)
await assertNoWorkbenchFakeSignals(runtime)
}
})
}, WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS)
})
},
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS
)
})
+18 -13
View File
@@ -376,23 +376,28 @@ export const startLocalWorkbenchHttpServer = async (
}
}
export const navigateToWorkbench = async (page: Page): Promise<void> => {
export const navigateToWorkbench = async (
page: Page,
options?: { waitForSessionListText?: boolean }
): Promise<void> => {
await page.evaluate(() => {
window.location.hash = '#/'
})
await getWorkbenchLocator(page, workbenchSelectors.page).waitFor({ state: 'visible' })
await getWorkbenchLocator(page, workbenchSelectors.sessionsList).waitFor({ state: 'visible' })
await waitForCondition({
name: 'workbench session list ready',
timeoutMs: WORKBENCH_E2E_TIMEOUT_MS,
check: async () => {
const sessionListText =
(await getWorkbenchLocator(page, workbenchSelectors.sessionsList)
.textContent()
.catch(() => '')) || ''
return sessionListText.trim().length > 0
}
})
if (options?.waitForSessionListText !== false) {
await waitForCondition({
name: 'workbench session list ready',
timeoutMs: WORKBENCH_E2E_TIMEOUT_MS,
check: async () => {
const sessionListText =
(await getWorkbenchLocator(page, workbenchSelectors.sessionsList)
.textContent()
.catch(() => '')) || ''
return sessionListText.trim().length > 0
}
})
}
await getWorkbenchLocator(page, workbenchSelectors.input).waitFor({ state: 'visible' })
}
@@ -414,7 +419,7 @@ export const configureLiveModelThroughSettingsUi = async (
state: 'visible'
})
await navigateToWorkbench(page)
await navigateToWorkbench(page, { waitForSessionListText: false })
await selectWorkbenchModel(page, env.modelName)
}