fix(chat): 只上传文档时给出梳理或追问,不再裸报 HTTP 400

空消息加附件可以进入回合。无文字时按解析状态引导或回退审阅,有文字但未处理文档时先回答再追问。4xx 信封不再被前端丢弃。
This commit is contained in:
weichen
2026-09-28 02:00:24 +00:00
parent 3e534bbb39
commit 457e870330
21 changed files with 911 additions and 38 deletions
+29
View File
@@ -3,6 +3,7 @@ import type { MessageExportCapability } from '../types/replyExport';
import { fallbackErrorClass } from './chatRetry';
import { mergeAuthHeaders } from './authHeaders';
import { apiFetch } from './client';
import { parseProjectSettingsErrorBody } from './projectSettingsError';
export type StreamEvent = {
id?: string;
@@ -160,6 +161,30 @@ function takeSSEFrames(
return { frames, rest: buffer.slice(start) };
}
const GENERIC_VALIDATION_MESSAGES = new Set(['请求参数无效', '请求无法完成', 'VALIDATION']);
/** 非 2xx 时读出信封。VALIDATION 的安全文案太泛,改用公开的 error 字段。 */
export function describeChatHttpFailure(
text: string,
): { code: string; detail: string } {
const parsed = parseProjectSettingsErrorBody(text);
let detail = parsed.message || '';
try {
const raw = JSON.parse(text) as { error?: unknown; message?: unknown };
const legacy = typeof raw.error === 'string' && raw.error[0] !== '{' ? raw.error : '';
if (
(parsed.code === 'VALIDATION' || parsed.code === 'message cannot be empty') &&
legacy &&
(GENERIC_VALIDATION_MESSAGES.has(detail) || detail === parsed.code)
) {
detail = legacy;
}
} catch {
// 非 JSON 时沿用解析器给出的正文。
}
return { code: parsed.code, detail };
}
export function parseSSEChunk(
buffer: string,
options?: { flush?: boolean },
@@ -206,9 +231,13 @@ export async function streamChat(
const rid = resp.headers.get('X-Request-Id') || resp.headers.get('x-request-id');
if (rid) handlers.onRequestId?.(rid);
if (!resp.ok || !resp.body) {
const failureText = resp.ok ? '' : await resp.text();
const failure = describeChatHttpFailure(failureText);
throw Object.assign(new Error(`HTTP ${resp.status}`), {
httpStatus: resp.status,
requestId: rid,
errorCode: failure.code,
detail: failure.detail,
});
}
const reader = resp.body.getReader();
+3 -1
View File
@@ -18,6 +18,7 @@ import { holdsDocDuty } from '../lib/docGovActionVisibility';
import { resolveSessionProjectId } from '../lib/sessionAdapter';
import { expandRightDocRail } from '../lib/expandRightDocRail';
import { registerUploadAttachments } from '../lib/uploadFreeformRegister';
import { turnAttachmentRefs } from '../lib/turnAttachments';
import type { DocumentAST } from '../types/block';
import type { UserProfile, UserRole } from '../types/docgov';
import type { SendMessageResult } from '../types/sendMessage';
@@ -135,7 +136,7 @@ export interface SendMessageInput {
userText: string,
asstId: string,
attemptIndex: number,
opts?: { userMsgId?: string; clientTurnId?: string; quote?: QuotedMessage },
opts?: { userMsgId?: string; clientTurnId?: string; quote?: QuotedMessage; attachments?: ReturnType<typeof turnAttachmentRefs> },
) => void;
handlePassReview: () => Promise<void>;
handleApprove: () => Promise<void>;
@@ -472,6 +473,7 @@ export function useSendMessage(input: SendMessageInput) {
userMsgId: userMsg.id,
clientTurnId,
quote: quotes && quotes.length > 0 ? quotes[0] : undefined,
attachments: turnAttachmentRefs(processedAttachments),
});
return { accepted: true };
}
+7 -2
View File
@@ -51,6 +51,7 @@ import type {
QuotedMessage,
TaskRunContext,
} from '../types/workbench';
import { turnAttachmentRefs, type TurnAttachmentRef } from '../lib/turnAttachments';
import { useTaskRunStream } from './useTaskRunStream';
import { useTurnRuntime } from './useTurnRuntime';
@@ -124,6 +125,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
userMsgId?: string;
clientTurnId?: string;
quote?: QuotedMessage;
attachments?: TurnAttachmentRef[];
};
const runStreamAttempt = (
@@ -232,6 +234,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
message: userText,
client_turn_id: opts?.clientTurnId,
quote: quote,
attachments: opts?.attachments,
},
{
onEvent: (ev) => {
@@ -973,11 +976,13 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
const sourceMsg = target.sourceUserMessageId
? messages.find((m) => m.id === target.sourceUserMessageId)
: undefined;
const originalText = sourceMsg?.content?.trim();
if (!originalText) return;
const originalText = sourceMsg?.content?.trim() || '';
const retryAttachments = turnAttachmentRefs(sourceMsg?.attachments);
if (!originalText && retryAttachments.length === 0) return;
runStreamAttempt(originalText, messageId, 0, {
userMsgId: sourceMsg?.id,
clientTurnId: target.clientTurnId || sourceMsg?.clientTurnId,
attachments: retryAttachments,
});
};
+13 -2
View File
@@ -132,9 +132,13 @@ export function buildNetworkErrorInfo(
}
if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) {
const shown = code || `HTTP_${httpStatus}`;
const validationDetail =
(code === 'VALIDATION' || code === 'message cannot be empty') && detail
? humanizeValidationDetail(detail)
: '';
return {
userMessage: detail || `请求失败(${shown}),请重试或联系管理员`,
code: shown,
userMessage: validationDetail || detail || `请求失败(${shown}),请重试或联系管理员`,
code: code === 'message cannot be empty' ? 'VALIDATION' : shown,
class: 'fatal',
actions: ['retry'],
};
@@ -147,6 +151,13 @@ export function buildNetworkErrorInfo(
};
}
function humanizeValidationDetail(detail: string): string {
if (detail === 'message cannot be empty') {
return '请输入文字,或上传文档后再发送';
}
return detail;
}
/** interrupted 且有工作区时可继续;否则只允许按原请求重开。 */
export function buildInterruptedTaskErrorInfo(errorMessage: string, workspaceId: string): ChatErrorInfo {
const message = errorMessage.trim() || '任务执行中断,可继续或重新开始';
+38
View File
@@ -0,0 +1,38 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { turnAttachmentRefs } from './turnAttachments';
import { describeChatHttpFailure } from '../api/chatStream';
import { buildNetworkErrorInfo } from './taskRunErrorInfo';
test('turnAttachmentRefs keeps doc and parse facts', () => {
const refs = turnAttachmentRefs([
{ name: 'F-030-PIR.pdf', docId: 'doc-1', assetId: 8, parseState: 'pending' },
{ name: ' ' },
]);
assert.deepEqual(refs, [
{
filename: 'F-030-PIR.pdf',
doc_id: 'doc-1',
asset_id: 8,
parse_state: 'pending',
},
]);
});
test('describeChatHttpFailure prefers the public validation error', () => {
const failure = describeChatHttpFailure(
JSON.stringify({
code: 'VALIDATION',
message: '请求参数无效',
error: 'message cannot be empty',
}),
);
assert.equal(failure.code, 'VALIDATION');
assert.equal(failure.detail, 'message cannot be empty');
const info = buildNetworkErrorInfo(400, {
errorCode: failure.code,
detail: failure.detail,
});
assert.equal(info.userMessage, '请输入文字,或上传文档后再发送');
assert.equal(info.code, 'VALIDATION');
});
+32
View File
@@ -0,0 +1,32 @@
export type TurnAttachmentRef = {
filename: string;
doc_id?: string;
asset_id?: number;
parse_state?: string;
};
type AttachmentLike = {
name?: string;
docId?: string;
assetId?: number;
parseState?: string;
};
/** 把输入区附件收成对话回合引用。没有文件名的占位项不进入请求。 */
export function turnAttachmentRefs(
attachments: AttachmentLike[] | undefined,
): TurnAttachmentRef[] {
if (!attachments?.length) return [];
const refs: TurnAttachmentRef[] = [];
for (const att of attachments) {
const filename = att.name?.trim() || '';
if (!filename && !att.docId && !(att.assetId && att.assetId > 0)) continue;
refs.push({
filename: filename || '未命名文档',
...(att.docId ? { doc_id: att.docId } : {}),
...(att.assetId && att.assetId > 0 ? { asset_id: att.assetId } : {}),
...(att.parseState ? { parse_state: att.parseState } : {}),
});
}
return refs;
}