diff --git a/apps/web/e2e/chat-numbering.spec.ts b/apps/web/e2e/chat-numbering.spec.ts new file mode 100644 index 0000000..17ae956 --- /dev/null +++ b/apps/web/e2e/chat-numbering.spec.ts @@ -0,0 +1,84 @@ +// ============================================================ +// 计划员聊天编号回归:模型常把多个选项都写成 1.,界面必须连续显示 1/2/3。 +// 全部离线:只桩会话历史,不连接现场后端。 +// ============================================================ +import { test, expect, type Page } from '@playwright/test'; + +const json = (body: unknown, status = 200) => ({ + status, contentType: 'application/json', body: JSON.stringify(body), +}); + +async function stubApi(page: Page): Promise { + const message = { + role: 'agent', + text: [ + '接下来你有三个选择:', + '', + '1. 修改现有订单', + '', + '把数量改成 50 套、交期改成 10 月 14 日。', + '', + '1. 新增一张急单', + '', + '换一个新订单号,按你给的数量和交期录入。', + '', + '1. 直接使用已有急单', + '', + '按现有急单的交期评估插入后的影响。', + ].join('\n'), + }; + + await page.route((url: URL) => url.pathname.startsWith('/api/'), route => { + const path = new URL(route.request().url()).pathname; + switch (path) { + case '/api/health': + return route.fulfill(json({ interfaceVersion: '1.0', authProvider: 'local', licenseProvider: 'local' })); + case '/api/auth/me': + return route.fulfill(json({ + user: { + user_id: 'user-1', username: 'e2e', fullname: 'E2E', tenant_uuid: 'tenant-1', + roles: ['admin'], auth_kind: 'user', + }, + provider: 'local', + license: null, + })); + case '/api/features': + return route.fulfill(json({ version: 1, source: 'default', path: '', error: null, unknown: [], features: {} })); + case '/api/workspace': + return route.fulfill(json({ + projects: [], + sessions: [{ + id: 'session-1', projectId: '__personal__', title: '编号测试', status: 'done', + updatedAt: '2026-09-18 10:00:00', scope: 'personal', + }], + files: [], + messages: { 'session-1': [message] }, + activeProjectId: '__personal__', activeSessionId: 'session-1', + })); + case '/api/sessions/session-1/messages': + return route.fulfill(json({ sessionId: 'session-1', messages: [message] })); + case '/api/skills': + return route.fulfill(json({ skills: [], skillsDir: '', mode: 'web' })); + case '/api/skills/health': + return route.fulfill(json({ health: [] })); + default: + return route.fulfill(json({ detail: 'e2e stub' }, 404)); + } + }); +} + +test('多个备选项即使都写成 1.,页面也连续显示 1、2、3', async ({ page }) => { + await stubApi(page); + await page.goto('/'); + const chat = page.locator('section.chat[data-session-ready="true"]'); + await expect(chat).toBeVisible({ timeout: 30_000 }); + + const items = chat.locator('.chat-md ol.md-list > li'); + await expect(items).toHaveCount(3); + await expect(items.nth(0)).toContainText('修改现有订单'); + await expect(items.nth(1)).toContainText('新增一张急单'); + await expect(items.nth(2)).toContainText('直接使用已有急单'); + + const values = await items.evaluateAll(nodes => nodes.map(node => (node as HTMLLIElement).value)); + expect(values).toEqual([1, 2, 3]); +}); diff --git a/apps/web/src/chat/ChatMarkdown.tsx b/apps/web/src/chat/ChatMarkdown.tsx index 000071f..61cf19a 100644 --- a/apps/web/src/chat/ChatMarkdown.tsx +++ b/apps/web/src/chat/ChatMarkdown.tsx @@ -37,7 +37,10 @@ export function ChatMarkdown(props: { text: string; streaming?: boolean }) { const raw = props.text.replace(/\r\n/g, '\n'); const lines = raw.split('\n'); const blocks: ReactNode[] = []; - let list: { ordered: boolean; items: string[] } | null = null; + let list: { ordered: boolean; items: { text: string; value?: number }[] } | null = null; + // Pi 常把同一段回复里的多个备选项拆成几张短列表,模型又会把每张都写成 1. + // 对计划员来说这是一个连续选择,所以跨空行/说明段落继续编号;遇到新标题再重置。 + let orderedCursor: number | null = null; let key = 0; const flushList = () => { @@ -46,7 +49,7 @@ export function ChatMarkdown(props: { text: string; streaming?: boolean }) { blocks.push( {list.items.map((it, i) => ( -
  • {renderInline(it)}
  • +
  • {renderInline(it.text)}
  • ))}
    , ); @@ -94,6 +97,7 @@ export function ChatMarkdown(props: { text: string; streaming?: boolean }) { const ol = /^(\d+)[.)、]\s+(.+)$/.exec(line); if (heading) { flushList(); + orderedCursor = null; const level = heading[1].length; blocks.push(
    {renderInline(heading[2])}
    ); i += 1; @@ -104,7 +108,7 @@ export function ChatMarkdown(props: { text: string; streaming?: boolean }) { flushList(); list = { ordered: false, items: [] }; } - list.items.push(ul[1]); + list.items.push({ text: ul[1] }); i += 1; continue; } @@ -113,7 +117,9 @@ export function ChatMarkdown(props: { text: string; streaming?: boolean }) { flushList(); list = { ordered: true, items: [] }; } - list.items.push(ol[2]); + const value: number = orderedCursor ?? Number(ol[1]); + orderedCursor = value + 1; + list.items.push({ text: ol[2], value }); i += 1; continue; }