发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
6 changed files with 464 additions and 10 deletions
Showing only changes of commit 62d763199f - Show all commits

View File

@@ -101,6 +101,7 @@
"publishPendingHint": "There is already an approval in progress for this workflow.", "publishPendingHint": "There is already an approval in progress for this workflow.",
"deletePendingHint": "There is already an approval in progress for this workflow.", "deletePendingHint": "There is already an approval in progress for this workflow.",
"check": "Check", "check": "Check",
"draftCacheFailed": "Unable to cache the temporary draft. Please save manually",
"checkPassed": "Workflow check passed", "checkPassed": "Workflow check passed",
"checkFailed": "Workflow check failed. Please fix the issues first", "checkFailed": "Workflow check failed. Please fix the issues first",
"checkContentEmpty": "Canvas content is empty, unable to check", "checkContentEmpty": "Canvas content is empty, unable to check",

View File

@@ -101,6 +101,7 @@
"publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。", "publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。", "deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"check": "检查", "check": "检查",
"draftCacheFailed": "临时草稿保存失败,请及时手动保存",
"checkPassed": "工作流检查通过", "checkPassed": "工作流检查通过",
"checkFailed": "工作流检查未通过,请先修复问题", "checkFailed": "工作流检查未通过,请先修复问题",
"checkContentEmpty": "当前画布内容为空,无法检查", "checkContentEmpty": "当前画布内容为空,无法检查",

View File

@@ -1,5 +1,12 @@
<script setup lang="ts"> <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 {useRoute} from 'vue-router';
import {usePreferences} from '@easyflow/preferences'; 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 SingleRun from '#/views/ai/workflow/components/SingleRun.vue';
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue'; import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.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 {getCustomNode} from './customNode/index';
import nodeNames from './customNode/nodeNames'; import nodeNames from './customNode/nodeNames';
@@ -48,12 +61,18 @@ onMounted(async () => {
]); ]);
showTinyFlow.value = true; showTinyFlow.value = true;
}); });
onBeforeUnmount(() => {
captureCurrentWorkflowDraft();
});
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown); document.removeEventListener('keydown', handleKeydown);
if (focusPulseTimer) { if (focusPulseTimer) {
clearTimeout(focusPulseTimer); clearTimeout(focusPulseTimer);
} }
}); });
onDeactivated(() => {
captureCurrentWorkflowDraft();
});
// variables // variables
const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null); const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null);
const workflowId = ref(route.query.id); const workflowId = ref(route.query.id);
@@ -69,6 +88,12 @@ const codeEngineList = ref<any[]>([
available: true, 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) { function escapeHtmlAttr(value?: string) {
return String(value || '') return String(value || '')
@@ -343,13 +368,17 @@ async function handleSave(showMsg: boolean = false): Promise<boolean> {
saveLoading.value = true; saveLoading.value = true;
try { try {
const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData()); const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
const savedContentSignature = createWorkflowContentSignature(content);
const res = await api.post('/api/v1/workflow/update', { const res = await api.post('/api/v1/workflow/update', {
id: workflowId.value, id: workflowId.value,
content, content,
}); });
if (res.errorCode === 0 && showMsg) { if (res.errorCode === 0) {
reconcileWorkflowDraftAfterSave(savedContentSignature);
if (showMsg) {
ElMessage.success(res.message); ElMessage.success(res.message);
} }
}
return res.errorCode === 0; return res.errorCode === 0;
} catch { } catch {
return false; return false;
@@ -364,13 +393,101 @@ async function getWorkflowInfo(workflowId: any, syncFlowData: boolean = true) {
const parsedContent = workflowInfo.value.content const parsedContent = workflowInfo.value.content
? JSON.parse(workflowInfo.value.content) ? JSON.parse(workflowInfo.value.content)
: {}; : {};
tinyFlowData.value = isWorkflowDataEmpty(parsedContent) const serverContent = isWorkflowDataEmpty(parsedContent)
? createInitialWorkflowData() ? createInitialWorkflowData()
: normalizeWorkflowStartNodes(parsedContent); : 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 || ''); 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() { async function getLlmList() {
return api.get('/api/v1/model/list').then((res) => { return api.get('/api/v1/model/list').then((res) => {
llmList.value = res.data; llmList.value = res.data;
@@ -652,12 +769,18 @@ function onAsyncExecute(info: any) {
<ElDrawer <ElDrawer
v-model="singleRunVisible" v-model="singleRunVisible"
:title="singleNode?.data?.title" :title="singleNode?.data?.title"
append-to-body
destroy-on-close destroy-on-close
size="600px" size="min(600px, 94vw)"
> >
<SingleRun :node="singleNode" :workflow-id="workflowId" /> <SingleRun :node="singleNode" :workflow-id="workflowId" />
</ElDrawer> </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> <div class="mb-2.5 font-semibold">{{ $t('aiWorkflow.params') }}</div>
<WorkflowForm <WorkflowForm
ref="workflowForm" ref="workflowForm"
@@ -733,6 +856,7 @@ function onAsyncExecute(info: any) {
:theme="isDark ? 'dark' : 'light'" :theme="isDark ? 'dark' : 'light'"
:provider="provider" :provider="provider"
:custom-nodes="customNode" :custom-nodes="customNode"
:on-data-change="scheduleWorkflowDraft"
:on-node-execute="runIndependently" :on-node-execute="runIndependently"
:on-run-test="runWorkflow" :on-run-test="runWorkflow"
/> />

View File

@@ -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();
});
});

View File

@@ -93,8 +93,13 @@ function submit() {
</script> </script>
<template> <template>
<div> <div class="single-run">
<ElForm label-position="top" ref="singleRunForm" :model="runParams"> <ElForm
class="single-run__form"
label-position="top"
ref="singleRunForm"
:model="runParams"
>
<template v-if="isFieldMode"> <template v-if="isFieldMode">
<div class="single-run-section"> <div class="single-run-section">
<div class="single-run-section__title">字段值</div> <div class="single-run-section__title">字段值</div>
@@ -176,12 +181,45 @@ function submit() {
</ElButton> </ElButton>
</ElFormItem> </ElFormItem>
</ElForm> </ElForm>
<div class="mb-2.5 mt-2.5 font-semibold">{{ $t('workflow.result') }}</div> <section class="single-run-result">
<ShowJson :value="result" /> <div class="single-run-result__title">{{ $t('workflow.result') }}</div>
<ShowJson class="single-run-result__viewer" :value="result" />
</section>
</div> </div>
</template> </template>
<style scoped> <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 { .single-run-section + .single-run-section {
margin-top: 20px; margin-top: 20px;
} }

View File

@@ -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));
}