feat: 增加工作流编排临时草稿缓存
- 按当前标签页缓存并静默恢复未保存的工作流编排 - 优化试运行抽屉层级、窄屏宽度与长结果滚动 - 覆盖版本变化、缓存异常与保存竞态场景
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, onUnmounted, ref} from 'vue';
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
} from 'vue';
|
||||
import {useRoute} from 'vue-router';
|
||||
|
||||
import {usePreferences} from '@easyflow/preferences';
|
||||
@@ -23,6 +30,12 @@ import ExecResult from '#/views/ai/workflow/components/ExecResult.vue';
|
||||
import SingleRun from '#/views/ai/workflow/components/SingleRun.vue';
|
||||
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
|
||||
import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue';
|
||||
import {
|
||||
clearWorkflowDraft,
|
||||
createWorkflowContentSignature,
|
||||
readWorkflowDraft,
|
||||
writeWorkflowDraft,
|
||||
} from '#/views/ai/workflow/workflowDraftCache';
|
||||
|
||||
import {getCustomNode} from './customNode/index';
|
||||
import nodeNames from './customNode/nodeNames';
|
||||
@@ -48,12 +61,18 @@ onMounted(async () => {
|
||||
]);
|
||||
showTinyFlow.value = true;
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
captureCurrentWorkflowDraft();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
if (focusPulseTimer) {
|
||||
clearTimeout(focusPulseTimer);
|
||||
}
|
||||
});
|
||||
onDeactivated(() => {
|
||||
captureCurrentWorkflowDraft();
|
||||
});
|
||||
// variables
|
||||
const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null);
|
||||
const workflowId = ref(route.query.id);
|
||||
@@ -69,6 +88,12 @@ const codeEngineList = ref<any[]>([
|
||||
available: true,
|
||||
},
|
||||
]);
|
||||
const WORKFLOW_DRAFT_WRITE_DELAY = 320;
|
||||
let draftWriteTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pendingDraftContent: any = null;
|
||||
let lastObservedWorkflowContent: any = null;
|
||||
let serverContentSignature = '';
|
||||
let draftCacheWarningShown = false;
|
||||
|
||||
function escapeHtmlAttr(value?: string) {
|
||||
return String(value || '')
|
||||
@@ -343,12 +368,16 @@ async function handleSave(showMsg: boolean = false): Promise<boolean> {
|
||||
saveLoading.value = true;
|
||||
try {
|
||||
const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
|
||||
const savedContentSignature = createWorkflowContentSignature(content);
|
||||
const res = await api.post('/api/v1/workflow/update', {
|
||||
id: workflowId.value,
|
||||
content,
|
||||
});
|
||||
if (res.errorCode === 0 && showMsg) {
|
||||
ElMessage.success(res.message);
|
||||
if (res.errorCode === 0) {
|
||||
reconcileWorkflowDraftAfterSave(savedContentSignature);
|
||||
if (showMsg) {
|
||||
ElMessage.success(res.message);
|
||||
}
|
||||
}
|
||||
return res.errorCode === 0;
|
||||
} catch {
|
||||
@@ -364,13 +393,101 @@ async function getWorkflowInfo(workflowId: any, syncFlowData: boolean = true) {
|
||||
const parsedContent = workflowInfo.value.content
|
||||
? JSON.parse(workflowInfo.value.content)
|
||||
: {};
|
||||
tinyFlowData.value = isWorkflowDataEmpty(parsedContent)
|
||||
const serverContent = isWorkflowDataEmpty(parsedContent)
|
||||
? createInitialWorkflowData()
|
||||
: normalizeWorkflowStartNodes(parsedContent);
|
||||
serverContentSignature = createWorkflowContentSignature(serverContent);
|
||||
const draft = readWorkflowDraft(workflowId, serverContent);
|
||||
tinyFlowData.value = draft
|
||||
? normalizeWorkflowStartNodes(draft.content as Record<string, any>)
|
||||
: serverContent;
|
||||
lastObservedWorkflowContent = tinyFlowData.value;
|
||||
}
|
||||
syncNavTitle(workflowInfo.value?.title || '');
|
||||
});
|
||||
}
|
||||
|
||||
function persistPendingWorkflowDraft() {
|
||||
if (!pendingDraftContent || !workflowId.value || !serverContentSignature) {
|
||||
draftWriteTimer = undefined;
|
||||
return;
|
||||
}
|
||||
const content = pendingDraftContent;
|
||||
pendingDraftContent = null;
|
||||
draftWriteTimer = undefined;
|
||||
const persisted = writeWorkflowDraft({
|
||||
baseContentSignature: serverContentSignature,
|
||||
content,
|
||||
workflowId: workflowId.value,
|
||||
});
|
||||
if (!persisted && !draftCacheWarningShown) {
|
||||
draftCacheWarningShown = true;
|
||||
ElMessage.warning($t('aiWorkflow.draftCacheFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleWorkflowDraft(content: any) {
|
||||
lastObservedWorkflowContent = normalizeWorkflowStartNodes(content);
|
||||
pendingDraftContent = lastObservedWorkflowContent;
|
||||
if (draftWriteTimer) {
|
||||
clearTimeout(draftWriteTimer);
|
||||
}
|
||||
draftWriteTimer = setTimeout(
|
||||
persistPendingWorkflowDraft,
|
||||
WORKFLOW_DRAFT_WRITE_DELAY,
|
||||
);
|
||||
}
|
||||
|
||||
function flushWorkflowDraft() {
|
||||
if (draftWriteTimer) {
|
||||
clearTimeout(draftWriteTimer);
|
||||
}
|
||||
if (pendingDraftContent) {
|
||||
persistPendingWorkflowDraft();
|
||||
}
|
||||
}
|
||||
|
||||
function captureCurrentWorkflowDraft() {
|
||||
const content = tinyflowRef.value?.getData();
|
||||
if (content) {
|
||||
lastObservedWorkflowContent = normalizeWorkflowStartNodes(content);
|
||||
pendingDraftContent = lastObservedWorkflowContent;
|
||||
}
|
||||
flushWorkflowDraft();
|
||||
}
|
||||
|
||||
function clearPendingWorkflowDraft() {
|
||||
if (draftWriteTimer) {
|
||||
clearTimeout(draftWriteTimer);
|
||||
}
|
||||
draftWriteTimer = undefined;
|
||||
pendingDraftContent = null;
|
||||
clearWorkflowDraft(workflowId.value);
|
||||
}
|
||||
|
||||
function reconcileWorkflowDraftAfterSave(savedContentSignature: string) {
|
||||
serverContentSignature = savedContentSignature;
|
||||
const currentContent =
|
||||
tinyflowRef.value?.getData() || lastObservedWorkflowContent;
|
||||
if (!currentContent) {
|
||||
clearPendingWorkflowDraft();
|
||||
return;
|
||||
}
|
||||
const normalizedContent = normalizeWorkflowStartNodes(currentContent);
|
||||
lastObservedWorkflowContent = normalizedContent;
|
||||
if (
|
||||
createWorkflowContentSignature(normalizedContent) === savedContentSignature
|
||||
) {
|
||||
clearPendingWorkflowDraft();
|
||||
return;
|
||||
}
|
||||
if (draftWriteTimer) {
|
||||
clearTimeout(draftWriteTimer);
|
||||
}
|
||||
draftWriteTimer = undefined;
|
||||
pendingDraftContent = normalizedContent;
|
||||
persistPendingWorkflowDraft();
|
||||
}
|
||||
async function getLlmList() {
|
||||
return api.get('/api/v1/model/list').then((res) => {
|
||||
llmList.value = res.data;
|
||||
@@ -652,12 +769,18 @@ function onAsyncExecute(info: any) {
|
||||
<ElDrawer
|
||||
v-model="singleRunVisible"
|
||||
:title="singleNode?.data?.title"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
size="600px"
|
||||
size="min(600px, 94vw)"
|
||||
>
|
||||
<SingleRun :node="singleNode" :workflow-id="workflowId" />
|
||||
</ElDrawer>
|
||||
<ElDrawer v-model="drawerVisible" :title="$t('button.run')" size="600px">
|
||||
<ElDrawer
|
||||
v-model="drawerVisible"
|
||||
:title="$t('button.run')"
|
||||
append-to-body
|
||||
size="min(600px, 94vw)"
|
||||
>
|
||||
<div class="mb-2.5 font-semibold">{{ $t('aiWorkflow.params') }}:</div>
|
||||
<WorkflowForm
|
||||
ref="workflowForm"
|
||||
@@ -733,6 +856,7 @@ function onAsyncExecute(info: any) {
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
:provider="provider"
|
||||
:custom-nodes="customNode"
|
||||
:on-data-change="scheduleWorkflowDraft"
|
||||
:on-node-execute="runIndependently"
|
||||
:on-run-test="runWorkflow"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
clearWorkflowDraft,
|
||||
createWorkflowContentSignature,
|
||||
readWorkflowDraft,
|
||||
writeWorkflowDraft,
|
||||
} from '../workflowDraftCache';
|
||||
|
||||
const workflowId = 'workflow-1';
|
||||
const serverContent = {
|
||||
edges: [],
|
||||
nodes: [{ id: 'start' }],
|
||||
};
|
||||
const draftContent = {
|
||||
edges: [],
|
||||
nodes: [{ id: 'start' }, { id: 'llm' }],
|
||||
};
|
||||
|
||||
describe('workflowDraftCache', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('按工作流保存并恢复当前浏览器标签页中的草稿', () => {
|
||||
expect(
|
||||
writeWorkflowDraft({
|
||||
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||
content: draftContent,
|
||||
workflowId,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(readWorkflowDraft(workflowId, serverContent)?.content).toEqual(
|
||||
draftContent,
|
||||
);
|
||||
expect(readWorkflowDraft('workflow-2', serverContent)).toBeNull();
|
||||
});
|
||||
|
||||
it('内容回到服务端版本时自动移除草稿', () => {
|
||||
writeWorkflowDraft({
|
||||
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||
content: draftContent,
|
||||
workflowId,
|
||||
});
|
||||
|
||||
expect(
|
||||
writeWorkflowDraft({
|
||||
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||
content: serverContent,
|
||||
workflowId,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(readWorkflowDraft(workflowId, serverContent)).toBeNull();
|
||||
});
|
||||
|
||||
it('服务端内容已更新时丢弃旧草稿,避免覆盖新版本', () => {
|
||||
writeWorkflowDraft({
|
||||
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||
content: draftContent,
|
||||
workflowId,
|
||||
});
|
||||
|
||||
const updatedServerContent = {
|
||||
edges: [{ id: 'edge-1' }],
|
||||
nodes: [{ id: 'start' }, { id: 'end' }],
|
||||
};
|
||||
expect(readWorkflowDraft(workflowId, updatedServerContent)).toBeNull();
|
||||
expect(readWorkflowDraft(workflowId, serverContent)).toBeNull();
|
||||
});
|
||||
|
||||
it('保存期间产生的新编辑可基于已保存版本继续缓存', () => {
|
||||
const newerDraftContent = {
|
||||
edges: [{ id: 'edge-1' }],
|
||||
nodes: [...draftContent.nodes, { id: 'end' }],
|
||||
};
|
||||
|
||||
writeWorkflowDraft({
|
||||
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||
content: draftContent,
|
||||
workflowId,
|
||||
});
|
||||
expect(
|
||||
writeWorkflowDraft({
|
||||
baseContentSignature: createWorkflowContentSignature(draftContent),
|
||||
content: newerDraftContent,
|
||||
workflowId,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(readWorkflowDraft(workflowId, draftContent)?.content).toEqual(
|
||||
newerDraftContent,
|
||||
);
|
||||
});
|
||||
|
||||
it('缓存不可写时返回失败且不抛出异常', () => {
|
||||
const storage = {
|
||||
getItem: vi.fn(() => null),
|
||||
removeItem: vi.fn(),
|
||||
setItem: vi.fn(() => {
|
||||
throw new Error('quota exceeded');
|
||||
}),
|
||||
};
|
||||
|
||||
expect(
|
||||
writeWorkflowDraft(
|
||||
{
|
||||
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||
content: draftContent,
|
||||
workflowId,
|
||||
},
|
||||
storage,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('忽略结构异常的缓存内容', () => {
|
||||
sessionStorage.setItem(
|
||||
`easyflow:workflow-draft:${workflowId}`,
|
||||
JSON.stringify({
|
||||
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||
content: 'invalid',
|
||||
updatedAt: Date.now(),
|
||||
version: 1,
|
||||
workflowId,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readWorkflowDraft(workflowId, serverContent)).toBeNull();
|
||||
});
|
||||
|
||||
it('可显式清理已保存草稿', () => {
|
||||
writeWorkflowDraft({
|
||||
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||
content: draftContent,
|
||||
workflowId,
|
||||
});
|
||||
|
||||
clearWorkflowDraft(workflowId);
|
||||
|
||||
expect(readWorkflowDraft(workflowId, serverContent)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -93,8 +93,13 @@ function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ElForm label-position="top" ref="singleRunForm" :model="runParams">
|
||||
<div class="single-run">
|
||||
<ElForm
|
||||
class="single-run__form"
|
||||
label-position="top"
|
||||
ref="singleRunForm"
|
||||
:model="runParams"
|
||||
>
|
||||
<template v-if="isFieldMode">
|
||||
<div class="single-run-section">
|
||||
<div class="single-run-section__title">字段值</div>
|
||||
@@ -176,12 +181,45 @@ function submit() {
|
||||
</ElButton>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<div class="mb-2.5 mt-2.5 font-semibold">{{ $t('workflow.result') }}:</div>
|
||||
<ShowJson :value="result" />
|
||||
<section class="single-run-result">
|
||||
<div class="single-run-result__title">{{ $t('workflow.result') }}:</div>
|
||||
<ShowJson class="single-run-result__viewer" :value="result" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.single-run {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.single-run__form {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.single-run-result {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.single-run-result__title {
|
||||
margin: 10px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.single-run-result__viewer {
|
||||
max-width: 100%;
|
||||
max-height: min(60vh, 720px);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.single-run-section + .single-run-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
type WorkflowDraftStorage = Pick<Storage, 'getItem' | 'removeItem' | 'setItem'>;
|
||||
|
||||
interface WorkflowDraftSnapshot {
|
||||
baseContentSignature: string;
|
||||
content: Record<string, unknown>;
|
||||
updatedAt: number;
|
||||
version: 1;
|
||||
workflowId: string;
|
||||
}
|
||||
|
||||
interface WriteWorkflowDraftOptions {
|
||||
baseContentSignature: string;
|
||||
content: unknown;
|
||||
workflowId: unknown;
|
||||
}
|
||||
|
||||
const WORKFLOW_DRAFT_PREFIX = 'easyflow:workflow-draft';
|
||||
const WORKFLOW_DRAFT_VERSION = 1;
|
||||
|
||||
function isWorkflowContent(
|
||||
content: unknown,
|
||||
): content is Record<string, unknown> {
|
||||
return (
|
||||
typeof content === 'object' && content !== null && !Array.isArray(content)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSessionStorage(): null | WorkflowDraftStorage {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getStorageKey(workflowId: unknown) {
|
||||
const normalizedId = String(workflowId ?? '').trim();
|
||||
return normalizedId ? `${WORKFLOW_DRAFT_PREFIX}:${normalizedId}` : '';
|
||||
}
|
||||
|
||||
function removeStoredDraft(
|
||||
storage: null | WorkflowDraftStorage,
|
||||
storageKey: string,
|
||||
) {
|
||||
if (!storage || !storageKey) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
storage.removeItem(storageKey);
|
||||
} catch {
|
||||
// 临时缓存清理失败不应阻断工作流编辑。
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorkflowContentSignature(content: unknown) {
|
||||
try {
|
||||
return JSON.stringify(content ?? null);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function readWorkflowDraft(
|
||||
workflowId: unknown,
|
||||
serverContent: unknown,
|
||||
storage: null | WorkflowDraftStorage = resolveSessionStorage(),
|
||||
) {
|
||||
const storageKey = getStorageKey(workflowId);
|
||||
if (!storage || !storageKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = storage.getItem(storageKey);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const snapshot = JSON.parse(raw) as Partial<WorkflowDraftSnapshot>;
|
||||
const serverContentSignature =
|
||||
createWorkflowContentSignature(serverContent);
|
||||
const draftContentSignature = createWorkflowContentSignature(
|
||||
snapshot.content,
|
||||
);
|
||||
const isValid =
|
||||
snapshot.version === WORKFLOW_DRAFT_VERSION &&
|
||||
snapshot.workflowId === String(workflowId) &&
|
||||
snapshot.baseContentSignature === serverContentSignature &&
|
||||
isWorkflowContent(snapshot.content) &&
|
||||
Boolean(draftContentSignature);
|
||||
|
||||
if (!isValid || draftContentSignature === serverContentSignature) {
|
||||
removeStoredDraft(storage, storageKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return snapshot as WorkflowDraftSnapshot;
|
||||
} catch {
|
||||
removeStoredDraft(storage, storageKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeWorkflowDraft(
|
||||
options: WriteWorkflowDraftOptions,
|
||||
storage: null | WorkflowDraftStorage = resolveSessionStorage(),
|
||||
) {
|
||||
const storageKey = getStorageKey(options.workflowId);
|
||||
const contentSignature = createWorkflowContentSignature(options.content);
|
||||
if (
|
||||
!storage ||
|
||||
!storageKey ||
|
||||
!contentSignature ||
|
||||
!options.baseContentSignature ||
|
||||
!isWorkflowContent(options.content)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (contentSignature === options.baseContentSignature) {
|
||||
removeStoredDraft(storage, storageKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
const snapshot: WorkflowDraftSnapshot = {
|
||||
baseContentSignature: options.baseContentSignature,
|
||||
content: options.content,
|
||||
updatedAt: Date.now(),
|
||||
version: WORKFLOW_DRAFT_VERSION,
|
||||
workflowId: String(options.workflowId),
|
||||
};
|
||||
try {
|
||||
storage.setItem(storageKey, JSON.stringify(snapshot));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWorkflowDraft(
|
||||
workflowId: unknown,
|
||||
storage: null | WorkflowDraftStorage = resolveSessionStorage(),
|
||||
) {
|
||||
removeStoredDraft(storage, getStorageKey(workflowId));
|
||||
}
|
||||
Reference in New Issue
Block a user