Compare commits
3 Commits
1870ac4028
...
6248e2c7b8
| Author | SHA1 | Date | |
|---|---|---|---|
| 6248e2c7b8 | |||
| 4de8cc5bd0 | |||
| 71b3d3d620 |
@@ -423,7 +423,7 @@ public class AgentRunService {
|
||||
chatContext.getExt().put(DOCUMENT_CONTEXT_TOKEN_ESTIMATE_EXT_KEY,
|
||||
documentContext.tokenEstimate());
|
||||
String runtimePrompt = effectivePrompt(prompt, !boundDocuments.isEmpty(), !boundMedia.isEmpty());
|
||||
AgentMessage userMessage = buildAgentMessage(runtimePrompt, boundMedia);
|
||||
AgentMessage userMessage = buildAgentMessage(runtimePrompt, boundMedia, documentContext);
|
||||
threadPoolTaskExecutor.execute(() -> startRuntime(
|
||||
agent, userMessage, documentContext, account, requestId, traceId, runtimeSessionId,
|
||||
assistantCode, chatContext, runOutput, persistChatlog, runtimeSessionStore, lockHandle));
|
||||
@@ -646,24 +646,6 @@ public class AgentRunService {
|
||||
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);
|
||||
AgentRuntimeBundle bundle = agentRuntimeCompiler.compile(agent, runtimeContext, !persistChatlog);
|
||||
appendDocumentContext(bundle, documentContext);
|
||||
AgentRuntime runtime = agentRuntimeFactory.create();
|
||||
// 会话初始化请求
|
||||
AgentInitRequest request = new AgentInitRequest();
|
||||
@@ -2340,13 +2321,30 @@ public class AgentRunService {
|
||||
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();
|
||||
message.setRole(AgentMessageRole.USER);
|
||||
List<com.easyagents.agent.runtime.message.AgentContentBlock> blocks = new ArrayList<>();
|
||||
if (prompt != null && !prompt.isBlank()) {
|
||||
blocks.add(new AgentTextBlock(prompt));
|
||||
}
|
||||
if (documentContext != null && documentContext.text() != null
|
||||
&& !documentContext.text().isBlank()) {
|
||||
blocks.add(new AgentTextBlock(documentContext.text()));
|
||||
}
|
||||
if (media != null) {
|
||||
for (AgentBoundMedia item : media) {
|
||||
AgentMediaBlock image = new AgentMediaBlock("image");
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
|
||||
import com.easyagents.agent.runtime.message.AgentMessage;
|
||||
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.memory.InMemoryAgentSessionStore;
|
||||
import org.junit.Assert;
|
||||
@@ -69,6 +70,28 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
*/
|
||||
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 恢复测试的运行描述。
|
||||
*
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"visibilityScopePublic": "Public",
|
||||
"visibilityScopePublicDesc": "Available to internal users matched by category",
|
||||
"params": "Params",
|
||||
"runInputForm": "Input form",
|
||||
"steps": "Steps",
|
||||
"result": "Result",
|
||||
"confirm": "For contents to be confirmed, please confirm first!",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"visibilityScopePublic": "公开",
|
||||
"visibilityScopePublicDesc": "分类命中的内部用户可访问",
|
||||
"params": "执行参数",
|
||||
"runInputForm": "输入表单",
|
||||
"steps": "执行步骤",
|
||||
"result": "执行结果",
|
||||
"confirm": "有待确认的内容,请先确认!",
|
||||
|
||||
@@ -181,6 +181,57 @@ describe('agentChatRuntimeManager', () => {
|
||||
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 () => {
|
||||
const account = {
|
||||
avatar: '',
|
||||
|
||||
@@ -45,6 +45,7 @@ interface RuntimeSessionState {
|
||||
prompt: string;
|
||||
projectionToolArgs: Record<string, string>;
|
||||
projectionToolNames: Record<string, string>;
|
||||
retryContextReady: boolean;
|
||||
runId?: string;
|
||||
roundId: string;
|
||||
sending: boolean;
|
||||
@@ -65,6 +66,7 @@ interface StoredRuntimeSession {
|
||||
prompt: string;
|
||||
projectionToolArgs?: Record<string, string>;
|
||||
projectionToolNames?: Record<string, string>;
|
||||
retryContextReady?: boolean;
|
||||
runId?: string;
|
||||
roundId: string;
|
||||
sending: boolean;
|
||||
@@ -86,10 +88,12 @@ interface StartOptions {
|
||||
images?: ChatImageAttachment[];
|
||||
onInputAccepted?: () => Promise<void> | void;
|
||||
prompt: string;
|
||||
retryContextReady?: boolean;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
const STORAGE_VERSION = 5;
|
||||
const STORAGE_VERSION = 6;
|
||||
const MODEL_CONNECTION_ERROR_MESSAGE = '模型连接异常';
|
||||
const STREAM_NOTIFY_INTERVAL_MS = 50;
|
||||
const STREAM_PERSIST_INTERVAL_MS = 300;
|
||||
const MAX_RUNTIME_SESSIONS_PER_IDENTITY = 10;
|
||||
@@ -177,6 +181,7 @@ function persistSession(state: RuntimeSessionState) {
|
||||
prompt: state.prompt,
|
||||
projectionToolArgs: state.projectionToolArgs,
|
||||
projectionToolNames: state.projectionToolNames,
|
||||
retryContextReady: state.retryContextReady,
|
||||
runId: state.runId,
|
||||
roundId: state.roundId,
|
||||
sending: state.sending,
|
||||
@@ -298,7 +303,7 @@ function restoreSession(identity: string, sessionId: string) {
|
||||
}
|
||||
const parsed = JSON.parse(raw) as StoredRuntimeSession;
|
||||
if (
|
||||
![3, 4, STORAGE_VERSION].includes(parsed.version) ||
|
||||
![3, 4, 5, STORAGE_VERSION].includes(parsed.version) ||
|
||||
parsed.sessionId !== sessionId
|
||||
) {
|
||||
return undefined;
|
||||
@@ -324,6 +329,7 @@ function restoreSession(identity: string, sessionId: string) {
|
||||
typeof parsed.projectionToolNames === 'object'
|
||||
? parsed.projectionToolNames
|
||||
: {},
|
||||
retryContextReady: Boolean(parsed.retryContextReady),
|
||||
runId: parsed.runId,
|
||||
roundId: parsed.roundId,
|
||||
sending: Boolean(parsed.sending && parsed.runId),
|
||||
@@ -386,6 +392,7 @@ function acceptInput(
|
||||
) {
|
||||
replaceAcceptedAttachments(state.items, state.roundId, payload);
|
||||
state.inputAccepted = true;
|
||||
state.retryContextReady = true;
|
||||
persistSession(state);
|
||||
notifyInputAccepted(state);
|
||||
}
|
||||
@@ -563,7 +570,9 @@ function finishRuntimeSuccess(state: RuntimeSessionState) {
|
||||
}
|
||||
|
||||
function finishRuntimeFailure(state: RuntimeSessionState, error: unknown) {
|
||||
state.error = errorMessage(error);
|
||||
state.error = state.retryContextReady
|
||||
? MODEL_CONNECTION_ERROR_MESSAGE
|
||||
: errorMessage(error);
|
||||
state.sending = false;
|
||||
state.completed = true;
|
||||
const last = state.items[state.items.length - 1];
|
||||
@@ -664,7 +673,9 @@ export const agentChatRuntimeManager = {
|
||||
event.type === EventType.RUN_ERROR &&
|
||||
event.code !== 'RUN_CANCELLED'
|
||||
) {
|
||||
current.error = event.message || '发送失败,请稍后再试';
|
||||
current.error = current.retryContextReady
|
||||
? MODEL_CONNECTION_ERROR_MESSAGE
|
||||
: event.message || '发送失败,请稍后再试';
|
||||
}
|
||||
observeTerminalEvent(current, event.type);
|
||||
applyAguiEventToTimeline(
|
||||
@@ -674,6 +685,9 @@ export const agentChatRuntimeManager = {
|
||||
onInputAccepted(payload) {
|
||||
acceptInput(current, payload);
|
||||
},
|
||||
runErrorMessage: current.retryContextReady
|
||||
? MODEL_CONNECTION_ERROR_MESSAGE
|
||||
: undefined,
|
||||
roundId: current.roundId,
|
||||
startedAt: current.startedAt,
|
||||
},
|
||||
@@ -757,6 +771,7 @@ export const agentChatRuntimeManager = {
|
||||
prompt: options.prompt,
|
||||
projectionToolArgs: {},
|
||||
projectionToolNames: {},
|
||||
retryContextReady: Boolean(options.retryContextReady),
|
||||
runId,
|
||||
roundId,
|
||||
sending: true,
|
||||
@@ -794,7 +809,9 @@ export const agentChatRuntimeManager = {
|
||||
event.type === EventType.RUN_ERROR &&
|
||||
event.code !== 'RUN_CANCELLED'
|
||||
) {
|
||||
current.error = event.message || '发送失败,请稍后再试';
|
||||
current.error = current.retryContextReady
|
||||
? MODEL_CONNECTION_ERROR_MESSAGE
|
||||
: event.message || '发送失败,请稍后再试';
|
||||
}
|
||||
observeTerminalEvent(current, event.type);
|
||||
applyAguiEventToTimeline(
|
||||
@@ -804,6 +821,9 @@ export const agentChatRuntimeManager = {
|
||||
onInputAccepted(payload) {
|
||||
acceptInput(current, payload);
|
||||
},
|
||||
runErrorMessage: current.retryContextReady
|
||||
? MODEL_CONNECTION_ERROR_MESSAGE
|
||||
: undefined,
|
||||
roundId,
|
||||
startedAt,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type {
|
||||
ChatDocumentAttachment,
|
||||
ChatImageAttachment,
|
||||
ChatTimelineErrorItem,
|
||||
ChatTimelineItem,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineToolApprovalPayload,
|
||||
@@ -108,6 +109,7 @@ const loadingKnowledges = ref(false);
|
||||
const savingExtraKnowledges = ref(false);
|
||||
const sending = ref(false);
|
||||
const runtimeRunning = ref(false);
|
||||
const retryableErrorRoundId = ref('');
|
||||
const approvalLoadingKey = ref('');
|
||||
const knowledgeOptions = ref<{ label: string; value: string }[]>([]);
|
||||
const knowledgeMap = ref(new Map<string, { id: string; title: string }>());
|
||||
@@ -463,12 +465,19 @@ function syncRuntimeSnapshot(sessionId = currentSessionId.value) {
|
||||
: undefined;
|
||||
if (!snapshot) {
|
||||
sending.value = false;
|
||||
retryableErrorRoundId.value = '';
|
||||
return false;
|
||||
}
|
||||
currentSessionId.value = snapshot.sessionId;
|
||||
selectedAgentId.value = String(snapshot.agentId);
|
||||
timelineItems.value = snapshot.items;
|
||||
sending.value = snapshot.sending;
|
||||
retryableErrorRoundId.value =
|
||||
snapshot.terminalOutcome === 'failed' &&
|
||||
snapshot.retryContextReady &&
|
||||
!snapshot.sending
|
||||
? snapshot.roundId
|
||||
: '';
|
||||
maybeRefreshCompletedRuntimeSession(snapshot);
|
||||
if (snapshot.prompt && !currentSession.value) {
|
||||
upsertSessionRecord(
|
||||
@@ -492,15 +501,20 @@ async function loadConversation(sessionId: string) {
|
||||
if (!sessionId) {
|
||||
timelineItems.value = [];
|
||||
sending.value = false;
|
||||
retryableErrorRoundId.value = '';
|
||||
return;
|
||||
}
|
||||
const runtimeSnapshot = agentChatRuntimeManager.getSnapshot(sessionId);
|
||||
if (runtimeSnapshot?.sending) {
|
||||
if (
|
||||
runtimeSnapshot?.sending ||
|
||||
runtimeSnapshot?.terminalOutcome === 'failed'
|
||||
) {
|
||||
syncRuntimeSnapshot(sessionId);
|
||||
await syncSessionRoute(sessionId);
|
||||
return;
|
||||
}
|
||||
loadingConversation.value = true;
|
||||
retryableErrorRoundId.value = '';
|
||||
try {
|
||||
const detailRes = await getAgentSession(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 includeComposer = options.includeComposer !== false;
|
||||
const readyImageCount = includeComposer
|
||||
? composer.images.readyItems.value.length
|
||||
: 0;
|
||||
const readyDocumentCount = includeComposer
|
||||
? composer.documents.readyItems.value.length
|
||||
: 0;
|
||||
if (
|
||||
(!content &&
|
||||
composer.images.readyItems.value.length === 0 &&
|
||||
composer.documents.readyItems.value.length === 0) ||
|
||||
(!content && readyImageCount === 0 && readyDocumentCount === 0) ||
|
||||
!selectedAgentId.value ||
|
||||
sending.value
|
||||
) {
|
||||
@@ -675,44 +702,54 @@ async function sendContent(rawContent: string) {
|
||||
ElMessage.warning('当前回复完成后再发送新消息');
|
||||
return;
|
||||
}
|
||||
if (composer.images.uploading.value) {
|
||||
if (includeComposer && composer.images.uploading.value) {
|
||||
ElMessage.warning('图片上传完成后再发送');
|
||||
return;
|
||||
}
|
||||
if (composer.documents.processing.value) {
|
||||
if (includeComposer && composer.documents.processing.value) {
|
||||
ElMessage.warning('文档读取完成后再发送');
|
||||
return;
|
||||
}
|
||||
const failedImage = composer.images.items.value.find(
|
||||
(item) => item.status === 'error',
|
||||
);
|
||||
const failedImage = includeComposer
|
||||
? composer.images.items.value.find((item) => item.status === 'error')
|
||||
: undefined;
|
||||
if (failedImage) {
|
||||
ElMessage.error(failedImage.error || '请处理上传失败的图片');
|
||||
return;
|
||||
}
|
||||
const failedDocument = composer.documents.items.value.find(
|
||||
(item) => item.status === 'error',
|
||||
);
|
||||
const failedDocument = includeComposer
|
||||
? composer.documents.items.value.find((item) => item.status === 'error')
|
||||
: undefined;
|
||||
if (failedDocument) {
|
||||
ElMessage.error(failedDocument.error || '请处理读取失败的文档');
|
||||
return;
|
||||
}
|
||||
await composer.flush();
|
||||
if (includeComposer) {
|
||||
await composer.flush();
|
||||
}
|
||||
sending.value = true;
|
||||
retryableErrorRoundId.value = '';
|
||||
try {
|
||||
const sessionId = await agentChatRuntimeManager.start({
|
||||
agentId: selectedAgentId.value,
|
||||
agentName: selectedAgent.value?.name,
|
||||
baseItems: timelineItems.value,
|
||||
capabilities: buildCapabilities(),
|
||||
documentUploadIds: composer.documents.uploadIds.value,
|
||||
documents: composer.documents.readyItems.value.map((item) => ({
|
||||
...item,
|
||||
})),
|
||||
imageUploadIds: composer.images.uploadIds.value,
|
||||
images: composer.images.readyItems.value.map((item) => ({ ...item })),
|
||||
onInputAccepted: markComposerInputAccepted,
|
||||
documentUploadIds: includeComposer
|
||||
? composer.documents.uploadIds.value
|
||||
: undefined,
|
||||
documents: includeComposer
|
||||
? composer.documents.readyItems.value.map((item) => ({ ...item }))
|
||||
: undefined,
|
||||
imageUploadIds: includeComposer
|
||||
? composer.images.uploadIds.value
|
||||
: undefined,
|
||||
images: includeComposer
|
||||
? composer.images.readyItems.value.map((item) => ({ ...item }))
|
||||
: undefined,
|
||||
onInputAccepted: includeComposer ? markComposerInputAccepted : undefined,
|
||||
prompt: content,
|
||||
retryContextReady: Boolean(options.retryContextReady),
|
||||
sessionId: composer.sessionId.value,
|
||||
});
|
||||
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() {
|
||||
await sendContent(promptText.value);
|
||||
}
|
||||
@@ -1266,12 +1321,15 @@ onBeforeUnmount(() => {
|
||||
:artifact-loader="loadCurrentAgentArtifact"
|
||||
:items="timelineItems"
|
||||
:document-loader="loadAgentChatDocument"
|
||||
:error-action="modelErrorAction"
|
||||
:error-action-disabled="sending || runtimeRunning"
|
||||
:image-loader="loadAgentChatImage"
|
||||
empty-text="选择智能体后开始对话"
|
||||
:approval-loading="Boolean(approvalLoadingKey)"
|
||||
:copy-action="handleCopyMessage"
|
||||
:copyable="canCopyMessage"
|
||||
@approve="handleApprove"
|
||||
@error-action="handleModelErrorRetry"
|
||||
@reject="handleReject"
|
||||
@select-next-variant="() => undefined"
|
||||
@select-previous-variant="() => undefined"
|
||||
|
||||
@@ -14,6 +14,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -37,13 +41,20 @@ const pageUrl = computed(() => {
|
||||
: `${baseUrl}?resourceType=${props.resourceType}`;
|
||||
});
|
||||
function openDialog() {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
function closeDialog() {
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
function confirm() {
|
||||
emit('choose', props.multiple ? chooseResources.value : currentChoose.value, props.attrName);
|
||||
emit(
|
||||
'choose',
|
||||
props.multiple ? chooseResources.value : currentChoose.value,
|
||||
props.attrName,
|
||||
);
|
||||
closeDialog();
|
||||
}
|
||||
watch(
|
||||
@@ -85,7 +96,7 @@ watch(
|
||||
</ElButton>
|
||||
</template>
|
||||
</EasyFlowPanelModal>
|
||||
<ElButton @click="openDialog()">
|
||||
<ElButton :disabled="disabled" @click="openDialog()">
|
||||
{{ $t('button.choose') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { easyFlowAguiCustomEvent } from './custom-events';
|
||||
export interface AguiTimelineProjectionOptions {
|
||||
finishedAt?: number;
|
||||
onInputAccepted?: (payload: Record<string, unknown>) => Promise<void> | void;
|
||||
runErrorMessage?: string;
|
||||
roundId?: string;
|
||||
startedAt?: number;
|
||||
}
|
||||
@@ -361,7 +362,7 @@ export function applyAguiEventToTimeline(
|
||||
}
|
||||
ChatTimelineBuilder.appendError(
|
||||
items,
|
||||
event.message || '请求失败',
|
||||
options.runErrorMessage || event.message || '请求失败',
|
||||
metadata(options, state),
|
||||
);
|
||||
ChatTimelineBuilder.finalize(items, {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { $t } from '#/locales';
|
||||
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
||||
import { buildSingleRunModel } from '../../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
|
||||
|
||||
import { resolveWorkflowParameterDisplayName } from './workflowFormParameters';
|
||||
|
||||
interface Props {
|
||||
workflowId: any;
|
||||
node: any;
|
||||
@@ -31,7 +33,7 @@ const parameterDisplayNameMap = computed(() => {
|
||||
return new Map(
|
||||
singleRunParameters.value.map((parameter: any) => [
|
||||
String(parameter.name || ''),
|
||||
String(parameter.displayName || parameter.formLabel || parameter.name || ''),
|
||||
resolveWorkflowParameterDisplayName(parameter),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
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 { $t } from '#/locales';
|
||||
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
||||
|
||||
import {
|
||||
appendWorkflowFileValues,
|
||||
buildWorkflowFileValueFromResource,
|
||||
buildWorkflowFileValueFromUpload,
|
||||
formatWorkflowFileSize,
|
||||
normalizeWorkflowFileValues,
|
||||
@@ -19,6 +23,10 @@ import {
|
||||
} from './workflowFileValue';
|
||||
|
||||
const props = defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: [Array, Object],
|
||||
default: undefined,
|
||||
@@ -28,21 +36,25 @@ const props = defineProps({
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const uploadLoading = ref(false);
|
||||
const dragActive = ref(false);
|
||||
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() {
|
||||
if (uploadLoading.value) {
|
||||
if (props.disabled || uploadLoading.value) {
|
||||
return;
|
||||
}
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
async function handleNativeFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = Array.from(input.files || []);
|
||||
if (files.length === 0) {
|
||||
async function uploadFiles(files: File[]) {
|
||||
if (props.disabled || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -52,9 +64,17 @@ async function handleNativeFileChange(event: Event) {
|
||||
const uploadedFiles = [];
|
||||
for (const file of files) {
|
||||
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);
|
||||
emit('update:modelValue', nextFiles);
|
||||
} catch (error: any) {
|
||||
@@ -62,32 +82,38 @@ async function handleNativeFileChange(event: Event) {
|
||||
console.error('工作流文件上传失败', error);
|
||||
} finally {
|
||||
uploadLoading.value = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleChooseResource(resources: any) {
|
||||
try {
|
||||
const resourceList = Array.isArray(resources) ? resources : [resources];
|
||||
const fileValues = resourceList
|
||||
.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 || '素材文件选择失败');
|
||||
async function handleNativeFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
await uploadFiles([...(input.files || [])]);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
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) {
|
||||
const nextFiles = currentFiles.value.filter((item) => item.filePath !== filePath);
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
const nextFiles = currentFiles.value.filter(
|
||||
(item) => item.filePath !== filePath,
|
||||
);
|
||||
emit('update:modelValue', nextFiles);
|
||||
}
|
||||
|
||||
function clearFiles() {
|
||||
emit('update:modelValue', []);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -96,14 +122,42 @@ function clearFiles() {
|
||||
ref="fileInputRef"
|
||||
class="workflow-file-input__native"
|
||||
type="file"
|
||||
:disabled="disabled"
|
||||
multiple
|
||||
@change="handleNativeFileChange"
|
||||
/>
|
||||
|
||||
<div class="workflow-file-input__hint">
|
||||
最多 {{ WORKFLOW_FILE_LIMITS.maxCount }} 个文件,单个不超过
|
||||
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize) }},总计不超过
|
||||
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize) }}
|
||||
<div
|
||||
v-if="currentFiles.length === 0"
|
||||
class="workflow-file-input__dropzone"
|
||||
: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 v-if="currentFiles.length > 0" class="workflow-file-input__list">
|
||||
@@ -112,47 +166,39 @@ function clearFiles() {
|
||||
:key="item.filePath"
|
||||
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__name">
|
||||
{{ item.fileName }}
|
||||
</div>
|
||||
<div class="workflow-file-input__meta">
|
||||
<span>{{ formatWorkflowFileSize(item.size) }}</span>
|
||||
<ElLink
|
||||
v-if="item.url || item.filePath"
|
||||
:href="item.url || item.filePath"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
>
|
||||
{{ $t('button.view') }}
|
||||
</ElLink>
|
||||
<span class="workflow-file-input__ready">
|
||||
<ElIcon><CircleCheck /></ElIcon>
|
||||
已上传
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ElButton text type="danger" @click="removeFile(item.filePath)">
|
||||
{{ $t('button.delete') }}
|
||||
</ElButton>
|
||||
<ElLink
|
||||
v-if="item.url || item.filePath"
|
||||
:href="item.url || item.filePath"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
>
|
||||
{{ $t('button.view') }}
|
||||
</ElLink>
|
||||
<ElButton
|
||||
v-if="!disabled"
|
||||
:icon="Delete"
|
||||
text
|
||||
circle
|
||||
aria-label="删除文件"
|
||||
@click="removeFile(item.filePath)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workflow-file-input__actions">
|
||||
<ElButton
|
||||
type="primary"
|
||||
plain
|
||||
: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
|
||||
type="danger"
|
||||
@click="clearFiles"
|
||||
>
|
||||
清空
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -161,16 +207,83 @@ function clearFiles() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workflow-file-input__native {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workflow-file-input__hint {
|
||||
font-size: 12px;
|
||||
.workflow-file-input__dropzone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
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 {
|
||||
@@ -181,39 +294,46 @@ function clearFiles() {
|
||||
|
||||
.workflow-file-input__summary {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 10px;
|
||||
padding: var(--space-3);
|
||||
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 {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workflow-file-input__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.workflow-file-input__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.workflow-file-input__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
.workflow-file-input__ready {
|
||||
display: inline-flex;
|
||||
gap: var(--space-1);
|
||||
align-items: center;
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,9 +13,14 @@ import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
||||
import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue';
|
||||
import WorkflowImageInput from '#/views/ai/workflow/components/WorkflowImageInput.vue';
|
||||
|
||||
import { resolveWorkflowParameterLabel } from './workflowFormParameters';
|
||||
import { hasWorkflowImageValue } from './workflowImageValue';
|
||||
|
||||
const props = defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
parameters: {
|
||||
type: Array<any>,
|
||||
required: true,
|
||||
@@ -45,6 +50,10 @@ function isResource(contentType: any) {
|
||||
function isFileContentType(contentType: any) {
|
||||
return contentType === 'file';
|
||||
}
|
||||
function isWideItem(item: any) {
|
||||
const contentType = getContentType(item);
|
||||
return item.formType === 'textarea' || contentType === 'image';
|
||||
}
|
||||
function getCheckboxOptions(item: any) {
|
||||
if (item.enums) {
|
||||
return (
|
||||
@@ -73,7 +82,9 @@ function buildRules(item: any) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
@@ -90,10 +101,16 @@ function buildRules(item: any) {
|
||||
];
|
||||
}
|
||||
function updateParam(name: string, value: any) {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
const newValue = { ...props.runParams, [name]: value };
|
||||
emit('update:runParams', newValue);
|
||||
}
|
||||
function choose(data: any, propName: string) {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
updateParam(propName, data.resourceUrl);
|
||||
}
|
||||
</script>
|
||||
@@ -101,20 +118,24 @@ function choose(data: any, propName: string) {
|
||||
<template>
|
||||
<ElFormItem
|
||||
v-for="(item, idx) in parameters"
|
||||
class="workflow-form-item"
|
||||
:class="{ 'is-wide': isWideItem(item) }"
|
||||
:prop="`${propPrefix}${item.name}`"
|
||||
:key="idx"
|
||||
:label="item.formLabel || item.name"
|
||||
:label="resolveWorkflowParameterLabel(item)"
|
||||
:rules="buildRules(item)"
|
||||
>
|
||||
<template v-if="getContentType(item) === 'text'">
|
||||
<ElInput
|
||||
v-if="item.formType === 'input' || !item.formType"
|
||||
:disabled="disabled"
|
||||
:model-value="runParams[item.name]"
|
||||
@update:model-value="(val) => updateParam(item.name, val)"
|
||||
:placeholder="item.formPlaceholder"
|
||||
/>
|
||||
<ElSelect
|
||||
v-if="item.formType === 'select'"
|
||||
:disabled="disabled"
|
||||
:model-value="runParams[item.name]"
|
||||
@update:model-value="(val) => updateParam(item.name, val)"
|
||||
:placeholder="item.formPlaceholder"
|
||||
@@ -123,6 +144,7 @@ function choose(data: any, propName: string) {
|
||||
/>
|
||||
<ElInput
|
||||
v-if="item.formType === 'textarea'"
|
||||
:disabled="disabled"
|
||||
:model-value="runParams[item.name]"
|
||||
@update:model-value="(val) => updateParam(item.name, val)"
|
||||
:placeholder="item.formPlaceholder"
|
||||
@@ -131,12 +153,14 @@ function choose(data: any, propName: string) {
|
||||
/>
|
||||
<ElRadioGroup
|
||||
v-if="item.formType === 'radio'"
|
||||
:disabled="disabled"
|
||||
:model-value="runParams[item.name]"
|
||||
@update:model-value="(val) => updateParam(item.name, val)"
|
||||
:options="getCheckboxOptions(item)"
|
||||
/>
|
||||
<ElCheckboxGroup
|
||||
v-if="item.formType === 'checkbox'"
|
||||
:disabled="disabled"
|
||||
:model-value="runParams[item.name]"
|
||||
@update:model-value="(val) => updateParam(item.name, val)"
|
||||
:options="getCheckboxOptions(item)"
|
||||
@@ -144,6 +168,7 @@ function choose(data: any, propName: string) {
|
||||
</template>
|
||||
<template v-if="getContentType(item) === 'other'">
|
||||
<ElInput
|
||||
:disabled="disabled"
|
||||
:model-value="runParams[item.name]"
|
||||
@update:model-value="(val) => updateParam(item.name, val)"
|
||||
:placeholder="item.formPlaceholder"
|
||||
@@ -151,23 +176,30 @@ function choose(data: any, propName: string) {
|
||||
</template>
|
||||
<template v-if="isFileContentType(getContentType(item))">
|
||||
<WorkflowFileInput
|
||||
:disabled="disabled"
|
||||
:model-value="runParams[item.name]"
|
||||
@update:model-value="(val) => updateParam(item.name, val)"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="getContentType(item) === 'image'">
|
||||
<WorkflowImageInput
|
||||
:disabled="disabled"
|
||||
:model-value="runParams[item.name]"
|
||||
@update:model-value="(val) => updateParam(item.name, val)"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="isResource(getContentType(item))">
|
||||
<ElInput
|
||||
:disabled="disabled"
|
||||
:model-value="runParams[item.name]"
|
||||
@update:model-value="(val) => updateParam(item.name, val)"
|
||||
:placeholder="item.formPlaceholder"
|
||||
/>
|
||||
<ChooseResource :attr-name="item.name" @choose="choose" />
|
||||
<ChooseResource
|
||||
:attr-name="item.name"
|
||||
:disabled="disabled"
|
||||
@choose="choose"
|
||||
/>
|
||||
</template>
|
||||
<ElAlert v-if="item.formDescription" type="info" style="margin-top: 5px">
|
||||
{{ item.formDescription }}
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
} from './workflowImageValue';
|
||||
|
||||
const props = defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Object],
|
||||
default: undefined,
|
||||
@@ -33,9 +37,7 @@ const urlInput = ref('');
|
||||
const currentImage = computed(() =>
|
||||
normalizeWorkflowImageValue(props.modelValue),
|
||||
);
|
||||
const previewUrl = computed(() =>
|
||||
getWorkflowImagePreviewUrl(props.modelValue),
|
||||
);
|
||||
const previewUrl = computed(() => getWorkflowImagePreviewUrl(props.modelValue));
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
@@ -47,6 +49,9 @@ watch(
|
||||
);
|
||||
|
||||
function applyUrl() {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
emit('update:modelValue', buildWorkflowImageValueFromUrl(urlInput.value));
|
||||
} catch (error: any) {
|
||||
@@ -55,7 +60,7 @@ function applyUrl() {
|
||||
}
|
||||
|
||||
function triggerSelectFile() {
|
||||
if (!uploadLoading.value) {
|
||||
if (!props.disabled && !uploadLoading.value) {
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
}
|
||||
@@ -63,13 +68,16 @@ function triggerSelectFile() {
|
||||
async function handleNativeFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
if (props.disabled || !file) {
|
||||
return;
|
||||
}
|
||||
uploadLoading.value = true;
|
||||
try {
|
||||
validateWorkflowImageFile(file);
|
||||
const response = await api.upload('/api/v1/commons/upload', { file }, {});
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
'update:modelValue',
|
||||
buildWorkflowImageValueFromUpload(file, response?.data?.path),
|
||||
@@ -84,6 +92,9 @@ async function handleNativeFileChange(event: Event) {
|
||||
}
|
||||
|
||||
function handleChooseResource(resource: any) {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
@@ -95,6 +106,9 @@ function handleChooseResource(resource: any) {
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
urlInput.value = '';
|
||||
emit('update:modelValue', undefined);
|
||||
}
|
||||
@@ -107,6 +121,7 @@ function clearImage() {
|
||||
class="workflow-image-input__native"
|
||||
type="file"
|
||||
:accept="WORKFLOW_IMAGE_LIMITS.accept"
|
||||
:disabled="disabled"
|
||||
@change="handleNativeFileChange"
|
||||
/>
|
||||
|
||||
@@ -143,11 +158,12 @@ function clearImage() {
|
||||
<ElInput
|
||||
v-model="urlInput"
|
||||
clearable
|
||||
:disabled="disabled"
|
||||
placeholder="输入 HTTP/HTTPS 图片 URL"
|
||||
@keyup.enter="applyUrl"
|
||||
>
|
||||
<template #append>
|
||||
<ElButton @click="applyUrl">使用 URL</ElButton>
|
||||
<ElButton :disabled="disabled" @click="applyUrl">使用 URL</ElButton>
|
||||
</template>
|
||||
</ElInput>
|
||||
|
||||
@@ -155,6 +171,7 @@ function clearImage() {
|
||||
<ElButton
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="disabled"
|
||||
:loading="uploadLoading"
|
||||
@click="triggerSelectFile"
|
||||
>
|
||||
@@ -162,10 +179,16 @@ function clearImage() {
|
||||
</ElButton>
|
||||
<ChooseResource
|
||||
attr-name="image"
|
||||
:disabled="disabled"
|
||||
:resource-type="0"
|
||||
@choose="handleChooseResource"
|
||||
/>
|
||||
<ElButton v-if="currentImage" text type="danger" @click="clearImage">
|
||||
<ElButton
|
||||
v-if="currentImage && !disabled"
|
||||
text
|
||||
type="danger"
|
||||
@click="clearImage"
|
||||
>
|
||||
清空
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveWorkflowFormParameters } from '../workflowFormParameters';
|
||||
import {
|
||||
resolveWorkflowFormParameters,
|
||||
resolveWorkflowParameterDisplayName,
|
||||
resolveWorkflowParameterLabel,
|
||||
} from '../workflowFormParameters';
|
||||
|
||||
describe('resolveWorkflowFormParameters', () => {
|
||||
it('uses the image parameter when a legacy schema still declares text', () => {
|
||||
@@ -73,4 +77,46 @@ describe('resolveWorkflowFormParameters', () => {
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildWorkflowFormParameterSummaries,
|
||||
buildWorkflowFormSubmissionImages,
|
||||
buildWorkflowFormSubmissionText,
|
||||
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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,85 @@ const DEFAULT_FIELD_LABELS = new Set([
|
||||
const GENERATED_FIELD_KEY_PATTERN =
|
||||
/^(?: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) {
|
||||
const key = String(field?.key || '').trim();
|
||||
const label = String(field?.label || '').trim();
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import type { ChatImageAttachment } from '@easyflow/common-ui';
|
||||
|
||||
import { resolveWorkflowParameterLabel } from './workflowFormParameters';
|
||||
import {
|
||||
getWorkflowImagePreviewUrl,
|
||||
normalizeWorkflowImageValue,
|
||||
} from './workflowImageValue';
|
||||
|
||||
export interface WorkflowFormParameterSummary {
|
||||
key: string;
|
||||
label: string;
|
||||
ready: boolean;
|
||||
required: boolean;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断附加表单是否存在必填参数。
|
||||
*
|
||||
@@ -44,6 +53,29 @@ export function buildWorkflowFormSubmissionText(
|
||||
.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 表单字段值
|
||||
* @returns 用户可读文本;空值返回空字符串
|
||||
*/
|
||||
function formatWorkflowFormValue(value: any): string {
|
||||
export function formatWorkflowFormValue(value: any): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
actionDisabled?: boolean;
|
||||
actionLabel?: string;
|
||||
message: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-error-notice" role="alert">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -39,4 +56,41 @@ defineProps<{
|
||||
border: 1px solid currentColor;
|
||||
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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ChatArtifactLoader,
|
||||
ChatDocumentLoader,
|
||||
ChatImageLoader,
|
||||
ChatTimelineErrorItem,
|
||||
ChatTimelineItem as ChatTimelineItemType,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineToolApprovalPayload,
|
||||
@@ -23,6 +24,8 @@ const props = defineProps<{
|
||||
documentLoader?: ChatDocumentLoader;
|
||||
emptyText?: string;
|
||||
emptyTitle?: string;
|
||||
errorAction?: (item: ChatTimelineErrorItem) => string | undefined;
|
||||
errorActionDisabled?: boolean;
|
||||
imageLoader?: ChatImageLoader;
|
||||
items: ChatTimelineItemType[];
|
||||
regenerable?: (item: ChatTimelineMessageItem) => boolean;
|
||||
@@ -33,6 +36,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
approve: [payload: ChatTimelineToolApprovalPayload];
|
||||
copyMessage: [item: ChatTimelineMessageItem];
|
||||
errorAction: [item: ChatTimelineErrorItem];
|
||||
regenerateMessage: [item: ChatTimelineMessageItem];
|
||||
reject: [payload: ChatTimelineToolApprovalPayload];
|
||||
selectNextVariant: [item: ChatTimelineMessageItem];
|
||||
@@ -210,6 +214,10 @@ function canRegenerateMessage(item: ChatTimelineItemType) {
|
||||
return item.type === 'message' && (props.regenerable?.(item) ?? false);
|
||||
}
|
||||
|
||||
function errorActionLabel(item: ChatTimelineItemType) {
|
||||
return item.type === 'error' ? props.errorAction?.(item) : undefined;
|
||||
}
|
||||
|
||||
function isAssistantActionAnchor(item: ChatTimelineItemType) {
|
||||
return (
|
||||
item.type === 'message' &&
|
||||
@@ -275,6 +283,8 @@ watch(
|
||||
:copy-action="copyAction"
|
||||
:copyable="copyable"
|
||||
:document-loader="documentLoader"
|
||||
:error-action="errorAction"
|
||||
:error-action-disabled="errorActionDisabled"
|
||||
:image-loader="imageLoader"
|
||||
:items="entry.items"
|
||||
:regenerable="regenerable"
|
||||
@@ -283,6 +293,7 @@ watch(
|
||||
:variant-loading="variantLoading"
|
||||
@approve="emit('approve', $event)"
|
||||
@copy-message="emit('copyMessage', $event)"
|
||||
@error-action="emit('errorAction', $event)"
|
||||
@layout-changed="handleLayoutChanged"
|
||||
@layout-toggle="handleLayoutToggle"
|
||||
@regenerate-message="emit('regenerateMessage', $event)"
|
||||
@@ -306,6 +317,8 @@ watch(
|
||||
:assistant-avatar="assistantAvatar"
|
||||
:item="entry.item"
|
||||
:document-loader="documentLoader"
|
||||
:error-action-disabled="errorActionDisabled"
|
||||
:error-action-label="errorActionLabel(entry.item)"
|
||||
:image-loader="imageLoader"
|
||||
:approval-loading="approvalLoading"
|
||||
:copy-action="copyAction"
|
||||
@@ -315,6 +328,7 @@ watch(
|
||||
:variant-loading="isVariantLoading(entry.item)"
|
||||
@approve="emit('approve', $event)"
|
||||
@copy-message="emit('copyMessage', $event)"
|
||||
@error-action="emit('errorAction', $event)"
|
||||
@regenerate-message="emit('regenerateMessage', $event)"
|
||||
@reject="emit('reject', $event)"
|
||||
@select-next-variant="emit('selectNextVariant', $event)"
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ChatArtifactLoader,
|
||||
ChatDocumentLoader,
|
||||
ChatImageLoader,
|
||||
ChatTimelineErrorItem,
|
||||
ChatTimelineItem,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineMessagePart,
|
||||
@@ -31,6 +32,8 @@ const props = defineProps<{
|
||||
copyable?: boolean;
|
||||
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
|
||||
documentLoader?: ChatDocumentLoader;
|
||||
errorActionDisabled?: boolean;
|
||||
errorActionLabel?: string;
|
||||
imageLoader?: ChatImageLoader;
|
||||
item: ChatTimelineItem;
|
||||
regenerable?: boolean;
|
||||
@@ -41,6 +44,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
approve: [payload: ChatTimelineToolApprovalPayload];
|
||||
copyMessage: [item: ChatTimelineMessageItem];
|
||||
errorAction: [item: ChatTimelineErrorItem];
|
||||
regenerateMessage: [item: ChatTimelineMessageItem];
|
||||
reject: [payload: ChatTimelineToolApprovalPayload];
|
||||
selectNextVariant: [item: ChatTimelineMessageItem];
|
||||
@@ -280,7 +284,10 @@ function handleCopyAction() {
|
||||
</div>
|
||||
<ChatErrorNotice
|
||||
v-else-if="item.type === 'error'"
|
||||
:action-disabled="errorActionDisabled"
|
||||
:action-label="errorActionLabel"
|
||||
:message="item.message"
|
||||
@action="emit('errorAction', item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ChatArtifactLoader,
|
||||
ChatDocumentLoader,
|
||||
ChatImageLoader,
|
||||
ChatTimelineErrorItem,
|
||||
ChatTimelineItem,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineToolApprovalPayload,
|
||||
@@ -21,6 +22,8 @@ const props = defineProps<{
|
||||
copyable?: (item: ChatTimelineMessageItem) => boolean;
|
||||
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
|
||||
documentLoader?: ChatDocumentLoader;
|
||||
errorAction?: (item: ChatTimelineErrorItem) => string | undefined;
|
||||
errorActionDisabled?: boolean;
|
||||
imageLoader?: ChatImageLoader;
|
||||
items: ChatTimelineItem[];
|
||||
regenerable?: (item: ChatTimelineMessageItem) => boolean;
|
||||
@@ -32,6 +35,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
approve: [payload: ChatTimelineToolApprovalPayload];
|
||||
copyMessage: [item: ChatTimelineMessageItem];
|
||||
errorAction: [item: ChatTimelineErrorItem];
|
||||
layoutChanged: [];
|
||||
layoutToggle: [roundId: string];
|
||||
regenerateMessage: [item: ChatTimelineMessageItem];
|
||||
@@ -234,6 +238,10 @@ function canRegenerateMessage(item: ChatTimelineItem) {
|
||||
return item.type === 'message' && (props.regenerable?.(item) ?? false);
|
||||
}
|
||||
|
||||
function errorActionLabel(item: ChatTimelineItem) {
|
||||
return item.type === 'error' ? props.errorAction?.(item) : undefined;
|
||||
}
|
||||
|
||||
function isVariantLoading(item: ChatTimelineItem) {
|
||||
return item.type === 'message' && (props.variantLoading?.(item) ?? false);
|
||||
}
|
||||
@@ -313,6 +321,8 @@ function handleNestedLayoutToggle() {
|
||||
"
|
||||
:item="item"
|
||||
:document-loader="documentLoader"
|
||||
:error-action-disabled="errorActionDisabled"
|
||||
:error-action-label="errorActionLabel(item)"
|
||||
:image-loader="imageLoader"
|
||||
:approval-loading="approvalLoading"
|
||||
:copy-action="copyAction"
|
||||
@@ -322,6 +332,7 @@ function handleNestedLayoutToggle() {
|
||||
:variant-loading="isVariantLoading(item)"
|
||||
@approve="emit('approve', $event)"
|
||||
@copy-message="emit('copyMessage', $event)"
|
||||
@error-action="emit('errorAction', $event)"
|
||||
@regenerate-message="emit('regenerateMessage', $event)"
|
||||
@reject="emit('reject', $event)"
|
||||
@select-next-variant="emit('selectNextVariant', $event)"
|
||||
@@ -338,6 +349,8 @@ function handleNestedLayoutToggle() {
|
||||
:artifact-loader="artifactLoader"
|
||||
:item="finalMessage"
|
||||
:document-loader="documentLoader"
|
||||
:error-action-disabled="errorActionDisabled"
|
||||
:error-action-label="errorActionLabel(finalMessage)"
|
||||
:image-loader="imageLoader"
|
||||
:copy-action="copyAction"
|
||||
:copyable="canCopyMessage(finalMessage)"
|
||||
@@ -345,6 +358,7 @@ function handleNestedLayoutToggle() {
|
||||
:regenerate-disabled="regenerateDisabled"
|
||||
:variant-loading="isVariantLoading(finalMessage)"
|
||||
@copy-message="emit('copyMessage', $event)"
|
||||
@error-action="emit('errorAction', $event)"
|
||||
@regenerate-message="emit('regenerateMessage', $event)"
|
||||
@select-next-variant="emit('selectNextVariant', $event)"
|
||||
@select-previous-variant="emit('selectPreviousVariant', $event)"
|
||||
|
||||
@@ -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 () => {
|
||||
vi.useFakeTimers();
|
||||
const copyAction = vi.fn().mockResolvedValue(true);
|
||||
|
||||
Reference in New Issue
Block a user