Compare commits

...

3 Commits

Author SHA1 Message Date
6248e2c7b8 fix: 支持模型异常后继续文档对话
- 将本轮文档上下文纳入可持久化的 Agent 用户消息

=- 在异常提示中提供请重试动作并自动发送继续
2026-08-26 22:56:31 +08:00
4de8cc5bd0 feat: 优化工作流运行输入交互
- 合并运行参数与问题输入并锁定首轮参数

- 优化文件上传、参数摘要与十二小时草稿恢复

- 补充输入表单国际化与相关测试
2026-08-26 22:49:02 +08:00
71b3d3d620 fix: 修正工作流试运行参数名称展示
- 默认字段标题回退为实际配置的参数名称

- 保留显式展示标题并统一表单与引用口径
2026-08-26 22:49:01 +08:00
26 changed files with 1844 additions and 271 deletions

View File

@@ -423,7 +423,7 @@ public class AgentRunService {
chatContext.getExt().put(DOCUMENT_CONTEXT_TOKEN_ESTIMATE_EXT_KEY, chatContext.getExt().put(DOCUMENT_CONTEXT_TOKEN_ESTIMATE_EXT_KEY,
documentContext.tokenEstimate()); documentContext.tokenEstimate());
String runtimePrompt = effectivePrompt(prompt, !boundDocuments.isEmpty(), !boundMedia.isEmpty()); String runtimePrompt = effectivePrompt(prompt, !boundDocuments.isEmpty(), !boundMedia.isEmpty());
AgentMessage userMessage = buildAgentMessage(runtimePrompt, boundMedia); AgentMessage userMessage = buildAgentMessage(runtimePrompt, boundMedia, documentContext);
threadPoolTaskExecutor.execute(() -> startRuntime( threadPoolTaskExecutor.execute(() -> startRuntime(
agent, userMessage, documentContext, account, requestId, traceId, runtimeSessionId, agent, userMessage, documentContext, account, requestId, traceId, runtimeSessionId,
assistantCode, chatContext, runOutput, persistChatlog, runtimeSessionStore, lockHandle)); assistantCode, chatContext, runOutput, persistChatlog, runtimeSessionStore, lockHandle));
@@ -646,24 +646,6 @@ public class AgentRunService {
return agentDocumentService.bindDraft(documentUploads); return agentDocumentService.bindDraft(documentUploads);
} }
/**
* 将本轮文档正文追加到临时运行定义的系统提示词中。
*
* <p>正文只存在于本轮模型调用定义,不写入 chatlog 或 AgentScope 消息记忆。</p>
*
* @param bundle 临时运行时编译结果
* @param documentContext 本轮文档上下文
*/
private void appendDocumentContext(AgentRuntimeBundle bundle, AgentDocumentContext documentContext) {
if (bundle == null || bundle.getDefinition() == null
|| documentContext == null || documentContext.text().isBlank()) {
return;
}
String current = bundle.getDefinition().getSystemPrompt();
bundle.getDefinition().setSystemPrompt(
(current == null ? "" : current) + documentContext.text());
}
/** /**
* 为仅附件输入生成可持久化的最小用户意图。 * 为仅附件输入生成可持久化的最小用户意图。
* *
@@ -1206,7 +1188,6 @@ public class AgentRunService {
} }
AgentRuntimeContext runtimeContext = buildAgentRuntimeContext(chatContext, traceId, runtimeSessionId); AgentRuntimeContext runtimeContext = buildAgentRuntimeContext(chatContext, traceId, runtimeSessionId);
AgentRuntimeBundle bundle = agentRuntimeCompiler.compile(agent, runtimeContext, !persistChatlog); AgentRuntimeBundle bundle = agentRuntimeCompiler.compile(agent, runtimeContext, !persistChatlog);
appendDocumentContext(bundle, documentContext);
AgentRuntime runtime = agentRuntimeFactory.create(); AgentRuntime runtime = agentRuntimeFactory.create();
// 会话初始化请求 // 会话初始化请求
AgentInitRequest request = new AgentInitRequest(); AgentInitRequest request = new AgentInitRequest();
@@ -2340,13 +2321,30 @@ public class AgentRunService {
return message; return message;
} }
private AgentMessage buildAgentMessage(String prompt, List<AgentBoundMedia> media) { /**
* 构建发送给 AgentScope 的用户消息。
*
* <p>文档正文属于用户提供的不可信材料,作为用户内容块进入本轮模型调用和 AgentScope
* memory。聊天记录仍单独保存原始输入与附件引用页面不会展示正文内容块。</p>
*
* @param prompt 用户输入
* @param media 图片附件
* @param documentContext 本轮选中的文档上下文
* @return 可持久化的运行时用户消息
*/
private AgentMessage buildAgentMessage(String prompt,
List<AgentBoundMedia> media,
AgentDocumentContext documentContext) {
AgentMessage message = new AgentMessage(); AgentMessage message = new AgentMessage();
message.setRole(AgentMessageRole.USER); message.setRole(AgentMessageRole.USER);
List<com.easyagents.agent.runtime.message.AgentContentBlock> blocks = new ArrayList<>(); List<com.easyagents.agent.runtime.message.AgentContentBlock> blocks = new ArrayList<>();
if (prompt != null && !prompt.isBlank()) { if (prompt != null && !prompt.isBlank()) {
blocks.add(new AgentTextBlock(prompt)); blocks.add(new AgentTextBlock(prompt));
} }
if (documentContext != null && documentContext.text() != null
&& !documentContext.text().isBlank()) {
blocks.add(new AgentTextBlock(documentContext.text()));
}
if (media != null) { if (media != null) {
for (AgentBoundMedia item : media) { for (AgentBoundMedia item : media) {
AgentMediaBlock image = new AgentMediaBlock("image"); AgentMediaBlock image = new AgentMediaBlock("image");

View File

@@ -10,6 +10,7 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.message.AgentKnowledgeReference; import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
import com.easyagents.agent.runtime.message.AgentMessage; import com.easyagents.agent.runtime.message.AgentMessage;
import com.easyagents.agent.runtime.message.AgentMessageRole; import com.easyagents.agent.runtime.message.AgentMessageRole;
import com.easyagents.agent.runtime.message.AgentTextBlock;
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore; import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore; import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
import org.junit.Assert; import org.junit.Assert;
@@ -69,6 +70,28 @@ import java.util.concurrent.atomic.AtomicBoolean;
*/ */
public class AgentRunServiceDraftAndHitlTest { public class AgentRunServiceDraftAndHitlTest {
/**
* 验证文档上下文随用户消息进入可持久化 memory同时保持独立内容块边界。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void buildAgentMessageShouldIncludeDocumentContext() throws Exception {
AgentRunService service = new AgentRunService();
AgentDocumentContext documentContext = new AgentDocumentContext(
"\n<<<DOCUMENT name=\"demo.docx\">>>\n正文\n<<<END_DOCUMENT>>>", 8, List.of());
AgentMessage message = invoke(service, "buildAgentMessage",
new Class<?>[]{String.class, List.class, AgentDocumentContext.class},
"请介绍文档", List.of(), documentContext);
Assert.assertEquals(2, message.getContentBlocks().size());
Assert.assertEquals("请介绍文档",
((AgentTextBlock) message.getContentBlocks().get(0)).getText());
Assert.assertEquals(documentContext.text(),
((AgentTextBlock) message.getContentBlocks().get(1)).getText());
}
/** /**
* 创建用于 owner 恢复测试的运行描述。 * 创建用于 owner 恢复测试的运行描述。
* *

View File

@@ -22,6 +22,7 @@
"visibilityScopePublic": "Public", "visibilityScopePublic": "Public",
"visibilityScopePublicDesc": "Available to internal users matched by category", "visibilityScopePublicDesc": "Available to internal users matched by category",
"params": "Params", "params": "Params",
"runInputForm": "Input form",
"steps": "Steps", "steps": "Steps",
"result": "Result", "result": "Result",
"confirm": "For contents to be confirmed, please confirm first!", "confirm": "For contents to be confirmed, please confirm first!",

View File

@@ -22,6 +22,7 @@
"visibilityScopePublic": "公开", "visibilityScopePublic": "公开",
"visibilityScopePublicDesc": "分类命中的内部用户可访问", "visibilityScopePublicDesc": "分类命中的内部用户可访问",
"params": "执行参数", "params": "执行参数",
"runInputForm": "输入表单",
"steps": "执行步骤", "steps": "执行步骤",
"result": "执行结果", "result": "执行结果",
"confirm": "有待确认的内容,请先确认!", "confirm": "有待确认的内容,请先确认!",

View File

@@ -181,6 +181,57 @@ describe('agentChatRuntimeManager', () => {
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined(); expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
}); });
it('输入已确认后将模型错误转换为可重试的用户提示', async () => {
let resolveRun: (() => void) | undefined;
let runOptions: EasyFlowAguiRunOptions | undefined;
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
runOptions = options;
return new Promise<void>((resolve) => {
resolveRun = resolve;
});
});
useUserStore().setUserInfo({
avatar: '',
id: 'retry-user',
loginName: 'retry-user',
nickname: '重试用户',
tenantId: 'tenant-1',
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '介绍文档',
sessionId: 'retry-session',
});
runOptions?.onEvent({
name: easyFlowAguiCustomEvent.inputAccepted,
type: EventType.CUSTOM,
value: {},
});
runOptions?.onEvent({
code: 'MODEL_ERROR',
message: 'Retries exhausted: 2/2',
runId: 'run-test',
threadId: 'retry-session',
type: EventType.RUN_ERROR,
});
resolveRun?.();
await Promise.resolve();
await Promise.resolve();
const snapshot = agentChatRuntimeManager.getSnapshot('retry-session');
expect(snapshot).toEqual(
expect.objectContaining({
error: '模型连接异常',
retryContextReady: true,
terminalOutcome: 'failed',
}),
);
expect(snapshot?.items.find((item) => item.type === 'error')).toEqual(
expect.objectContaining({ message: '模型连接异常' }),
);
});
it('刷新恢复已确认输入时重新触发草稿清理回调', async () => { it('刷新恢复已确认输入时重新触发草稿清理回调', async () => {
const account = { const account = {
avatar: '', avatar: '',

View File

@@ -45,6 +45,7 @@ interface RuntimeSessionState {
prompt: string; prompt: string;
projectionToolArgs: Record<string, string>; projectionToolArgs: Record<string, string>;
projectionToolNames: Record<string, string>; projectionToolNames: Record<string, string>;
retryContextReady: boolean;
runId?: string; runId?: string;
roundId: string; roundId: string;
sending: boolean; sending: boolean;
@@ -65,6 +66,7 @@ interface StoredRuntimeSession {
prompt: string; prompt: string;
projectionToolArgs?: Record<string, string>; projectionToolArgs?: Record<string, string>;
projectionToolNames?: Record<string, string>; projectionToolNames?: Record<string, string>;
retryContextReady?: boolean;
runId?: string; runId?: string;
roundId: string; roundId: string;
sending: boolean; sending: boolean;
@@ -86,10 +88,12 @@ interface StartOptions {
images?: ChatImageAttachment[]; images?: ChatImageAttachment[];
onInputAccepted?: () => Promise<void> | void; onInputAccepted?: () => Promise<void> | void;
prompt: string; prompt: string;
retryContextReady?: boolean;
sessionId?: string; sessionId?: string;
} }
const STORAGE_VERSION = 5; const STORAGE_VERSION = 6;
const MODEL_CONNECTION_ERROR_MESSAGE = '模型连接异常';
const STREAM_NOTIFY_INTERVAL_MS = 50; const STREAM_NOTIFY_INTERVAL_MS = 50;
const STREAM_PERSIST_INTERVAL_MS = 300; const STREAM_PERSIST_INTERVAL_MS = 300;
const MAX_RUNTIME_SESSIONS_PER_IDENTITY = 10; const MAX_RUNTIME_SESSIONS_PER_IDENTITY = 10;
@@ -177,6 +181,7 @@ function persistSession(state: RuntimeSessionState) {
prompt: state.prompt, prompt: state.prompt,
projectionToolArgs: state.projectionToolArgs, projectionToolArgs: state.projectionToolArgs,
projectionToolNames: state.projectionToolNames, projectionToolNames: state.projectionToolNames,
retryContextReady: state.retryContextReady,
runId: state.runId, runId: state.runId,
roundId: state.roundId, roundId: state.roundId,
sending: state.sending, sending: state.sending,
@@ -298,7 +303,7 @@ function restoreSession(identity: string, sessionId: string) {
} }
const parsed = JSON.parse(raw) as StoredRuntimeSession; const parsed = JSON.parse(raw) as StoredRuntimeSession;
if ( if (
![3, 4, STORAGE_VERSION].includes(parsed.version) || ![3, 4, 5, STORAGE_VERSION].includes(parsed.version) ||
parsed.sessionId !== sessionId parsed.sessionId !== sessionId
) { ) {
return undefined; return undefined;
@@ -324,6 +329,7 @@ function restoreSession(identity: string, sessionId: string) {
typeof parsed.projectionToolNames === 'object' typeof parsed.projectionToolNames === 'object'
? parsed.projectionToolNames ? parsed.projectionToolNames
: {}, : {},
retryContextReady: Boolean(parsed.retryContextReady),
runId: parsed.runId, runId: parsed.runId,
roundId: parsed.roundId, roundId: parsed.roundId,
sending: Boolean(parsed.sending && parsed.runId), sending: Boolean(parsed.sending && parsed.runId),
@@ -386,6 +392,7 @@ function acceptInput(
) { ) {
replaceAcceptedAttachments(state.items, state.roundId, payload); replaceAcceptedAttachments(state.items, state.roundId, payload);
state.inputAccepted = true; state.inputAccepted = true;
state.retryContextReady = true;
persistSession(state); persistSession(state);
notifyInputAccepted(state); notifyInputAccepted(state);
} }
@@ -563,7 +570,9 @@ function finishRuntimeSuccess(state: RuntimeSessionState) {
} }
function finishRuntimeFailure(state: RuntimeSessionState, error: unknown) { function finishRuntimeFailure(state: RuntimeSessionState, error: unknown) {
state.error = errorMessage(error); state.error = state.retryContextReady
? MODEL_CONNECTION_ERROR_MESSAGE
: errorMessage(error);
state.sending = false; state.sending = false;
state.completed = true; state.completed = true;
const last = state.items[state.items.length - 1]; const last = state.items[state.items.length - 1];
@@ -664,7 +673,9 @@ export const agentChatRuntimeManager = {
event.type === EventType.RUN_ERROR && event.type === EventType.RUN_ERROR &&
event.code !== 'RUN_CANCELLED' event.code !== 'RUN_CANCELLED'
) { ) {
current.error = event.message || '发送失败,请稍后再试'; current.error = current.retryContextReady
? MODEL_CONNECTION_ERROR_MESSAGE
: event.message || '发送失败,请稍后再试';
} }
observeTerminalEvent(current, event.type); observeTerminalEvent(current, event.type);
applyAguiEventToTimeline( applyAguiEventToTimeline(
@@ -674,6 +685,9 @@ export const agentChatRuntimeManager = {
onInputAccepted(payload) { onInputAccepted(payload) {
acceptInput(current, payload); acceptInput(current, payload);
}, },
runErrorMessage: current.retryContextReady
? MODEL_CONNECTION_ERROR_MESSAGE
: undefined,
roundId: current.roundId, roundId: current.roundId,
startedAt: current.startedAt, startedAt: current.startedAt,
}, },
@@ -757,6 +771,7 @@ export const agentChatRuntimeManager = {
prompt: options.prompt, prompt: options.prompt,
projectionToolArgs: {}, projectionToolArgs: {},
projectionToolNames: {}, projectionToolNames: {},
retryContextReady: Boolean(options.retryContextReady),
runId, runId,
roundId, roundId,
sending: true, sending: true,
@@ -794,7 +809,9 @@ export const agentChatRuntimeManager = {
event.type === EventType.RUN_ERROR && event.type === EventType.RUN_ERROR &&
event.code !== 'RUN_CANCELLED' event.code !== 'RUN_CANCELLED'
) { ) {
current.error = event.message || '发送失败,请稍后再试'; current.error = current.retryContextReady
? MODEL_CONNECTION_ERROR_MESSAGE
: event.message || '发送失败,请稍后再试';
} }
observeTerminalEvent(current, event.type); observeTerminalEvent(current, event.type);
applyAguiEventToTimeline( applyAguiEventToTimeline(
@@ -804,6 +821,9 @@ export const agentChatRuntimeManager = {
onInputAccepted(payload) { onInputAccepted(payload) {
acceptInput(current, payload); acceptInput(current, payload);
}, },
runErrorMessage: current.retryContextReady
? MODEL_CONNECTION_ERROR_MESSAGE
: undefined,
roundId, roundId,
startedAt, startedAt,
}, },

View File

@@ -2,6 +2,7 @@
import type { import type {
ChatDocumentAttachment, ChatDocumentAttachment,
ChatImageAttachment, ChatImageAttachment,
ChatTimelineErrorItem,
ChatTimelineItem, ChatTimelineItem,
ChatTimelineMessageItem, ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload, ChatTimelineToolApprovalPayload,
@@ -108,6 +109,7 @@ const loadingKnowledges = ref(false);
const savingExtraKnowledges = ref(false); const savingExtraKnowledges = ref(false);
const sending = ref(false); const sending = ref(false);
const runtimeRunning = ref(false); const runtimeRunning = ref(false);
const retryableErrorRoundId = ref('');
const approvalLoadingKey = ref(''); const approvalLoadingKey = ref('');
const knowledgeOptions = ref<{ label: string; value: string }[]>([]); const knowledgeOptions = ref<{ label: string; value: string }[]>([]);
const knowledgeMap = ref(new Map<string, { id: string; title: string }>()); const knowledgeMap = ref(new Map<string, { id: string; title: string }>());
@@ -463,12 +465,19 @@ function syncRuntimeSnapshot(sessionId = currentSessionId.value) {
: undefined; : undefined;
if (!snapshot) { if (!snapshot) {
sending.value = false; sending.value = false;
retryableErrorRoundId.value = '';
return false; return false;
} }
currentSessionId.value = snapshot.sessionId; currentSessionId.value = snapshot.sessionId;
selectedAgentId.value = String(snapshot.agentId); selectedAgentId.value = String(snapshot.agentId);
timelineItems.value = snapshot.items; timelineItems.value = snapshot.items;
sending.value = snapshot.sending; sending.value = snapshot.sending;
retryableErrorRoundId.value =
snapshot.terminalOutcome === 'failed' &&
snapshot.retryContextReady &&
!snapshot.sending
? snapshot.roundId
: '';
maybeRefreshCompletedRuntimeSession(snapshot); maybeRefreshCompletedRuntimeSession(snapshot);
if (snapshot.prompt && !currentSession.value) { if (snapshot.prompt && !currentSession.value) {
upsertSessionRecord( upsertSessionRecord(
@@ -492,15 +501,20 @@ async function loadConversation(sessionId: string) {
if (!sessionId) { if (!sessionId) {
timelineItems.value = []; timelineItems.value = [];
sending.value = false; sending.value = false;
retryableErrorRoundId.value = '';
return; return;
} }
const runtimeSnapshot = agentChatRuntimeManager.getSnapshot(sessionId); const runtimeSnapshot = agentChatRuntimeManager.getSnapshot(sessionId);
if (runtimeSnapshot?.sending) { if (
runtimeSnapshot?.sending ||
runtimeSnapshot?.terminalOutcome === 'failed'
) {
syncRuntimeSnapshot(sessionId); syncRuntimeSnapshot(sessionId);
await syncSessionRoute(sessionId); await syncSessionRoute(sessionId);
return; return;
} }
loadingConversation.value = true; loadingConversation.value = true;
retryableErrorRoundId.value = '';
try { try {
const detailRes = await getAgentSession(sessionId); const detailRes = await getAgentSession(sessionId);
const res = await getAgentConversation(sessionId); const res = await getAgentConversation(sessionId);
@@ -660,12 +674,25 @@ function buildCapabilities() {
]; ];
} }
async function sendContent(rawContent: string) { interface SendContentOptions {
includeComposer?: boolean;
retryContextReady?: boolean;
}
async function sendContent(
rawContent: string,
options: SendContentOptions = {},
) {
const content = rawContent.trim(); const content = rawContent.trim();
const includeComposer = options.includeComposer !== false;
const readyImageCount = includeComposer
? composer.images.readyItems.value.length
: 0;
const readyDocumentCount = includeComposer
? composer.documents.readyItems.value.length
: 0;
if ( if (
(!content && (!content && readyImageCount === 0 && readyDocumentCount === 0) ||
composer.images.readyItems.value.length === 0 &&
composer.documents.readyItems.value.length === 0) ||
!selectedAgentId.value || !selectedAgentId.value ||
sending.value sending.value
) { ) {
@@ -675,44 +702,54 @@ async function sendContent(rawContent: string) {
ElMessage.warning('当前回复完成后再发送新消息'); ElMessage.warning('当前回复完成后再发送新消息');
return; return;
} }
if (composer.images.uploading.value) { if (includeComposer && composer.images.uploading.value) {
ElMessage.warning('图片上传完成后再发送'); ElMessage.warning('图片上传完成后再发送');
return; return;
} }
if (composer.documents.processing.value) { if (includeComposer && composer.documents.processing.value) {
ElMessage.warning('文档读取完成后再发送'); ElMessage.warning('文档读取完成后再发送');
return; return;
} }
const failedImage = composer.images.items.value.find( const failedImage = includeComposer
(item) => item.status === 'error', ? composer.images.items.value.find((item) => item.status === 'error')
); : undefined;
if (failedImage) { if (failedImage) {
ElMessage.error(failedImage.error || '请处理上传失败的图片'); ElMessage.error(failedImage.error || '请处理上传失败的图片');
return; return;
} }
const failedDocument = composer.documents.items.value.find( const failedDocument = includeComposer
(item) => item.status === 'error', ? composer.documents.items.value.find((item) => item.status === 'error')
); : undefined;
if (failedDocument) { if (failedDocument) {
ElMessage.error(failedDocument.error || '请处理读取失败的文档'); ElMessage.error(failedDocument.error || '请处理读取失败的文档');
return; return;
} }
if (includeComposer) {
await composer.flush(); await composer.flush();
}
sending.value = true; sending.value = true;
retryableErrorRoundId.value = '';
try { try {
const sessionId = await agentChatRuntimeManager.start({ const sessionId = await agentChatRuntimeManager.start({
agentId: selectedAgentId.value, agentId: selectedAgentId.value,
agentName: selectedAgent.value?.name, agentName: selectedAgent.value?.name,
baseItems: timelineItems.value, baseItems: timelineItems.value,
capabilities: buildCapabilities(), capabilities: buildCapabilities(),
documentUploadIds: composer.documents.uploadIds.value, documentUploadIds: includeComposer
documents: composer.documents.readyItems.value.map((item) => ({ ? composer.documents.uploadIds.value
...item, : undefined,
})), documents: includeComposer
imageUploadIds: composer.images.uploadIds.value, ? composer.documents.readyItems.value.map((item) => ({ ...item }))
images: composer.images.readyItems.value.map((item) => ({ ...item })), : undefined,
onInputAccepted: markComposerInputAccepted, imageUploadIds: includeComposer
? composer.images.uploadIds.value
: undefined,
images: includeComposer
? composer.images.readyItems.value.map((item) => ({ ...item }))
: undefined,
onInputAccepted: includeComposer ? markComposerInputAccepted : undefined,
prompt: content, prompt: content,
retryContextReady: Boolean(options.retryContextReady),
sessionId: composer.sessionId.value, sessionId: composer.sessionId.value,
}); });
await bindCreatedSession(sessionId, content); await bindCreatedSession(sessionId, content);
@@ -725,6 +762,24 @@ async function sendContent(rawContent: string) {
} }
} }
function modelErrorAction(item: ChatTimelineErrorItem) {
return item.roundId === retryableErrorRoundId.value ? '请重试' : undefined;
}
async function handleModelErrorRetry(item: ChatTimelineErrorItem) {
if (
item.roundId !== retryableErrorRoundId.value ||
sending.value ||
runtimeRunning.value
) {
return;
}
await sendContent('继续', {
includeComposer: false,
retryContextReady: true,
});
}
async function handleSend() { async function handleSend() {
await sendContent(promptText.value); await sendContent(promptText.value);
} }
@@ -1266,12 +1321,15 @@ onBeforeUnmount(() => {
:artifact-loader="loadCurrentAgentArtifact" :artifact-loader="loadCurrentAgentArtifact"
:items="timelineItems" :items="timelineItems"
:document-loader="loadAgentChatDocument" :document-loader="loadAgentChatDocument"
:error-action="modelErrorAction"
:error-action-disabled="sending || runtimeRunning"
:image-loader="loadAgentChatImage" :image-loader="loadAgentChatImage"
empty-text="选择智能体后开始对话" empty-text="选择智能体后开始对话"
:approval-loading="Boolean(approvalLoadingKey)" :approval-loading="Boolean(approvalLoadingKey)"
:copy-action="handleCopyMessage" :copy-action="handleCopyMessage"
:copyable="canCopyMessage" :copyable="canCopyMessage"
@approve="handleApprove" @approve="handleApprove"
@error-action="handleModelErrorRetry"
@reject="handleReject" @reject="handleReject"
@select-next-variant="() => undefined" @select-next-variant="() => undefined"
@select-previous-variant="() => undefined" @select-previous-variant="() => undefined"

View File

@@ -14,6 +14,10 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
disabled: {
type: Boolean,
default: false,
},
multiple: { multiple: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -37,13 +41,20 @@ const pageUrl = computed(() => {
: `${baseUrl}?resourceType=${props.resourceType}`; : `${baseUrl}?resourceType=${props.resourceType}`;
}); });
function openDialog() { function openDialog() {
if (props.disabled) {
return;
}
dialogVisible.value = true; dialogVisible.value = true;
} }
function closeDialog() { function closeDialog() {
dialogVisible.value = false; dialogVisible.value = false;
} }
function confirm() { function confirm() {
emit('choose', props.multiple ? chooseResources.value : currentChoose.value, props.attrName); emit(
'choose',
props.multiple ? chooseResources.value : currentChoose.value,
props.attrName,
);
closeDialog(); closeDialog();
} }
watch( watch(
@@ -85,7 +96,7 @@ watch(
</ElButton> </ElButton>
</template> </template>
</EasyFlowPanelModal> </EasyFlowPanelModal>
<ElButton @click="openDialog()"> <ElButton :disabled="disabled" @click="openDialog()">
{{ $t('button.choose') }} {{ $t('button.choose') }}
</ElButton> </ElButton>
</div> </div>

View File

@@ -18,6 +18,7 @@ import { easyFlowAguiCustomEvent } from './custom-events';
export interface AguiTimelineProjectionOptions { export interface AguiTimelineProjectionOptions {
finishedAt?: number; finishedAt?: number;
onInputAccepted?: (payload: Record<string, unknown>) => Promise<void> | void; onInputAccepted?: (payload: Record<string, unknown>) => Promise<void> | void;
runErrorMessage?: string;
roundId?: string; roundId?: string;
startedAt?: number; startedAt?: number;
} }
@@ -361,7 +362,7 @@ export function applyAguiEventToTimeline(
} }
ChatTimelineBuilder.appendError( ChatTimelineBuilder.appendError(
items, items,
event.message || '请求失败', options.runErrorMessage || event.message || '请求失败',
metadata(options, state), metadata(options, state),
); );
ChatTimelineBuilder.finalize(items, { ChatTimelineBuilder.finalize(items, {

View File

@@ -12,6 +12,8 @@ import { $t } from '#/locales';
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue'; import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
import { buildSingleRunModel } from '../../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields'; import { buildSingleRunModel } from '../../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
import { resolveWorkflowParameterDisplayName } from './workflowFormParameters';
interface Props { interface Props {
workflowId: any; workflowId: any;
node: any; node: any;
@@ -31,7 +33,7 @@ const parameterDisplayNameMap = computed(() => {
return new Map( return new Map(
singleRunParameters.value.map((parameter: any) => [ singleRunParameters.value.map((parameter: any) => [
String(parameter.name || ''), String(parameter.name || ''),
String(parameter.displayName || parameter.formLabel || parameter.name || ''), resolveWorkflowParameterDisplayName(parameter),
]), ]),
); );
}); });

View File

@@ -1,15 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { ElButton, ElLink, ElMessage } from 'element-plus'; import {
CircleCheck,
Delete,
Document,
UploadFilled,
} from '@element-plus/icons-vue';
import { ElButton, ElIcon, ElLink, ElMessage } from 'element-plus';
import { api } from '#/api/request'; import { api } from '#/api/request';
import { $t } from '#/locales'; import { $t } from '#/locales';
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
import { import {
appendWorkflowFileValues, appendWorkflowFileValues,
buildWorkflowFileValueFromResource,
buildWorkflowFileValueFromUpload, buildWorkflowFileValueFromUpload,
formatWorkflowFileSize, formatWorkflowFileSize,
normalizeWorkflowFileValues, normalizeWorkflowFileValues,
@@ -19,6 +23,10 @@ import {
} from './workflowFileValue'; } from './workflowFileValue';
const props = defineProps({ const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
modelValue: { modelValue: {
type: [Array, Object], type: [Array, Object],
default: undefined, default: undefined,
@@ -28,21 +36,25 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
const uploadLoading = ref(false); const uploadLoading = ref(false);
const dragActive = ref(false);
const fileInputRef = ref<HTMLInputElement | null>(null); const fileInputRef = ref<HTMLInputElement | null>(null);
const currentFiles = computed(() => normalizeWorkflowFileValues(props.modelValue)); const currentFiles = computed(() =>
normalizeWorkflowFileValues(props.modelValue),
);
const maxSingleFileSizeText = formatWorkflowFileSize(
WORKFLOW_FILE_LIMITS.maxSingleSize,
).replace('.0 ', ' ');
function triggerSelectFile() { function triggerSelectFile() {
if (uploadLoading.value) { if (props.disabled || uploadLoading.value) {
return; return;
} }
fileInputRef.value?.click(); fileInputRef.value?.click();
} }
async function handleNativeFileChange(event: Event) { async function uploadFiles(files: File[]) {
const input = event.target as HTMLInputElement; if (props.disabled || files.length === 0) {
const files = Array.from(input.files || []);
if (files.length === 0) {
return; return;
} }
@@ -52,9 +64,17 @@ async function handleNativeFileChange(event: Event) {
const uploadedFiles = []; const uploadedFiles = [];
for (const file of files) { for (const file of files) {
const res = await api.upload('/api/v1/commons/upload', { file }, {}); const res = await api.upload('/api/v1/commons/upload', { file }, {});
uploadedFiles.push(buildWorkflowFileValueFromUpload(file, res?.data?.path)); uploadedFiles.push(
buildWorkflowFileValueFromUpload(file, res?.data?.path),
);
} }
const nextFiles = appendWorkflowFileValues(currentFiles.value, uploadedFiles); if (props.disabled) {
return;
}
const nextFiles = appendWorkflowFileValues(
currentFiles.value,
uploadedFiles,
);
validateWorkflowFileValues(nextFiles); validateWorkflowFileValues(nextFiles);
emit('update:modelValue', nextFiles); emit('update:modelValue', nextFiles);
} catch (error: any) { } catch (error: any) {
@@ -62,31 +82,37 @@ async function handleNativeFileChange(event: Event) {
console.error('工作流文件上传失败', error); console.error('工作流文件上传失败', error);
} finally { } finally {
uploadLoading.value = false; uploadLoading.value = false;
input.value = '';
} }
} }
function handleChooseResource(resources: any) { async function handleNativeFileChange(event: Event) {
try { const input = event.target as HTMLInputElement;
const resourceList = Array.isArray(resources) ? resources : [resources]; await uploadFiles([...(input.files || [])]);
const fileValues = resourceList input.value = '';
.map((resource) => buildWorkflowFileValueFromResource(resource || {}))
.filter(Boolean);
const nextFiles = appendWorkflowFileValues(currentFiles.value, fileValues);
validateWorkflowFileValues(nextFiles);
emit('update:modelValue', nextFiles);
} catch (error: any) {
ElMessage.error(error?.message || '素材文件选择失败');
} }
function setDragActive(active: boolean) {
if (!props.disabled) {
dragActive.value = active;
}
}
async function handleDrop(event: DragEvent) {
dragActive.value = false;
if (props.disabled || uploadLoading.value) {
return;
}
await uploadFiles([...(event.dataTransfer?.files || [])]);
} }
function removeFile(filePath: string) { function removeFile(filePath: string) {
const nextFiles = currentFiles.value.filter((item) => item.filePath !== filePath); if (props.disabled) {
emit('update:modelValue', nextFiles); return;
} }
const nextFiles = currentFiles.value.filter(
function clearFiles() { (item) => item.filePath !== filePath,
emit('update:modelValue', []); );
emit('update:modelValue', nextFiles);
} }
</script> </script>
@@ -96,14 +122,42 @@ function clearFiles() {
ref="fileInputRef" ref="fileInputRef"
class="workflow-file-input__native" class="workflow-file-input__native"
type="file" type="file"
:disabled="disabled"
multiple multiple
@change="handleNativeFileChange" @change="handleNativeFileChange"
/> />
<div class="workflow-file-input__hint"> <div
最多 {{ WORKFLOW_FILE_LIMITS.maxCount }} 个文件单个不超过 v-if="currentFiles.length === 0"
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize) }}总计不超过 class="workflow-file-input__dropzone"
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize) }} :class="{ 'is-disabled': disabled, 'is-dragging': dragActive }"
@dragenter.prevent="setDragActive(true)"
@dragover.prevent="setDragActive(true)"
@dragleave.prevent="setDragActive(false)"
@drop.prevent="handleDrop"
>
<button
class="workflow-file-input__upload-trigger"
type="button"
:disabled="disabled || uploadLoading"
@click="triggerSelectFile"
>
<ElIcon class="workflow-file-input__upload-icon">
<UploadFilled />
</ElIcon>
<span class="workflow-file-input__dropzone-copy">
<span>
{{
disabled
? '未上传文件'
: uploadLoading
? '正在上传…'
: '拖入文件或点击上传'
}}
</span>
<small>单个文件不超过 {{ maxSingleFileSizeText }}</small>
</span>
</button>
</div> </div>
<div v-if="currentFiles.length > 0" class="workflow-file-input__list"> <div v-if="currentFiles.length > 0" class="workflow-file-input__list">
@@ -112,12 +166,21 @@ function clearFiles() {
:key="item.filePath" :key="item.filePath"
class="workflow-file-input__summary" class="workflow-file-input__summary"
> >
<ElIcon class="workflow-file-input__file-icon">
<Document />
</ElIcon>
<div class="workflow-file-input__content"> <div class="workflow-file-input__content">
<div class="workflow-file-input__name"> <div class="workflow-file-input__name">
{{ item.fileName }} {{ item.fileName }}
</div> </div>
<div class="workflow-file-input__meta"> <div class="workflow-file-input__meta">
<span>{{ formatWorkflowFileSize(item.size) }}</span> <span>{{ formatWorkflowFileSize(item.size) }}</span>
<span class="workflow-file-input__ready">
<ElIcon><CircleCheck /></ElIcon>
已上传
</span>
</div>
</div>
<ElLink <ElLink
v-if="item.url || item.filePath" v-if="item.url || item.filePath"
:href="item.url || item.filePath" :href="item.url || item.filePath"
@@ -126,32 +189,15 @@ function clearFiles() {
> >
{{ $t('button.view') }} {{ $t('button.view') }}
</ElLink> </ElLink>
</div>
</div>
<ElButton text type="danger" @click="removeFile(item.filePath)">
{{ $t('button.delete') }}
</ElButton>
</div>
</div>
<div class="workflow-file-input__actions">
<ElButton <ElButton
type="primary" v-if="!disabled"
plain :icon="Delete"
:loading="uploadLoading"
@click="triggerSelectFile"
>
{{ currentFiles.length > 0 ? '继续上传' : $t('button.upload') }}
</ElButton>
<ChooseResource attr-name="file" multiple @choose="handleChooseResource" />
<ElButton
v-if="currentFiles.length > 0"
text text
type="danger" circle
@click="clearFiles" aria-label="删除文件"
> @click="removeFile(item.filePath)"
清空 />
</ElButton> </div>
</div> </div>
</div> </div>
</template> </template>
@@ -161,16 +207,83 @@ function clearFiles() {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
width: 100%;
} }
.workflow-file-input__native { .workflow-file-input__native {
display: none; display: none;
} }
.workflow-file-input__hint { .workflow-file-input__dropzone {
font-size: 12px; display: flex;
align-items: center;
width: 100%;
min-height: 56px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
line-height: 1.5; background: hsl(var(--surface-subtle));
border: 1px dashed var(--el-border-color);
border-radius: var(--radius-control);
transition:
color var(--motion-duration-base) var(--motion-ease-standard),
background-color var(--motion-duration-base) var(--motion-ease-standard),
border-color var(--motion-duration-base) var(--motion-ease-standard);
}
.workflow-file-input__dropzone:hover,
.workflow-file-input__dropzone.is-dragging {
color: hsl(var(--primary));
background: hsl(var(--primary) / 6%);
border-color: hsl(var(--primary) / 48%);
}
.workflow-file-input__dropzone.is-disabled,
.workflow-file-input__dropzone.is-disabled:hover {
color: var(--el-text-color-placeholder);
background: var(--el-fill-color-light);
border-color: var(--el-border-color-lighter);
}
.workflow-file-input__upload-trigger {
display: inline-flex;
flex: 1;
gap: var(--space-2);
align-items: center;
align-self: stretch;
min-width: 0;
padding: var(--space-2) var(--space-3);
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
}
.workflow-file-input__upload-trigger:focus-visible {
outline: none;
box-shadow: 0 0 0 3px hsl(var(--primary) / 12%);
}
.workflow-file-input__upload-trigger:disabled {
cursor: default;
opacity: 0.72;
}
.workflow-file-input__upload-icon {
font-size: 18px;
}
.workflow-file-input__dropzone-copy {
display: flex;
flex-flow: row wrap;
gap: var(--space-1) var(--space-2);
align-items: center;
line-height: 1.4;
}
.workflow-file-input__dropzone-copy small {
font-size: 11px;
color: var(--el-text-color-placeholder);
} }
.workflow-file-input__list { .workflow-file-input__list {
@@ -181,39 +294,46 @@ function clearFiles() {
.workflow-file-input__summary { .workflow-file-input__summary {
display: flex; display: flex;
gap: 12px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; padding: var(--space-3);
padding: 12px;
border: 1px solid var(--el-border-color-light);
border-radius: 10px;
background: var(--el-fill-color-blank); background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-light);
border-radius: var(--radius-control);
}
.workflow-file-input__file-icon {
flex: 0 0 auto;
font-size: 18px;
color: hsl(var(--primary));
} }
.workflow-file-input__content { .workflow-file-input__content {
min-width: 0;
flex: 1; flex: 1;
min-width: 0;
} }
.workflow-file-input__name { .workflow-file-input__name {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
word-break: break-word; overflow-wrap: anywhere;
} }
.workflow-file-input__meta { .workflow-file-input__meta {
display: flex; display: flex;
align-items: center;
gap: 10px; gap: 10px;
align-items: center;
margin-top: 6px; margin-top: 6px;
font-size: 12px; font-size: 12px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
} }
.workflow-file-input__actions { .workflow-file-input__ready {
display: flex; display: inline-flex;
flex-wrap: wrap; gap: var(--space-1);
gap: 8px; align-items: center;
color: var(--el-color-success);
} }
</style> </style>

View File

@@ -13,9 +13,14 @@ import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue'; import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue';
import WorkflowImageInput from '#/views/ai/workflow/components/WorkflowImageInput.vue'; import WorkflowImageInput from '#/views/ai/workflow/components/WorkflowImageInput.vue';
import { resolveWorkflowParameterLabel } from './workflowFormParameters';
import { hasWorkflowImageValue } from './workflowImageValue'; import { hasWorkflowImageValue } from './workflowImageValue';
const props = defineProps({ const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
parameters: { parameters: {
type: Array<any>, type: Array<any>,
required: true, required: true,
@@ -45,6 +50,10 @@ function isResource(contentType: any) {
function isFileContentType(contentType: any) { function isFileContentType(contentType: any) {
return contentType === 'file'; return contentType === 'file';
} }
function isWideItem(item: any) {
const contentType = getContentType(item);
return item.formType === 'textarea' || contentType === 'image';
}
function getCheckboxOptions(item: any) { function getCheckboxOptions(item: any) {
if (item.enums) { if (item.enums) {
return ( return (
@@ -73,7 +82,9 @@ function buildRules(item: any) {
return; return;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
callback(value.length > 0 ? undefined : new Error($t('message.required'))); callback(
value.length > 0 ? undefined : new Error($t('message.required')),
);
return; return;
} }
if (value && typeof value === 'object') { if (value && typeof value === 'object') {
@@ -90,10 +101,16 @@ function buildRules(item: any) {
]; ];
} }
function updateParam(name: string, value: any) { function updateParam(name: string, value: any) {
if (props.disabled) {
return;
}
const newValue = { ...props.runParams, [name]: value }; const newValue = { ...props.runParams, [name]: value };
emit('update:runParams', newValue); emit('update:runParams', newValue);
} }
function choose(data: any, propName: string) { function choose(data: any, propName: string) {
if (props.disabled) {
return;
}
updateParam(propName, data.resourceUrl); updateParam(propName, data.resourceUrl);
} }
</script> </script>
@@ -101,20 +118,24 @@ function choose(data: any, propName: string) {
<template> <template>
<ElFormItem <ElFormItem
v-for="(item, idx) in parameters" v-for="(item, idx) in parameters"
class="workflow-form-item"
:class="{ 'is-wide': isWideItem(item) }"
:prop="`${propPrefix}${item.name}`" :prop="`${propPrefix}${item.name}`"
:key="idx" :key="idx"
:label="item.formLabel || item.name" :label="resolveWorkflowParameterLabel(item)"
:rules="buildRules(item)" :rules="buildRules(item)"
> >
<template v-if="getContentType(item) === 'text'"> <template v-if="getContentType(item) === 'text'">
<ElInput <ElInput
v-if="item.formType === 'input' || !item.formType" v-if="item.formType === 'input' || !item.formType"
:disabled="disabled"
:model-value="runParams[item.name]" :model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)" @update:model-value="(val) => updateParam(item.name, val)"
:placeholder="item.formPlaceholder" :placeholder="item.formPlaceholder"
/> />
<ElSelect <ElSelect
v-if="item.formType === 'select'" v-if="item.formType === 'select'"
:disabled="disabled"
:model-value="runParams[item.name]" :model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)" @update:model-value="(val) => updateParam(item.name, val)"
:placeholder="item.formPlaceholder" :placeholder="item.formPlaceholder"
@@ -123,6 +144,7 @@ function choose(data: any, propName: string) {
/> />
<ElInput <ElInput
v-if="item.formType === 'textarea'" v-if="item.formType === 'textarea'"
:disabled="disabled"
:model-value="runParams[item.name]" :model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)" @update:model-value="(val) => updateParam(item.name, val)"
:placeholder="item.formPlaceholder" :placeholder="item.formPlaceholder"
@@ -131,12 +153,14 @@ function choose(data: any, propName: string) {
/> />
<ElRadioGroup <ElRadioGroup
v-if="item.formType === 'radio'" v-if="item.formType === 'radio'"
:disabled="disabled"
:model-value="runParams[item.name]" :model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)" @update:model-value="(val) => updateParam(item.name, val)"
:options="getCheckboxOptions(item)" :options="getCheckboxOptions(item)"
/> />
<ElCheckboxGroup <ElCheckboxGroup
v-if="item.formType === 'checkbox'" v-if="item.formType === 'checkbox'"
:disabled="disabled"
:model-value="runParams[item.name]" :model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)" @update:model-value="(val) => updateParam(item.name, val)"
:options="getCheckboxOptions(item)" :options="getCheckboxOptions(item)"
@@ -144,6 +168,7 @@ function choose(data: any, propName: string) {
</template> </template>
<template v-if="getContentType(item) === 'other'"> <template v-if="getContentType(item) === 'other'">
<ElInput <ElInput
:disabled="disabled"
:model-value="runParams[item.name]" :model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)" @update:model-value="(val) => updateParam(item.name, val)"
:placeholder="item.formPlaceholder" :placeholder="item.formPlaceholder"
@@ -151,23 +176,30 @@ function choose(data: any, propName: string) {
</template> </template>
<template v-if="isFileContentType(getContentType(item))"> <template v-if="isFileContentType(getContentType(item))">
<WorkflowFileInput <WorkflowFileInput
:disabled="disabled"
:model-value="runParams[item.name]" :model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)" @update:model-value="(val) => updateParam(item.name, val)"
/> />
</template> </template>
<template v-if="getContentType(item) === 'image'"> <template v-if="getContentType(item) === 'image'">
<WorkflowImageInput <WorkflowImageInput
:disabled="disabled"
:model-value="runParams[item.name]" :model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)" @update:model-value="(val) => updateParam(item.name, val)"
/> />
</template> </template>
<template v-if="isResource(getContentType(item))"> <template v-if="isResource(getContentType(item))">
<ElInput <ElInput
:disabled="disabled"
:model-value="runParams[item.name]" :model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)" @update:model-value="(val) => updateParam(item.name, val)"
:placeholder="item.formPlaceholder" :placeholder="item.formPlaceholder"
/> />
<ChooseResource :attr-name="item.name" @choose="choose" /> <ChooseResource
:attr-name="item.name"
:disabled="disabled"
@choose="choose"
/>
</template> </template>
<ElAlert v-if="item.formDescription" type="info" style="margin-top: 5px"> <ElAlert v-if="item.formDescription" type="info" style="margin-top: 5px">
{{ item.formDescription }} {{ item.formDescription }}

View File

@@ -19,6 +19,10 @@ import {
} from './workflowImageValue'; } from './workflowImageValue';
const props = defineProps({ const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
modelValue: { modelValue: {
type: [String, Object], type: [String, Object],
default: undefined, default: undefined,
@@ -33,9 +37,7 @@ const urlInput = ref('');
const currentImage = computed(() => const currentImage = computed(() =>
normalizeWorkflowImageValue(props.modelValue), normalizeWorkflowImageValue(props.modelValue),
); );
const previewUrl = computed(() => const previewUrl = computed(() => getWorkflowImagePreviewUrl(props.modelValue));
getWorkflowImagePreviewUrl(props.modelValue),
);
watch( watch(
() => props.modelValue, () => props.modelValue,
@@ -47,6 +49,9 @@ watch(
); );
function applyUrl() { function applyUrl() {
if (props.disabled) {
return;
}
try { try {
emit('update:modelValue', buildWorkflowImageValueFromUrl(urlInput.value)); emit('update:modelValue', buildWorkflowImageValueFromUrl(urlInput.value));
} catch (error: any) { } catch (error: any) {
@@ -55,7 +60,7 @@ function applyUrl() {
} }
function triggerSelectFile() { function triggerSelectFile() {
if (!uploadLoading.value) { if (!props.disabled && !uploadLoading.value) {
fileInputRef.value?.click(); fileInputRef.value?.click();
} }
} }
@@ -63,13 +68,16 @@ function triggerSelectFile() {
async function handleNativeFileChange(event: Event) { async function handleNativeFileChange(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
if (!file) { if (props.disabled || !file) {
return; return;
} }
uploadLoading.value = true; uploadLoading.value = true;
try { try {
validateWorkflowImageFile(file); validateWorkflowImageFile(file);
const response = await api.upload('/api/v1/commons/upload', { file }, {}); const response = await api.upload('/api/v1/commons/upload', { file }, {});
if (props.disabled) {
return;
}
emit( emit(
'update:modelValue', 'update:modelValue',
buildWorkflowImageValueFromUpload(file, response?.data?.path), buildWorkflowImageValueFromUpload(file, response?.data?.path),
@@ -84,6 +92,9 @@ async function handleNativeFileChange(event: Event) {
} }
function handleChooseResource(resource: any) { function handleChooseResource(resource: any) {
if (props.disabled) {
return;
}
try { try {
emit( emit(
'update:modelValue', 'update:modelValue',
@@ -95,6 +106,9 @@ function handleChooseResource(resource: any) {
} }
function clearImage() { function clearImage() {
if (props.disabled) {
return;
}
urlInput.value = ''; urlInput.value = '';
emit('update:modelValue', undefined); emit('update:modelValue', undefined);
} }
@@ -107,6 +121,7 @@ function clearImage() {
class="workflow-image-input__native" class="workflow-image-input__native"
type="file" type="file"
:accept="WORKFLOW_IMAGE_LIMITS.accept" :accept="WORKFLOW_IMAGE_LIMITS.accept"
:disabled="disabled"
@change="handleNativeFileChange" @change="handleNativeFileChange"
/> />
@@ -143,11 +158,12 @@ function clearImage() {
<ElInput <ElInput
v-model="urlInput" v-model="urlInput"
clearable clearable
:disabled="disabled"
placeholder="输入 HTTP/HTTPS 图片 URL" placeholder="输入 HTTP/HTTPS 图片 URL"
@keyup.enter="applyUrl" @keyup.enter="applyUrl"
> >
<template #append> <template #append>
<ElButton @click="applyUrl">使用 URL</ElButton> <ElButton :disabled="disabled" @click="applyUrl">使用 URL</ElButton>
</template> </template>
</ElInput> </ElInput>
@@ -155,6 +171,7 @@ function clearImage() {
<ElButton <ElButton
type="primary" type="primary"
plain plain
:disabled="disabled"
:loading="uploadLoading" :loading="uploadLoading"
@click="triggerSelectFile" @click="triggerSelectFile"
> >
@@ -162,10 +179,16 @@ function clearImage() {
</ElButton> </ElButton>
<ChooseResource <ChooseResource
attr-name="image" attr-name="image"
:disabled="disabled"
:resource-type="0" :resource-type="0"
@choose="handleChooseResource" @choose="handleChooseResource"
/> />
<ElButton v-if="currentImage" text type="danger" @click="clearImage"> <ElButton
v-if="currentImage && !disabled"
text
type="danger"
@click="clearImage"
>
清空 清空
</ElButton> </ElButton>
</div> </div>

View File

@@ -0,0 +1,58 @@
import { mount } from '@vue/test-utils';
import { defineComponent, nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest';
import WorkflowFileInput from '../WorkflowFileInput.vue';
describe('workflow file input', () => {
it('shows the upload area again after the uploaded file is deleted', async () => {
const Host = defineComponent({
components: { WorkflowFileInput },
setup() {
const value = ref([
{
fileName: '需求说明.pdf',
filePath: '/files/requirements.pdf',
size: 1024,
},
]);
return { value };
},
template: '<WorkflowFileInput v-model="value" />',
});
const wrapper = mount(Host);
expect(wrapper.find('.workflow-file-input__dropzone').exists()).toBe(false);
expect(wrapper.find('.workflow-file-input__summary').exists()).toBe(true);
await wrapper.get('button[aria-label="删除文件"]').trigger('click');
await nextTick();
expect(wrapper.find('.workflow-file-input__summary').exists()).toBe(false);
expect(wrapper.find('.workflow-file-input__dropzone').exists()).toBe(true);
});
it('hides the delete action and disables upload when parameters are locked', async () => {
const wrapper = mount(WorkflowFileInput, {
props: {
disabled: true,
modelValue: [
{
fileName: '需求说明.pdf',
filePath: '/files/requirements.pdf',
size: 1024,
},
],
},
});
expect(wrapper.find('button[aria-label="删除文件"]').exists()).toBe(false);
expect(wrapper.find('.workflow-file-input__summary').exists()).toBe(true);
await wrapper.setProps({ modelValue: [] });
expect(
wrapper.get('.workflow-file-input__upload-trigger').attributes(),
).toHaveProperty('disabled');
});
});

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveWorkflowFormParameters } from '../workflowFormParameters'; import {
resolveWorkflowFormParameters,
resolveWorkflowParameterDisplayName,
resolveWorkflowParameterLabel,
} from '../workflowFormParameters';
describe('resolveWorkflowFormParameters', () => { describe('resolveWorkflowFormParameters', () => {
it('uses the image parameter when a legacy schema still declares text', () => { it('uses the image parameter when a legacy schema still declares text', () => {
@@ -73,4 +77,46 @@ describe('resolveWorkflowFormParameters', () => {
formLabel: '背景资料', formLabel: '背景资料',
}); });
}); });
it('uses the configured parameter name for a default trial-run label', () => {
expect(
resolveWorkflowParameterLabel({
name: 'customer_name',
formLabel: '新字段',
}),
).toBe('customer_name');
expect(
resolveWorkflowParameterDisplayName({
name: 'start_1.customer_name',
displayName: '开始节点 > 新字段',
}),
).toBe('开始节点 > customer_name');
});
it('uses the configured parameter name instead of a type-derived label', () => {
const parameter = {
name: 'start_1.file111',
formLabel: '文件',
displayName: '开始节点 > 文件',
};
expect(resolveWorkflowParameterLabel(parameter)).toBe('file111');
expect(resolveWorkflowParameterDisplayName(parameter)).toBe(
'开始节点 > file111',
);
});
it('preserves the configured system question label', () => {
const parameter = {
name: 'user_input',
formLabel: '用户问题123',
displayName: '流程开始 > 用户问题123',
systemReserved: true,
};
expect(resolveWorkflowParameterLabel(parameter)).toBe('用户问题123');
expect(resolveWorkflowParameterDisplayName(parameter)).toBe(
'流程开始 > 用户问题123',
);
});
}); });

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
buildWorkflowFormParameterSummaries,
buildWorkflowFormSubmissionImages, buildWorkflowFormSubmissionImages,
buildWorkflowFormSubmissionText, buildWorkflowFormSubmissionText,
hasRequiredWorkflowFormParameters, hasRequiredWorkflowFormParameters,
@@ -67,4 +68,43 @@ describe('workflowFormPresentation', () => {
}, },
]); ]);
}); });
it('builds compact summaries for configured workflow parameters', () => {
expect(
buildWorkflowFormParameterSummaries(
[
{ name: 'customer', formLabel: '客户名称', required: true },
{ name: 'scene', formLabel: '业务场景', required: false },
{ name: 'files', formLabel: '需求附件', required: true },
],
{
customer: '华北分公司',
scene: '',
files: [{ fileName: '需求说明.pdf' }],
},
),
).toEqual([
{
key: 'customer',
label: 'customer',
ready: true,
required: true,
value: '华北分公司',
},
{
key: 'scene',
label: 'scene',
ready: false,
required: false,
value: '待填写',
},
{
key: 'files',
label: 'files',
ready: true,
required: true,
value: '需求说明.pdf',
},
]);
});
}); });

View File

@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
buildWorkflowRunDraftKey,
hasWorkflowRunDraftContent,
readWorkflowRunDraft,
removeWorkflowRunDraft,
WORKFLOW_RUN_DRAFT_TTL_MS,
writeWorkflowRunDraft,
} from '../workflowRunDraft';
const parameters = [
{ contentType: 'text', formType: 'input', name: 'company' },
{ contentType: 'file', formType: 'input', name: 'attachment' },
];
describe('workflowRunDraft', () => {
beforeEach(() => {
sessionStorage.clear();
});
it('isolates drafts by workflow, account and run mode', () => {
expect(buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false)).not.toBe(
buildWorkflowRunDraftKey('flow-1', 'tenant:user-2', false),
);
expect(buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false)).not.toBe(
buildWorkflowRunDraftKey('flow-2', 'tenant:user-1', false),
);
expect(buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', true)).not.toBe(
buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false),
);
});
it('restores current compatible fields within twelve hours', () => {
const key = buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false);
writeWorkflowRunDraft(
sessionStorage,
key,
{
question: '分析合同',
values: {
attachment: [{ name: 'contract.pdf', url: '/contract.pdf' }],
company: '华北分公司',
removedField: '旧字段',
},
},
1000,
);
expect(readWorkflowRunDraft(sessionStorage, key, parameters, 2000)).toEqual(
{
question: '分析合同',
values: {
attachment: [{ name: 'contract.pdf', url: '/contract.pdf' }],
company: '华北分公司',
},
},
);
});
it('drops expired drafts', () => {
const key = buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false);
writeWorkflowRunDraft(
sessionStorage,
key,
{
question: '过期内容',
values: { attachment: '错误文件值', company: ['错误文本值'] },
},
1000,
);
expect(
readWorkflowRunDraft(
sessionStorage,
key,
parameters,
1000 + WORKFLOW_RUN_DRAFT_TTL_MS,
),
).toBeUndefined();
expect(sessionStorage.getItem(key)).toBeNull();
});
it('only persists user changes and supports an explicit reset', () => {
const defaults = { attachment: [], company: '' };
expect(hasWorkflowRunDraftContent('', defaults, defaults)).toBe(false);
expect(
hasWorkflowRunDraftContent(
'',
{ ...defaults, company: '华北' },
defaults,
),
).toBe(true);
expect(hasWorkflowRunDraftContent('待处理', defaults, defaults)).toBe(true);
const key = buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false);
writeWorkflowRunDraft(sessionStorage, key, {
question: '待处理',
values: defaults,
});
removeWorkflowRunDraft(sessionStorage, key);
expect(sessionStorage.getItem(key)).toBeNull();
});
});

View File

@@ -17,6 +17,85 @@ const DEFAULT_FIELD_LABELS = new Set([
const GENERATED_FIELD_KEY_PATTERN = const GENERATED_FIELD_KEY_PATTERN =
/^(?:field_[A-Za-z0-9]+|(?:text|textarea|radio|checkbox|select|file)_field(?:_\d+)?)$/; /^(?:field_[A-Za-z0-9]+|(?:text|textarea|radio|checkbox|select|file)_field(?:_\d+)?)$/;
function configuredParameterName(name: unknown) {
const normalizedName = String(name || '').trim();
const nameParts = normalizedName.split('.').filter(Boolean);
return nameParts[nameParts.length - 1] || normalizedName;
}
function isSystemParameter(parameter: any, name: string) {
return parameter?.systemReserved === true || name === 'user_input';
}
function withConfiguredParameterName(label: unknown, name: unknown) {
const normalizedLabel = String(label || '').trim();
const parameterName = configuredParameterName(name);
if (!parameterName) {
return normalizedLabel;
}
const parts = normalizedLabel.split('>').map((part) => part.trim());
return parts.length > 1
? `${parts.slice(0, -1).join(' > ')} > ${parameterName}`
: parameterName;
}
function replaceDefaultParameterLabel(label: unknown, name: unknown) {
const normalizedLabel = String(label || '').trim();
const normalizedName = String(name || '').trim();
if (!normalizedLabel || !normalizedName) {
return normalizedLabel;
}
const parts = normalizedLabel.split('>').map((part) => part.trim());
const lastPart = parts[parts.length - 1] || '';
if (!DEFAULT_FIELD_LABELS.has(lastPart)) {
return normalizedLabel;
}
const parameterName = configuredParameterName(normalizedName);
return parts.length > 1
? `${parts.slice(0, -1).join(' > ')} > ${parameterName}`
: parameterName;
}
/**
* 解析工作流参数在表单中的展示名称。
*
* @param parameter 工作流运行参数
* @returns 用户可见的参数名称
*/
export function resolveWorkflowParameterLabel(parameter: any) {
const name = String(parameter?.name || '').trim();
if (!isSystemParameter(parameter, name) && name) {
return configuredParameterName(name);
}
const formLabel = replaceDefaultParameterLabel(parameter?.formLabel, name);
const displayName = replaceDefaultParameterLabel(
parameter?.displayName,
name,
);
return formLabel || displayName || name || '参数';
}
/**
* 解析工作流参数在引用内容中的展示名称。
*
* @param parameter 工作流运行参数
* @returns 用户可见的引用参数名称
*/
export function resolveWorkflowParameterDisplayName(parameter: any) {
const name = String(parameter?.name || '').trim();
if (!isSystemParameter(parameter, name) && name) {
return withConfiguredParameterName(
parameter?.displayName || parameter?.formLabel,
name,
);
}
const displayName = replaceDefaultParameterLabel(
parameter?.displayName,
name,
);
return displayName || resolveWorkflowParameterLabel(parameter);
}
function resolveFieldLabel(field: any) { function resolveFieldLabel(field: any) {
const key = String(field?.key || '').trim(); const key = String(field?.key || '').trim();
const label = String(field?.label || '').trim(); const label = String(field?.label || '').trim();

View File

@@ -1,10 +1,19 @@
import type { ChatImageAttachment } from '@easyflow/common-ui'; import type { ChatImageAttachment } from '@easyflow/common-ui';
import { resolveWorkflowParameterLabel } from './workflowFormParameters';
import { import {
getWorkflowImagePreviewUrl, getWorkflowImagePreviewUrl,
normalizeWorkflowImageValue, normalizeWorkflowImageValue,
} from './workflowImageValue'; } from './workflowImageValue';
export interface WorkflowFormParameterSummary {
key: string;
label: string;
ready: boolean;
required: boolean;
value: string;
}
/** /**
* 判断附加表单是否存在必填参数。 * 判断附加表单是否存在必填参数。
* *
@@ -44,6 +53,29 @@ export function buildWorkflowFormSubmissionText(
.join('\n'); .join('\n');
} }
/**
* 构建运行参数的紧凑摘要。
*
* @param parameters 运行参数
* @param values 表单值
* @returns 可用于收起态展示的参数摘要
*/
export function buildWorkflowFormParameterSummaries(
parameters: any[],
values: Record<string, any>,
): WorkflowFormParameterSummary[] {
return parameters.map((parameter) => {
const value = formatWorkflowFormValue(values[parameter?.name]);
return {
key: String(parameter?.name || ''),
label: resolveWorkflowParameterLabel(parameter),
ready: Boolean(value),
required: parameter?.required === true,
value: value || '待填写',
};
});
}
/** /**
* 将图片表单字段转换为聊天图片附件。 * 将图片表单字段转换为聊天图片附件。
* *
@@ -86,7 +118,7 @@ export function buildWorkflowFormSubmissionImages(
* @param value 表单字段值 * @param value 表单字段值
* @returns 用户可读文本;空值返回空字符串 * @returns 用户可读文本;空值返回空字符串
*/ */
function formatWorkflowFormValue(value: any): string { export function formatWorkflowFormValue(value: any): string {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
return ''; return '';
} }

View File

@@ -0,0 +1,173 @@
const WORKFLOW_RUN_DRAFT_PREFIX = 'easyflow:workflow-run-draft';
const WORKFLOW_RUN_DRAFT_VERSION = 1;
export const WORKFLOW_RUN_DRAFT_TTL_MS = 12 * 60 * 60 * 1000;
interface WorkflowRunDraftPayload {
question: string;
values: Record<string, unknown>;
}
interface StoredWorkflowRunDraft extends WorkflowRunDraftPayload {
expiresAt: number;
version: number;
}
type DraftStorage = Pick<Storage, 'getItem' | 'removeItem' | 'setItem'>;
/** 获取可用的会话存储。 */
export function getWorkflowRunDraftStorage() {
try {
return globalThis.sessionStorage;
} catch {
return undefined;
}
}
/** 生成按工作流、账号和运行模式隔离的草稿键。 */
export function buildWorkflowRunDraftKey(
workflowId: string,
identity: string,
shareMode: boolean,
) {
const mode = shareMode ? 'share' : 'private';
const scope = shareMode ? 'public' : identity || 'anonymous';
return [
WORKFLOW_RUN_DRAFT_PREFIX,
`v${WORKFLOW_RUN_DRAFT_VERSION}`,
mode,
encodeURIComponent(scope),
encodeURIComponent(workflowId),
].join(':');
}
/** 读取并按当前工作流参数定义过滤草稿。 */
export function readWorkflowRunDraft(
storage: DraftStorage | undefined,
key: string,
parameters: any[],
now = Date.now(),
): undefined | WorkflowRunDraftPayload {
if (!storage || !key) {
return undefined;
}
try {
const raw = storage.getItem(key);
if (!raw) {
return undefined;
}
const draft = JSON.parse(raw) as Partial<StoredWorkflowRunDraft>;
if (
draft.version !== WORKFLOW_RUN_DRAFT_VERSION ||
typeof draft.expiresAt !== 'number' ||
draft.expiresAt <= now ||
typeof draft.question !== 'string' ||
!draft.values ||
typeof draft.values !== 'object' ||
Array.isArray(draft.values)
) {
storage.removeItem(key);
return undefined;
}
const values: Record<string, unknown> = {};
for (const parameter of parameters) {
const name = String(parameter?.name || '').trim();
if (
name &&
Object.prototype.hasOwnProperty.call(draft.values, name) &&
isCompatibleDraftValue(parameter, draft.values[name])
) {
values[name] = draft.values[name];
}
}
return { question: draft.question, values };
} catch {
try {
storage.removeItem(key);
} catch {
// 存储不可用时无需影响页面加载。
}
return undefined;
}
}
/** 保存工作流运行草稿,并设置 12 小时过期时间。 */
export function writeWorkflowRunDraft(
storage: DraftStorage | undefined,
key: string,
draft: WorkflowRunDraftPayload,
now = Date.now(),
) {
if (!storage || !key) {
return;
}
try {
storage.setItem(
key,
JSON.stringify({
...draft,
expiresAt: now + WORKFLOW_RUN_DRAFT_TTL_MS,
version: WORKFLOW_RUN_DRAFT_VERSION,
} satisfies StoredWorkflowRunDraft),
);
} catch {
// 存储不可用或空间不足时不阻断工作流输入。
}
}
/** 删除工作流运行草稿。 */
export function removeWorkflowRunDraft(
storage: DraftStorage | undefined,
key: string,
) {
if (!storage || !key) {
return;
}
try {
storage.removeItem(key);
} catch {
// 存储不可用时无需影响重置流程。
}
}
/** 判断当前输入是否包含需要持久化的用户修改。 */
export function hasWorkflowRunDraftContent(
question: string,
values: Record<string, unknown>,
defaults: Record<string, unknown>,
) {
if (question.length > 0) {
return true;
}
return Object.keys(values).some(
(name) => !isSameDraftValue(values[name], defaults[name]),
);
}
function isCompatibleDraftValue(parameter: any, value: unknown) {
const contentType = String(parameter?.contentType || '').toLowerCase();
const formType = String(parameter?.formType || '').toLowerCase();
if (contentType === 'file' || formType === 'checkbox') {
return Array.isArray(value);
}
if (contentType === 'image') {
return Boolean(value) && typeof value === 'object';
}
return (
value === null ||
typeof value === 'boolean' ||
typeof value === 'number' ||
typeof value === 'string'
);
}
function isSameDraftValue(left: unknown, right: unknown) {
if (left === right) {
return true;
}
try {
return JSON.stringify(left) === JSON.stringify(right);
} catch {
return false;
}
}

View File

@@ -1,13 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ defineProps<{
actionDisabled?: boolean;
actionLabel?: string;
message: string; message: string;
}>(); }>();
const emit = defineEmits<{
action: [];
}>();
</script> </script>
<template> <template>
<div class="chat-error-notice" role="alert"> <div class="chat-error-notice" role="alert">
<span class="chat-error-notice__icon" aria-hidden="true">!</span> <span class="chat-error-notice__icon" aria-hidden="true">!</span>
<span>{{ message }}</span> <span class="chat-error-notice__content">
<span>{{ message }}<template v-if="actionLabel"></template></span>
<button
v-if="actionLabel"
type="button"
class="chat-error-notice__action"
:disabled="actionDisabled"
@click="emit('action')"
>
{{ actionLabel }}
</button>
</span>
</div> </div>
</template> </template>
@@ -39,4 +56,41 @@ defineProps<{
border: 1px solid currentColor; border: 1px solid currentColor;
border-radius: 50%; border-radius: 50%;
} }
.chat-error-notice__content {
min-width: 0;
}
.chat-error-notice__action {
padding: 0;
margin-left: var(--space-1);
font: inherit;
font-weight: 600;
line-height: inherit;
color: currentcolor;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--el-border-radius-small);
}
.chat-error-notice__action:hover:not(:disabled) {
opacity: 0.8;
}
.chat-error-notice__action:active:not(:disabled) {
opacity: 0.65;
}
.chat-error-notice__action:focus-visible {
outline: 2px solid var(--el-color-primary-light-3);
outline-offset: 2px;
}
.chat-error-notice__action:disabled {
cursor: not-allowed;
opacity: 0.5;
}
</style> </style>

View File

@@ -3,6 +3,7 @@ import type {
ChatArtifactLoader, ChatArtifactLoader,
ChatDocumentLoader, ChatDocumentLoader,
ChatImageLoader, ChatImageLoader,
ChatTimelineErrorItem,
ChatTimelineItem as ChatTimelineItemType, ChatTimelineItem as ChatTimelineItemType,
ChatTimelineMessageItem, ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload, ChatTimelineToolApprovalPayload,
@@ -23,6 +24,8 @@ const props = defineProps<{
documentLoader?: ChatDocumentLoader; documentLoader?: ChatDocumentLoader;
emptyText?: string; emptyText?: string;
emptyTitle?: string; emptyTitle?: string;
errorAction?: (item: ChatTimelineErrorItem) => string | undefined;
errorActionDisabled?: boolean;
imageLoader?: ChatImageLoader; imageLoader?: ChatImageLoader;
items: ChatTimelineItemType[]; items: ChatTimelineItemType[];
regenerable?: (item: ChatTimelineMessageItem) => boolean; regenerable?: (item: ChatTimelineMessageItem) => boolean;
@@ -33,6 +36,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload]; approve: [payload: ChatTimelineToolApprovalPayload];
copyMessage: [item: ChatTimelineMessageItem]; copyMessage: [item: ChatTimelineMessageItem];
errorAction: [item: ChatTimelineErrorItem];
regenerateMessage: [item: ChatTimelineMessageItem]; regenerateMessage: [item: ChatTimelineMessageItem];
reject: [payload: ChatTimelineToolApprovalPayload]; reject: [payload: ChatTimelineToolApprovalPayload];
selectNextVariant: [item: ChatTimelineMessageItem]; selectNextVariant: [item: ChatTimelineMessageItem];
@@ -210,6 +214,10 @@ function canRegenerateMessage(item: ChatTimelineItemType) {
return item.type === 'message' && (props.regenerable?.(item) ?? false); return item.type === 'message' && (props.regenerable?.(item) ?? false);
} }
function errorActionLabel(item: ChatTimelineItemType) {
return item.type === 'error' ? props.errorAction?.(item) : undefined;
}
function isAssistantActionAnchor(item: ChatTimelineItemType) { function isAssistantActionAnchor(item: ChatTimelineItemType) {
return ( return (
item.type === 'message' && item.type === 'message' &&
@@ -275,6 +283,8 @@ watch(
:copy-action="copyAction" :copy-action="copyAction"
:copyable="copyable" :copyable="copyable"
:document-loader="documentLoader" :document-loader="documentLoader"
:error-action="errorAction"
:error-action-disabled="errorActionDisabled"
:image-loader="imageLoader" :image-loader="imageLoader"
:items="entry.items" :items="entry.items"
:regenerable="regenerable" :regenerable="regenerable"
@@ -283,6 +293,7 @@ watch(
:variant-loading="variantLoading" :variant-loading="variantLoading"
@approve="emit('approve', $event)" @approve="emit('approve', $event)"
@copy-message="emit('copyMessage', $event)" @copy-message="emit('copyMessage', $event)"
@error-action="emit('errorAction', $event)"
@layout-changed="handleLayoutChanged" @layout-changed="handleLayoutChanged"
@layout-toggle="handleLayoutToggle" @layout-toggle="handleLayoutToggle"
@regenerate-message="emit('regenerateMessage', $event)" @regenerate-message="emit('regenerateMessage', $event)"
@@ -306,6 +317,8 @@ watch(
:assistant-avatar="assistantAvatar" :assistant-avatar="assistantAvatar"
:item="entry.item" :item="entry.item"
:document-loader="documentLoader" :document-loader="documentLoader"
:error-action-disabled="errorActionDisabled"
:error-action-label="errorActionLabel(entry.item)"
:image-loader="imageLoader" :image-loader="imageLoader"
:approval-loading="approvalLoading" :approval-loading="approvalLoading"
:copy-action="copyAction" :copy-action="copyAction"
@@ -315,6 +328,7 @@ watch(
:variant-loading="isVariantLoading(entry.item)" :variant-loading="isVariantLoading(entry.item)"
@approve="emit('approve', $event)" @approve="emit('approve', $event)"
@copy-message="emit('copyMessage', $event)" @copy-message="emit('copyMessage', $event)"
@error-action="emit('errorAction', $event)"
@regenerate-message="emit('regenerateMessage', $event)" @regenerate-message="emit('regenerateMessage', $event)"
@reject="emit('reject', $event)" @reject="emit('reject', $event)"
@select-next-variant="emit('selectNextVariant', $event)" @select-next-variant="emit('selectNextVariant', $event)"

View File

@@ -3,6 +3,7 @@ import type {
ChatArtifactLoader, ChatArtifactLoader,
ChatDocumentLoader, ChatDocumentLoader,
ChatImageLoader, ChatImageLoader,
ChatTimelineErrorItem,
ChatTimelineItem, ChatTimelineItem,
ChatTimelineMessageItem, ChatTimelineMessageItem,
ChatTimelineMessagePart, ChatTimelineMessagePart,
@@ -31,6 +32,8 @@ const props = defineProps<{
copyable?: boolean; copyable?: boolean;
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>; copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
documentLoader?: ChatDocumentLoader; documentLoader?: ChatDocumentLoader;
errorActionDisabled?: boolean;
errorActionLabel?: string;
imageLoader?: ChatImageLoader; imageLoader?: ChatImageLoader;
item: ChatTimelineItem; item: ChatTimelineItem;
regenerable?: boolean; regenerable?: boolean;
@@ -41,6 +44,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload]; approve: [payload: ChatTimelineToolApprovalPayload];
copyMessage: [item: ChatTimelineMessageItem]; copyMessage: [item: ChatTimelineMessageItem];
errorAction: [item: ChatTimelineErrorItem];
regenerateMessage: [item: ChatTimelineMessageItem]; regenerateMessage: [item: ChatTimelineMessageItem];
reject: [payload: ChatTimelineToolApprovalPayload]; reject: [payload: ChatTimelineToolApprovalPayload];
selectNextVariant: [item: ChatTimelineMessageItem]; selectNextVariant: [item: ChatTimelineMessageItem];
@@ -280,7 +284,10 @@ function handleCopyAction() {
</div> </div>
<ChatErrorNotice <ChatErrorNotice
v-else-if="item.type === 'error'" v-else-if="item.type === 'error'"
:action-disabled="errorActionDisabled"
:action-label="errorActionLabel"
:message="item.message" :message="item.message"
@action="emit('errorAction', item)"
/> />
</div> </div>
</template> </template>

View File

@@ -3,6 +3,7 @@ import type {
ChatArtifactLoader, ChatArtifactLoader,
ChatDocumentLoader, ChatDocumentLoader,
ChatImageLoader, ChatImageLoader,
ChatTimelineErrorItem,
ChatTimelineItem, ChatTimelineItem,
ChatTimelineMessageItem, ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload, ChatTimelineToolApprovalPayload,
@@ -21,6 +22,8 @@ const props = defineProps<{
copyable?: (item: ChatTimelineMessageItem) => boolean; copyable?: (item: ChatTimelineMessageItem) => boolean;
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>; copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
documentLoader?: ChatDocumentLoader; documentLoader?: ChatDocumentLoader;
errorAction?: (item: ChatTimelineErrorItem) => string | undefined;
errorActionDisabled?: boolean;
imageLoader?: ChatImageLoader; imageLoader?: ChatImageLoader;
items: ChatTimelineItem[]; items: ChatTimelineItem[];
regenerable?: (item: ChatTimelineMessageItem) => boolean; regenerable?: (item: ChatTimelineMessageItem) => boolean;
@@ -32,6 +35,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload]; approve: [payload: ChatTimelineToolApprovalPayload];
copyMessage: [item: ChatTimelineMessageItem]; copyMessage: [item: ChatTimelineMessageItem];
errorAction: [item: ChatTimelineErrorItem];
layoutChanged: []; layoutChanged: [];
layoutToggle: [roundId: string]; layoutToggle: [roundId: string];
regenerateMessage: [item: ChatTimelineMessageItem]; regenerateMessage: [item: ChatTimelineMessageItem];
@@ -234,6 +238,10 @@ function canRegenerateMessage(item: ChatTimelineItem) {
return item.type === 'message' && (props.regenerable?.(item) ?? false); return item.type === 'message' && (props.regenerable?.(item) ?? false);
} }
function errorActionLabel(item: ChatTimelineItem) {
return item.type === 'error' ? props.errorAction?.(item) : undefined;
}
function isVariantLoading(item: ChatTimelineItem) { function isVariantLoading(item: ChatTimelineItem) {
return item.type === 'message' && (props.variantLoading?.(item) ?? false); return item.type === 'message' && (props.variantLoading?.(item) ?? false);
} }
@@ -313,6 +321,8 @@ function handleNestedLayoutToggle() {
" "
:item="item" :item="item"
:document-loader="documentLoader" :document-loader="documentLoader"
:error-action-disabled="errorActionDisabled"
:error-action-label="errorActionLabel(item)"
:image-loader="imageLoader" :image-loader="imageLoader"
:approval-loading="approvalLoading" :approval-loading="approvalLoading"
:copy-action="copyAction" :copy-action="copyAction"
@@ -322,6 +332,7 @@ function handleNestedLayoutToggle() {
:variant-loading="isVariantLoading(item)" :variant-loading="isVariantLoading(item)"
@approve="emit('approve', $event)" @approve="emit('approve', $event)"
@copy-message="emit('copyMessage', $event)" @copy-message="emit('copyMessage', $event)"
@error-action="emit('errorAction', $event)"
@regenerate-message="emit('regenerateMessage', $event)" @regenerate-message="emit('regenerateMessage', $event)"
@reject="emit('reject', $event)" @reject="emit('reject', $event)"
@select-next-variant="emit('selectNextVariant', $event)" @select-next-variant="emit('selectNextVariant', $event)"
@@ -338,6 +349,8 @@ function handleNestedLayoutToggle() {
:artifact-loader="artifactLoader" :artifact-loader="artifactLoader"
:item="finalMessage" :item="finalMessage"
:document-loader="documentLoader" :document-loader="documentLoader"
:error-action-disabled="errorActionDisabled"
:error-action-label="errorActionLabel(finalMessage)"
:image-loader="imageLoader" :image-loader="imageLoader"
:copy-action="copyAction" :copy-action="copyAction"
:copyable="canCopyMessage(finalMessage)" :copyable="canCopyMessage(finalMessage)"
@@ -345,6 +358,7 @@ function handleNestedLayoutToggle() {
:regenerate-disabled="regenerateDisabled" :regenerate-disabled="regenerateDisabled"
:variant-loading="isVariantLoading(finalMessage)" :variant-loading="isVariantLoading(finalMessage)"
@copy-message="emit('copyMessage', $event)" @copy-message="emit('copyMessage', $event)"
@error-action="emit('errorAction', $event)"
@regenerate-message="emit('regenerateMessage', $event)" @regenerate-message="emit('regenerateMessage', $event)"
@select-next-variant="emit('selectNextVariant', $event)" @select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)" @select-previous-variant="emit('selectPreviousVariant', $event)"

View File

@@ -154,6 +154,28 @@ describe('chat timeline toolbar', () => {
); );
}); });
it('renders and emits an accessible error action', async () => {
const errorItem: Extract<ChatTimelineItem, { type: 'error' }> = {
id: 'error-1',
message: '模型连接异常',
roundId: 'round-1',
type: 'error',
};
const wrapper = mount(ChatTimeline, {
props: {
errorAction: () => '请重试',
items: [errorItem],
},
});
const retryButton = wrapper.get('.chat-error-notice__action');
expect(wrapper.text()).toContain('模型连接异常,请重试');
expect(retryButton.text()).toBe('请重试');
await retryButton.trigger('click');
expect(wrapper.emitted('errorAction')?.[0]?.[0]).toEqual(errorItem);
});
it('shows a check icon after the copy action succeeds', async () => { it('shows a check icon after the copy action succeeds', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const copyAction = vi.fn().mockResolvedValue(true); const copyAction = vi.fn().mockResolvedValue(true);