feat: refine business chat workflow

This commit is contained in:
inman
2026-05-13 23:52:11 +08:00
parent 043d0f0bfe
commit 6b503dcbe9
30 changed files with 4609 additions and 126 deletions

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import {
appendBusinessResponseGuidance,
hasBusinessResponseGuidance,
stripBusinessResponseGuidance,
} from '../../shared/business-guidance';
describe('business response guidance', () => {
it('appends hidden business response guidance without changing the visible prompt', () => {
const message = appendBusinessResponseGuidance('生成昨日经营日报');
expect(message).toContain('生成昨日经营日报');
expect(message).toContain('智念业务员工');
expect(hasBusinessResponseGuidance(message)).toBe(true);
});
it('strips guidance for UI display and avoids duplicate blocks', () => {
const once = appendBusinessResponseGuidance('检查渠道房态');
const twice = appendBusinessResponseGuidance(once);
expect(stripBusinessResponseGuidance(twice)).toBe('检查渠道房态');
expect((twice.match(/\[\[YINIAN_BUSINESS_RESPONSE_GUIDANCE\]\]/g) ?? [])).toHaveLength(1);
});
});

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ChatMessage } from '@/pages/Chat/ChatMessage';
import type { RawMessage } from '@/stores/chat';
import { appendBusinessResponseGuidance } from '../../shared/business-guidance';
describe('ChatMessage attachment dedupe', () => {
it('keeps attachment-only assistant replies visible even when process attachments are suppressed', () => {
@@ -79,3 +80,67 @@ describe('ChatMessage LaTeX rendering', () => {
expect(container.querySelector('.katex')).toBeNull();
});
});
describe('ChatMessage business answer rendering', () => {
it('renders a business summary panel for structured operational replies', () => {
const message: RawMessage = {
role: 'assistant',
content: [
'状态:需处理,携程渠道房态与 PMS 不一致。',
'依据:携程 0508 房型显示可售 3 间PMS 为 0 间。',
'影响:可能产生超售风险。',
'下一步:请先暂停该房型自动售卖,并复核渠道登录态。',
].join('\n'),
};
render(<ChatMessage message={message} />);
const panel = screen.getByTestId('business-answer-panel');
expect(panel).toHaveTextContent('业务摘要');
expect(panel).toHaveTextContent('需处理,携程渠道房态与 PMS 不一致');
expect(panel).toHaveTextContent('携程 0508 房型显示可售 3 间');
expect(panel).toHaveTextContent('请先暂停该房型自动售卖');
});
it('does not add the business panel to ordinary assistant chat', () => {
const message: RawMessage = {
role: 'assistant',
content: '你好,我可以帮你处理问题。',
};
render(<ChatMessage message={message} />);
expect(screen.queryByTestId('business-answer-panel')).not.toBeInTheDocument();
});
});
describe('ChatMessage markdown rendering', () => {
it('hides legacy injected business guidance from visible user messages', () => {
const message: RawMessage = {
role: 'user',
content: appendBusinessResponseGuidance('会话中的 markdown 结构化,表格也可以加强 UI 渲染'),
};
render(<ChatMessage message={message} />);
expect(screen.getByText('会话中的 markdown 结构化,表格也可以加强 UI 渲染')).toBeInTheDocument();
expect(screen.queryByText(/YINIAN_BUSINESS_RESPONSE_GUIDANCE/)).not.toBeInTheDocument();
expect(screen.queryByText(/请按智念业务员工/)).not.toBeInTheDocument();
});
it('renders markdown tables inside the enhanced table shell', () => {
const message: RawMessage = {
role: 'assistant',
content: [
'| 渠道 | 房型 | 状态 |',
'| --- | --- | --- |',
'| 携程 | 豪华大床房 | 需复核 |',
].join('\n'),
};
render(<ChatMessage message={message} />);
expect(screen.getByTestId('markdown-table-scroll')).toBeInTheDocument();
expect(screen.getByTestId('markdown-table')).toHaveTextContent('豪华大床房');
});
});

View File

@@ -120,9 +120,10 @@ describe('chat target routing', () => {
const sendCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.send');
expect(sendCall?.[1]).toMatchObject({
sessionKey: 'agent:research:desk',
message: 'Hello direct agent',
deliver: false,
});
expect((sendCall?.[1] as { message: string }).message).toContain('Hello direct agent');
expect((sendCall?.[1] as { message: string }).message).not.toContain('YINIAN_BUSINESS_RESPONSE_GUIDANCE');
expect(typeof (sendCall?.[1] as { idempotencyKey?: unknown })?.idempotencyKey).toBe('string');
});
@@ -182,7 +183,8 @@ describe('chat target routing', () => {
};
expect(payload.sessionKey).toBe('agent:research:desk');
expect(payload.message).toBe('Process the attached file(s).');
expect(payload.message).toContain('Process the attached file(s).');
expect(payload.message).not.toContain('YINIAN_BUSINESS_RESPONSE_GUIDANCE');
expect(payload.media[0]?.filePath).toBe('/tmp/design.png');
});
@@ -251,5 +253,6 @@ describe('chat target routing', () => {
expect((sendCall?.[1] as { message: string }).message).toContain('[知识库上下文]');
expect((sendCall?.[1] as { message: string }).message).toContain('handbook.docx');
expect((sendCall?.[1] as { message: string }).message).toContain('Refunds are available within 7 days.');
expect((sendCall?.[1] as { message: string }).message).not.toContain('YINIAN_BUSINESS_RESPONSE_GUIDANCE');
});
});

