fix(chat): 只上传文档时给出梳理或追问,不再裸报 HTTP 400
空消息加附件可以进入回合。无文字时按解析状态引导或回退审阅,有文字但未处理文档时先回答再追问。4xx 信封不再被前端丢弃。
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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() || '任务执行中断,可继续或重新开始';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user