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:
1 parent
1ff1d2592f
commit
5e77ef867a
21 files changed
+385
-197
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
[]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('工作')) &&
|
||||
|
||||
+109
-79
@@ -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
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user