View File

@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from 'vitest';
import {
buildCronDesktopReminderDisplay,
buildCronDesktopReminderEvent,
createCronDesktopReminderHandler,
} from '@electron/utils/cron-desktop-reminder';
describe('cron desktop reminders', () => {
it('builds a desktop reminder event from isolated cron completion notifications', () => {
const event = buildCronDesktopReminderEvent({
method: 'agent',
params: {
phase: 'completed',
runId: 'run-1',
sessionKey: 'agent:main:cron:job-1:run:session-a',
data: {
summary: '日报已生成,渠道价格无异常。',
},
},
});
expect(event).toMatchObject({
jobId: 'job-1',
agentId: 'main',
sessionKey: 'agent:main:cron:job-1',
runKey: 'job-1:run-1',
tone: 'success',
summary: '日报已生成,渠道价格无异常。',
});
});
it('ignores non-terminal or non-cron notifications', () => {
expect(buildCronDesktopReminderEvent({
method: 'agent',
params: {
phase: 'started',
sessionKey: 'agent:main:cron:job-1',
},
})).toBeNull();
expect(buildCronDesktopReminderEvent({
method: 'agent',
params: {
phase: 'completed',
sessionKey: 'agent:main:main',
},
})).toBeNull();
});
it('builds action-required display copy for failed runs', () => {
const event = buildCronDesktopReminderEvent({
method: 'agent',
params: {
phase: 'failed',
runId: 'run-failed',
sessionKey: 'agent:main:cron:job-failed',
error: '渠道登录态失效',
},
});
expect(event).not.toBeNull();
const display = buildCronDesktopReminderDisplay(event!, {
id: 'job-failed',
name: '渠道房态诊断',
});
expect(display).toEqual({
tone: 'danger',
title: '任务需要处理:渠道房态诊断',
body: '渠道登录态失效',
route: '/tasks?task=job-failed',
});
});
it('deduplicates repeated completion notifications for the same run', async () => {
const notify = vi.fn();
const handler = createCronDesktopReminderHandler({
now: () => 1000,
notify,
resolveJob: vi.fn().mockResolvedValue({ id: 'job-1', name: '每日经营日报' }),
});
const notification = {
method: 'agent',
params: {
phase: 'completed',
runId: 'run-1',
sessionKey: 'agent:main:cron:job-1',
},
};
await handler(notification);
await handler(notification);
expect(notify).toHaveBeenCalledTimes(1);
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({
title: '任务已完成:每日经营日报',
route: '/tasks?task=job-1',
}),
expect.objectContaining({ jobId: 'job-1' }),
);
});
});

View File

@@ -68,7 +68,12 @@ describe('handleCronRoutes', () => {
expect(handled).toBe(true);
expect(rpc).toHaveBeenCalledWith('cron.add', expect.objectContaining({
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_weather' },
payload: {
kind: 'agentTurn',
message: expect.stringContaining('Summarize today'),
},
}));
expect((rpc.mock.calls[0]?.[1] as { payload: { message: string } }).payload.message).not.toContain('YINIAN_BUSINESS_RESPONSE_GUIDANCE');
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
@@ -114,10 +119,11 @@ describe('handleCronRoutes', () => {
expect(rpc).toHaveBeenCalledWith('cron.update', {
id: 'job-2',
patch: {
payload: { kind: 'agentTurn', message: 'Updated prompt' },
payload: { kind: 'agentTurn', message: expect.stringContaining('Updated prompt') },
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_next' },
},
});
expect((rpc.mock.calls[0]?.[1] as { patch: { payload: { message: string } } }).patch.payload.message).not.toContain('YINIAN_BUSINESS_RESPONSE_GUIDANCE');
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
@@ -247,12 +253,13 @@ describe('handleCronRoutes', () => {
it('builds cron fallback query and running messages as visible chat messages', async () => {
const { buildCronSessionFallbackMessages } = await import('@electron/api/routes/cron');
const { appendBusinessResponseGuidance } = await import('../../shared/business-guidance');
const messages = buildCronSessionFallbackMessages({
sessionKey: 'agent:main:cron:job-running',
job: {
name: '写日报',
payload: { kind: 'agentTurn', message: '生成今天的经营日报' },
payload: { kind: 'agentTurn', message: appendBusinessResponseGuidance('生成今天的经营日报') },
state: { runningAtMs: 1778650080011 },
},
runs: [],

View File

@@ -62,9 +62,9 @@ function createConfig(overrides: Partial<YinianConfigSnapshot> = {}): YinianConf
};
}
function renderTasks() {
function renderTasks(initialEntries = ['/tasks']) {
return render(
<MemoryRouter>
<MemoryRouter initialEntries={initialEntries}>
<Tasks />
</MemoryRouter>,
);
@@ -190,6 +190,10 @@ describe('Tasks page', () => {
enabled: true,
});
expect(payload.message).toContain('昨日经营情况');
const reminder = await screen.findByTestId('tasks-reminder-cron-daily');
expect(reminder).toHaveTextContent('已安排');
expect(reminder).toHaveTextContent('下次将在');
});
it('pins task center cards to the sidebar quick trigger list', async () => {
@@ -218,6 +222,57 @@ describe('Tasks page', () => {
expect(useTaskCenterStore.getState().pinnedTaskIds).toEqual([]);
});
it('surfaces failed task reminders as action-required rows', async () => {
useCronStore.setState({
jobs: [{
id: 'cron-failed',
name: '渠道房态诊断',
message: '检查渠道房态是否一致',
schedule: '0 9 * * *',
delivery: { mode: 'none' },
enabled: true,
createdAt: '2026-05-13T00:00:00.000Z',
updatedAt: '2026-05-13T00:00:00.000Z',
agentId: 'main',
lastRun: {
time: '2026-05-13T01:00:00.000Z',
success: false,
error: '渠道登录态失效',
},
}],
loading: false,
error: null,
});
renderTasks();
const reminder = await screen.findByTestId('tasks-reminder-cron-failed');
expect(reminder).toHaveTextContent('需处理');
expect(reminder).toHaveTextContent('渠道登录态失效');
});
it('highlights the scheduled task opened from a desktop reminder', async () => {
useCronStore.setState({
jobs: [{
id: 'cron-daily',
name: '每日经营日报',
message: '使用日报生成助手 skill 生成日报',
schedule: '0 9 * * *',
delivery: { mode: 'none' },
enabled: true,
createdAt: '2026-05-13T00:00:00.000Z',
updatedAt: '2026-05-13T00:00:00.000Z',
agentId: 'main',
}],
loading: false,
error: null,
});
renderTasks(['/tasks?task=cron-daily']);
expect(await screen.findByTestId('tasks-job-cron-daily')).toHaveClass('ring-2');
});
it('runs a task in its fixed conversation and switches there immediately', async () => {
useCronStore.setState({
jobs: [{