feat: 支持工作流对话匿名分享

- 增加免登录公共接口、访客隔离、限流和匿名上传校验

- 分离 SSE 连接与运行生命周期,支持刷新恢复服务端权威状态

- 持久化分享页对话并优化时间线滚动与输入区交互
This commit is contained in:
2026-08-31 16:45:21 +08:00
parent 6daf805cd0
commit 263f5f4b8b
46 changed files with 3742 additions and 180 deletions

View File

@@ -21,12 +21,11 @@ import { events } from 'fetch-event-stream';
import { useAuthStore } from '#/store';
import {
isWorkflowShareRequest,
readWorkflowShareKey,
withWorkflowShareHeader,
WORKFLOW_SHARE_HEADER,
withWorkflowShareHeaders,
} from '#/utils/workflow-share-context';
import { refreshTokenApi } from './core';
import { isInactiveSseRequest } from './sseRequestLifecycle';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const ERROR_MESSAGE_DEDUP_WINDOW = 800;
@@ -103,12 +102,15 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
config.headers.Accept = 'application/json';
config.headers['easyflow-token'] = formatToken(accessStore.accessToken);
config.headers['Accept-Language'] = preferences.app.locale;
const workflowShareKey = readWorkflowShareKey();
if (
workflowShareKey &&
isWorkflowShareRequest(config.url, config.method)
) {
config.headers[WORKFLOW_SHARE_HEADER] = workflowShareKey;
const workflowShareHeaders = withWorkflowShareHeaders(
{},
{
requestMethod: config.method,
requestUrl: config.url,
},
);
for (const [name, value] of Object.entries(workflowShareHeaders)) {
config.headers[name] = value;
}
return config;
},
@@ -134,6 +136,8 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
doRefreshToken,
enableRefreshToken: preferences.app.enableRefreshToken ?? false,
formatToken,
shouldHandleUnauthorized: (config) =>
!isWorkflowShareRequest(config?.url, config?.method),
}),
);
@@ -188,7 +192,7 @@ export function createEventStreamHeaders(
headers[key] = value;
});
}
return withWorkflowShareHeader(headers, {
return withWorkflowShareHeaders(headers, {
requestMethod: 'POST',
requestUrl,
});
@@ -276,15 +280,25 @@ export class SseClient {
options?.onMessage?.(event);
}
} catch (innerError) {
if (
isInactiveSseRequest(signal, this.currentRequestId, currentRequestId)
) {
return;
}
options?.onError?.(innerError);
return;
}
// 只有在还是同一个请求的情况下才调用 onFinished
if (this.currentRequestId === currentRequestId) {
if (
!isInactiveSseRequest(signal, this.currentRequestId, currentRequestId)
) {
options?.onFinished?.();
}
} catch (error) {
if (this.currentRequestId !== currentRequestId) {
if (
isInactiveSseRequest(signal, this.currentRequestId, currentRequestId)
) {
return;
}
console.error('SSE错误:', error);

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { isInactiveSseRequest } from './sseRequestLifecycle';
describe('sseRequestLifecycle', () => {
it('treats an explicit abort as an inactive request', () => {
const controller = new AbortController();
controller.abort();
expect(isInactiveSseRequest(controller.signal, 1, 1)).toBe(true);
});
it('treats a superseded request as inactive', () => {
const controller = new AbortController();
expect(isInactiveSseRequest(controller.signal, 2, 1)).toBe(true);
expect(isInactiveSseRequest(controller.signal, 1, 1)).toBe(false);
});
});

View File

@@ -0,0 +1,10 @@
/**
* 判断 SSE 请求是否已被主动中止或被后续请求替换。
*/
export function isInactiveSseRequest(
signal: AbortSignal,
currentRequestId: number,
requestId: number,
) {
return signal.aborted || currentRequestId !== requestId;
}

View File

@@ -0,0 +1,42 @@
import { createMemoryHistory, createRouter } from 'vue-router';
import { useAccessStore } from '@easyflow/stores';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it } from 'vitest';
import { createRouterGuard } from '../guard';
describe('public route guard', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('bypasses stale login state for an anonymous workflow share', async () => {
const accessStore = useAccessStore();
accessStore.setAccessToken('stale-token');
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
component: { template: '<div>login</div>' },
name: 'Login',
path: '/auth/login',
},
{
component: { template: '<div>workflow share</div>' },
meta: { ignoreAccess: true, title: 'Workflow Share' },
name: 'WorkflowShare',
path: '/share/workflow',
},
],
});
createRouterGuard(router);
await router.push('/share/workflow?shareKey=share-key');
await router.isReady();
expect(router.currentRoute.value.name).toBe('WorkflowShare');
expect(router.currentRoute.value.query.shareKey).toBe('share-key');
});
});

View File

@@ -11,6 +11,7 @@ describe('external share routes', () => {
hideInBreadcrumb: true,
hideInMenu: true,
hideInTab: true,
ignoreAccess: true,
noBasicLayout: true,
});
});
@@ -19,6 +20,7 @@ describe('external share routes', () => {
const route = routes.find((item) => item.name === 'WorkflowShareExpired');
expect(route?.path).toBe('/share/workflow/expired');
expect(route?.meta?.ignoreAccess).toBe(true);
expect(route?.meta?.noBasicLayout).toBe(true);
});
});

View File

@@ -152,6 +152,12 @@ function setupAccessGuard(router: Router) {
let devLoginPromise: null | Promise<void> = null;
router.beforeEach(async (to, from) => {
// 公开路由必须在读取或刷新登录态之前短路,避免浏览器残留的过期
// token 把匿名分享页重定向到登录页。
if (to.meta.ignoreAccess) {
return true;
}
const accessStore = useAccessStore();
const userStore = useUserStore();
const authStore = useAuthStore();
@@ -227,11 +233,6 @@ function setupAccessGuard(router: Router) {
// accessToken 检查
if (!accessStore.accessToken) {
// 明确声明忽略权限访问权限,则可以访问
if (to.meta.ignoreAccess) {
return true;
}
// 没有访问权限,跳转登录页面
if (to.fullPath !== LOGIN_PATH) {
const cleanFullPath =

View File

@@ -33,6 +33,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/ai/workflow/WorkflowShareView.vue'),
meta: {
title: 'Workflow Share',
ignoreAccess: true,
noBasicLayout: true,
hideInMenu: true,
hideInBreadcrumb: true,
@@ -46,6 +47,7 @@ const routes: RouteRecordRaw[] = [
import('#/views/ai/documentCollection/KnowledgeShareExpired.vue'),
meta: {
title: 'Workflow Share Expired',
ignoreAccess: true,
noBasicLayout: true,
hideInMenu: true,
hideInBreadcrumb: true,

View File

@@ -5,6 +5,14 @@ import { readScopedRouteQueryParam } from './share-route-context';
*/
export const WORKFLOW_SHARE_HEADER = 'X-Workflow-Chat-Share-Key';
/**
* 当前标签页的工作流对话分享访客标识请求头。
*/
export const WORKFLOW_SHARE_VISITOR_HEADER = 'X-Workflow-Chat-Visitor';
const WORKFLOW_SHARE_VISITOR_STORAGE_KEY =
'easyflow.workflow-chat-share.visitor';
interface WorkflowShareResolutionOptions<T> {
currentWorkflowId?: null | T;
onFailure: (error: unknown) => Promise<void> | void;
@@ -16,16 +24,19 @@ interface WorkflowShareHeaderOptions {
pageUrl?: string;
requestMethod?: string;
requestUrl?: string;
storage?: Pick<Storage, 'getItem' | 'setItem'>;
visitorId?: string;
}
const WORKFLOW_SHARE_ROUTES = ['/share/workflow'];
const WORKFLOW_SHARE_REQUESTS = [
['GET', '/api/v1/workflowChat/descriptor'],
['GET', '/api/v1/workflowChat/execution'],
['GET', '/api/v1/workflowChat/public/descriptor'],
['GET', '/api/v1/workflowChat/public/execution'],
['GET', '/api/v1/workflowShare/resolve'],
['POST', '/api/v1/workflowChat/cancel'],
['POST', '/api/v1/workflowChat/resume'],
['POST', '/api/v1/workflowChat/run'],
['POST', '/api/v1/workflowChat/public/cancel'],
['POST', '/api/v1/workflowChat/public/resume'],
['POST', '/api/v1/workflowChat/public/run'],
['POST', '/api/v1/workflowChat/public/upload'],
] as const;
/**
@@ -91,12 +102,42 @@ export function isWorkflowShareRequest(
}
}
/**
* 读取或创建当前标签页稳定的 128-bit 匿名访客标识。
*/
export function resolveWorkflowShareVisitorId(
storage:
| Pick<Storage, 'getItem' | 'setItem'>
| undefined = resolveSessionStorage(),
randomBytes: (size: number) => Uint8Array = createRandomBytes,
): string {
const existing = storage?.getItem(WORKFLOW_SHARE_VISITOR_STORAGE_KEY)?.trim();
if (existing && /^[a-f0-9]{32}$/.test(existing)) {
return existing;
}
const visitorId = [...randomBytes(16)]
.map((value) => value.toString(16).padStart(2, '0'))
.join('');
storage?.setItem(WORKFLOW_SHARE_VISITOR_STORAGE_KEY, visitorId);
return visitorId;
}
/**
* 在保留现有请求头的基础上附加工作流分享密钥。
*/
export function withWorkflowShareHeader(
headers: Record<string, string>,
options: WorkflowShareHeaderOptions = {},
): Record<string, string> {
return withWorkflowShareHeaders(headers, options);
}
/**
* 在保留通用请求头的基础上附加匿名分享密钥与当前标签页访客标识。
*/
export function withWorkflowShareHeaders(
headers: Record<string, string>,
options: WorkflowShareHeaderOptions = {},
): Record<string, string> {
if (!isWorkflowShareRequest(options.requestUrl, options.requestMethod)) {
return headers;
@@ -105,12 +146,32 @@ export function withWorkflowShareHeader(
if (!shareKey) {
return headers;
}
const visitorId =
options.visitorId || resolveWorkflowShareVisitorId(options.storage);
return {
...headers,
'easyflow-token': '',
[WORKFLOW_SHARE_HEADER]: shareKey,
[WORKFLOW_SHARE_VISITOR_HEADER]: visitorId,
};
}
function resolveSessionStorage() {
try {
return globalThis.sessionStorage;
} catch {
return undefined;
}
}
function createRandomBytes(size: number) {
const bytes = new Uint8Array(size);
if (!globalThis.crypto?.getRandomValues) {
throw new Error('当前浏览器不支持安全的匿名访客标识');
}
return globalThis.crypto.getRandomValues(bytes);
}
/**
* 解析分享地址对应的工作流,并在链接失效时统一收口异常。
*/

View File

@@ -57,7 +57,11 @@ import { navigateBackToList } from '#/router/list-return-context';
import { resolveAgentChatIdentity } from '#/utils/agent-chat-cache';
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
import { resolveWorkflowShareFailureReason } from '#/utils/workflow-share-context';
import {
readWorkflowShareKey,
resolveWorkflowShareFailureReason,
resolveWorkflowShareVisitorId,
} from '#/utils/workflow-share-context';
import {
finalizeWorkflowExecutionSteps,
@@ -65,6 +69,10 @@ import {
hydrateWorkflowExecutionSteps,
reduceWorkflowExecutionSteps,
} from './workflowExecutionDetails';
import {
resolveWorkflowExecutionRecoveryOutput,
resolveWorkflowExecutionRecoveryStatus,
} from './workflowExecutionRecovery';
import WorkflowFinalOutput from './WorkflowFinalOutput.vue';
import WorkflowFormItem from './WorkflowFormItem.vue';
import {
@@ -88,6 +96,14 @@ import {
formatWorkflowProgressLabel,
summarizeWorkflowActiveNodes,
} from './workflowRunProgress';
import {
buildWorkflowShareConversationKey,
buildWorkflowShareStorageScope,
getWorkflowShareConversationStorage,
readWorkflowShareConversation,
removeWorkflowShareConversation,
writeWorkflowShareConversation,
} from './workflowShareConversationStorage';
interface WorkflowStreamEnvelope {
data?: Record<string, any>;
@@ -97,6 +113,10 @@ interface WorkflowStreamEnvelope {
type: string;
}
interface ChatTimelineHandle {
scrollToBottom: () => void;
}
const props = withDefaults(
defineProps<{
shareMode?: boolean;
@@ -109,14 +129,21 @@ const props = withDefaults(
const MAX_COLLAPSED_PARAMETER_COUNT = 4;
const PRIMARY_PARAMETER_COUNT = 4;
const DRAFT_SAVE_DELAY_MS = 300;
const CONVERSATION_SAVE_DELAY_MS = 300;
const EXECUTION_RECOVERY_POLL_MS = 1500;
const route = useRoute();
const userStore = useUserStore();
const streamClient = new SseClient();
const shareStorageScope = resolveShareStorageScope();
const loading = ref(true);
const loadError = ref('');
const descriptor = ref<Record<string, any>>({});
const workflowId = ref<string>();
const timelineItems = ref<ChatTimelineItem[]>([]);
const timelineRef = ref<ChatTimelineHandle>();
const timelinePinnedToBottom = ref(true);
const composerRef = ref<HTMLElement>();
const composerHeight = ref(152);
const question = ref('');
const running = ref(false);
const stopping = ref(false);
@@ -153,7 +180,12 @@ const manualAbort = ref(false);
const lastRunningNodeName = ref('');
let progressStatusTimer = 0;
let draftSaveTimer = 0;
let conversationSaveTimer = 0;
let executionRecoveryTimer = 0;
let executionRecoveryActive = false;
let executionRecoveryLoading = false;
let draftReady = false;
let conversationReady = false;
let userMessageSequence = 0;
const formParameters = computed(() =>
@@ -289,22 +321,46 @@ const detailDurationText = computed(() => {
: persistedDuration;
return duration === undefined ? '—' : `${duration} ms`;
});
const timelineStyle = computed<Record<string, string>>(() => ({
'--workflow-chat-composer-height': `${composerHeight.value}px`,
}));
useResizeObserver(parameterChipsRef, ([entry]) => {
parameterChipsWidth.value = entry?.contentRect.width || 0;
});
useResizeObserver(composerRef, () => {
const nextHeight = composerRef.value?.getBoundingClientRect().height || 0;
if (nextHeight > 0) {
composerHeight.value = Math.ceil(nextHeight);
}
});
watch([question, extraValues], scheduleDraftSave, { deep: true });
watch(
[
timelineItems,
executeId,
runStatusKey,
parametersLocked,
executionState,
executionStartedAt,
executionElapsed,
],
scheduleConversationSave,
{ deep: true },
);
onMounted(() => {
window.addEventListener('pagehide', persistDraft);
window.addEventListener('pagehide', handlePageHide);
void loadPage();
});
onBeforeUnmount(() => {
window.removeEventListener('pagehide', persistDraft);
window.removeEventListener('pagehide', handlePageHide);
clearDraftSaveTimer();
persistDraft();
clearConversationSaveTimer();
clearExecutionRecoveryTimer();
persistPageState();
manualAbort.value = true;
streamClient.abort();
clearProgressStatusTimer();
@@ -312,6 +368,7 @@ onBeforeUnmount(() => {
async function loadPage() {
draftReady = false;
conversationReady = false;
loading.value = true;
loadError.value = '';
try {
@@ -321,14 +378,23 @@ async function loadPage() {
if (!workflowId.value) {
throw new Error('工作流不存在');
}
const response = await api.get('/api/v1/workflowChat/descriptor', {
const response = await api.get(workflowChatEndpoint('descriptor'), {
params: { workflowId: workflowId.value },
});
descriptor.value = response.data || {};
initializeAdditionalValues();
restoreDraft();
restoreShareConversation();
await nextTick();
draftReady = true;
conversationReady = true;
if (props.shareMode && executeId.value) {
if (executionRecoveryActive) {
void recoverExecutionAfterRefresh();
} else {
void loadExecutionDetail();
}
}
} catch (error: any) {
loadError.value = error?.message || '工作流加载失败';
} finally {
@@ -370,14 +436,29 @@ function draftKey() {
}
return buildWorkflowRunDraftKey(
workflowId.value,
resolveAgentChatIdentity(userStore.userInfo),
props.shareMode
? shareStorageScope
: resolveAgentChatIdentity(userStore.userInfo),
props.shareMode,
);
}
function shareConversationKey() {
return buildWorkflowShareConversationKey(
workflowId.value || '',
shareStorageScope,
);
}
function workflowChatEndpoint(action: string) {
return props.shareMode
? `/api/v1/workflowChat/public/${action}`
: `/api/v1/workflowChat/${action}`;
}
function restoreDraft() {
const draft = readWorkflowRunDraft(
getWorkflowRunDraftStorage(),
getWorkflowRunDraftStorage(props.shareMode),
draftKey(),
additionalParameters.value,
);
@@ -419,7 +500,7 @@ function persistDraft() {
if (!draftReady) {
return;
}
const storage = getWorkflowRunDraftStorage();
const storage = getWorkflowRunDraftStorage(props.shareMode);
const key = draftKey();
if (
!hasWorkflowRunDraftContent(
@@ -437,6 +518,99 @@ function persistDraft() {
});
}
function resolveShareStorageScope() {
if (!props.shareMode) {
return '';
}
try {
return buildWorkflowShareStorageScope(
resolveWorkflowShareVisitorId(),
readWorkflowShareKey() || '',
);
} catch {
return '';
}
}
function restoreShareConversation() {
if (!props.shareMode) {
return false;
}
const snapshot = readWorkflowShareConversation(
getWorkflowShareConversationStorage(),
shareConversationKey(),
);
if (!snapshot) {
return false;
}
timelineItems.value = snapshot.timelineItems;
executeId.value = snapshot.executeId;
runStatusKey.value = snapshot.runStatusKey;
parametersLocked.value = snapshot.parametersLocked;
executionState.value = snapshot.executionState;
executionStartedAt.value = snapshot.executionStartedAt;
executionElapsed.value = snapshot.executionElapsed;
executionRecoveryActive =
Boolean(snapshot.executeId) &&
(snapshot.executionState === 'running' ||
snapshot.executionState === 'waiting');
running.value = executionRecoveryActive;
stopping.value = false;
waitingConfirmation.value = undefined;
confirmSubmittingAction.value = '';
confirmError.value = '';
return true;
}
function clearConversationSaveTimer() {
if (conversationSaveTimer) {
window.clearTimeout(conversationSaveTimer);
conversationSaveTimer = 0;
}
}
function scheduleConversationSave() {
if (!props.shareMode || !conversationReady) {
return;
}
clearConversationSaveTimer();
conversationSaveTimer = window.setTimeout(() => {
conversationSaveTimer = 0;
persistShareConversation();
}, CONVERSATION_SAVE_DELAY_MS);
}
function persistShareConversation() {
if (!props.shareMode || !conversationReady) {
return;
}
const storage = getWorkflowShareConversationStorage();
const key = shareConversationKey();
if (timelineItems.value.length === 0 && !executeId.value) {
removeWorkflowShareConversation(storage, key);
return;
}
writeWorkflowShareConversation(storage, key, {
executeId: executeId.value,
executionElapsed: executionElapsed.value,
executionStartedAt: executionStartedAt.value,
executionState: executionState.value,
parametersLocked: parametersLocked.value,
runStatusKey: runStatusKey.value,
timelineItems: timelineItems.value,
});
}
function persistPageState() {
persistDraft();
persistShareConversation();
}
function handlePageHide() {
manualAbort.value = true;
persistPageState();
}
async function backToWorkflowList() {
await navigateBackToList(
router,
@@ -529,10 +703,23 @@ function appendError(message: string, id = `error-${Date.now()}`) {
}
function appendFinalOutput(output: unknown, eventId: string) {
const finalOutputId = `final-output-${eventId}`;
const executionPrefix = executeId.value
? `final-output-${executeId.value}`
: finalOutputId;
if (
timelineItems.value.some(
(item) =>
item.type === 'custom' &&
(item.id === finalOutputId || item.id.startsWith(executionPrefix)),
)
) {
return;
}
timelineItems.value.push({
customType: 'workflow-final-output',
data: output,
id: `final-output-${eventId}`,
id: finalOutputId,
type: 'custom',
});
}
@@ -604,6 +791,8 @@ async function handleSend() {
extraValues.value,
);
parametersLocked.value = true;
executionRecoveryActive = false;
clearExecutionRecoveryTimer();
appendUserMessage(content, images);
question.value = '';
parametersExpanded.value = false;
@@ -623,11 +812,13 @@ async function handleSend() {
confirmError.value = '';
lastRunningNodeName.value = '';
manualAbort.value = false;
await nextTick();
scrollToLatest();
void streamClient.post(
'/api/v1/workflowChat/run',
workflowChatEndpoint('run'),
{
workflowId: workflowId.value,
...(props.shareMode ? {} : { workflowId: workflowId.value }),
variables: {
...extraValues.value,
user_input: content,
@@ -638,6 +829,9 @@ async function handleSend() {
if (manualAbort.value) {
return;
}
if (beginExecutionRecovery()) {
return;
}
finishExecution(
'failed',
error?.message || '工作流执行失败',
@@ -647,6 +841,9 @@ async function handleSend() {
},
onFinished: () => {
if (running.value && !manualAbort.value) {
if (beginExecutionRecovery()) {
return;
}
finishExecution(
'failed',
'运行连接已结束,请重试',
@@ -747,6 +944,8 @@ function finishExecution(
eventId = executeId.value || String(Date.now()),
) {
const failedNodeName = activeNodeSummary();
executionRecoveryActive = false;
clearExecutionRecoveryTimer();
clearProgressStatusTimer();
running.value = false;
stopping.value = false;
@@ -806,7 +1005,7 @@ function finalizeLiveExecutionSteps(
);
executionElapsed.value =
executionStartedAt.value === undefined
? undefined
? executionElapsed.value
: Math.max(0, finishedAt - executionStartedAt.value);
}
@@ -834,7 +1033,7 @@ async function resumeExecution(confirmed: boolean) {
) {
return;
}
await api.post('/api/v1/workflowChat/resume', {
await api.post(workflowChatEndpoint('resume'), {
executeId: executeId.value,
confirmParams: {
[confirmKey.value]: confirmed ? 'yes' : 'no',
@@ -845,6 +1044,9 @@ async function resumeExecution(confirmed: boolean) {
executionState.value = 'running';
markConfirmationStep('running');
appendStatus(progressLabel('正在运行'), 'running', runStatusKey.value);
if (executionRecoveryActive) {
scheduleExecutionRecovery();
}
} catch (error: any) {
confirmError.value = error?.message || '提交失败,请重试';
} finally {
@@ -861,7 +1063,7 @@ async function stopExecution() {
appendStatus(progressLabel('正在中止'), 'running', runStatusKey.value);
try {
if (executeId.value) {
await api.post('/api/v1/workflowChat/cancel', {
await api.post(workflowChatEndpoint('cancel'), {
executeId: executeId.value,
});
}
@@ -886,8 +1088,19 @@ async function resetConversation() {
}
}
draftReady = false;
conversationReady = false;
clearDraftSaveTimer();
removeWorkflowRunDraft(getWorkflowRunDraftStorage(), draftKey());
clearConversationSaveTimer();
clearExecutionRecoveryTimer();
executionRecoveryActive = false;
removeWorkflowRunDraft(
getWorkflowRunDraftStorage(props.shareMode),
draftKey(),
);
removeWorkflowShareConversation(
getWorkflowShareConversationStorage(),
shareConversationKey(),
);
manualAbort.value = true;
streamClient.abort();
timelineItems.value = [];
@@ -910,26 +1123,181 @@ async function resetConversation() {
initializeAdditionalValues();
await nextTick();
draftReady = true;
conversationReady = true;
}
async function loadExecutionDetail() {
function clearExecutionRecoveryTimer() {
if (!executionRecoveryTimer) {
return;
}
window.clearTimeout(executionRecoveryTimer);
executionRecoveryTimer = 0;
}
function beginExecutionRecovery() {
if (!props.shareMode || !executeId.value) {
return false;
}
executionRecoveryActive = true;
running.value = true;
stopping.value = false;
if (executionState.value !== 'waiting') {
executionState.value = 'running';
appendStatus(
'连接已断开,正在恢复运行状态…',
'running',
runStatusKey.value,
);
}
clearExecutionRecoveryTimer();
void recoverExecutionAfterRefresh();
return true;
}
function scheduleExecutionRecovery() {
if (
!executionRecoveryActive ||
!props.shareMode ||
!executeId.value ||
executionState.value === 'waiting'
) {
return;
}
clearExecutionRecoveryTimer();
executionRecoveryTimer = window.setTimeout(() => {
executionRecoveryTimer = 0;
void recoverExecutionAfterRefresh();
}, EXECUTION_RECOVERY_POLL_MS);
}
async function recoverExecutionAfterRefresh() {
if (
!executionRecoveryActive ||
executionRecoveryLoading ||
!executeId.value
) {
return;
}
executionRecoveryLoading = true;
try {
const detail = await loadExecutionDetail({ background: true });
if (!detail || !executionRecoveryActive) {
scheduleExecutionRecovery();
return;
}
syncRecoveredExecution(detail);
} finally {
executionRecoveryLoading = false;
}
}
function syncRecoveredExecution(detail: Record<string, any>) {
liveExecutionSteps.value = hydrateWorkflowExecutionSteps(detail.steps);
const activeStep = [...liveExecutionSteps.value]
.reverse()
.find((step) => step.status === 'running' || step.status === 'waiting');
lastRunningNodeName.value = activeStep?.nodeName || '';
const status = resolveWorkflowExecutionRecoveryStatus(detail);
if (status === 'SUSPEND') {
removeStaleConnectionFailure();
running.value = true;
stopping.value = false;
executionState.value = 'waiting';
waitingConfirmation.value = {
message: detail.runtime?.message || '请确认',
parameters: Array.isArray(detail.runtime?.parameters)
? detail.runtime.parameters
: [],
};
initializeConfirmValues(waitingConfirmation.value.parameters);
markConfirmationStep('waiting');
appendStatus(progressLabel('等待确认'), 'running', runStatusKey.value);
return;
}
if (status === 'READY' || status === 'RUNNING' || status === 'ERROR') {
removeStaleConnectionFailure();
running.value = true;
stopping.value = false;
waitingConfirmation.value = undefined;
executionState.value = 'running';
appendStatus(progressLabel('正在运行'), 'running', runStatusKey.value);
scheduleExecutionRecovery();
return;
}
const persistedDuration = Number(detail.record?.execTime);
if (Number.isFinite(persistedDuration)) {
executionElapsed.value = persistedDuration;
executionStartedAt.value = undefined;
}
if (status === 'SUCCEEDED') {
removeStaleConnectionFailure();
const output = resolveWorkflowExecutionRecoveryOutput(detail);
finishExecution('completed', undefined, output, executeId.value);
return;
}
if (status === 'CANCELLED') {
finishExecution('cancelled', undefined, undefined, executeId.value);
return;
}
if (status === 'FAILED') {
finishExecution(
'failed',
detail.runtime?.message || detail.record?.errorInfo,
undefined,
executeId.value,
);
return;
}
scheduleExecutionRecovery();
}
function removeStaleConnectionFailure() {
if (!executeId.value) {
return;
}
detailLoading.value = true;
detailLoadError.value = '';
try {
const response = await api.get('/api/v1/workflowChat/execution', {
params: { executeId: executeId.value },
});
executionDetail.value = response.data;
} catch (error: any) {
detailLoadError.value = error?.message || '运行详情加载失败';
} finally {
detailLoading.value = false;
const staleErrorId = `terminal-error-${executeId.value}`;
const next = timelineItems.value.filter(
(item) => item.type !== 'error' || item.id !== staleErrorId,
);
if (next.length !== timelineItems.value.length) {
timelineItems.value = next;
}
}
async function loadExecutionDetail(options: { background?: boolean } = {}) {
if (!executeId.value) {
return;
}
const targetExecuteId = executeId.value;
if (!options.background) {
detailLoading.value = true;
detailLoadError.value = '';
}
try {
const response = await api.get(workflowChatEndpoint('execution'), {
params: { executeId: targetExecuteId },
});
if (targetExecuteId !== executeId.value) {
return;
}
executionDetail.value = response.data;
return response.data as Record<string, any>;
} catch (error: any) {
if (!options.background) {
detailLoadError.value = error?.message || '运行详情加载失败';
}
} finally {
if (!options.background) {
detailLoading.value = false;
}
}
}
function scrollToLatest() {
timelineRef.value?.scrollToBottom();
}
async function openExecutionDetail() {
detailVisible.value = true;
if (!running.value) {
@@ -1051,6 +1419,7 @@ function executionTraceText(
<div class="workflow-chat__title-row">
<h1>{{ descriptor.title || '工作流' }}</h1>
<ElTag
v-if="!shareMode"
size="small"
:type="shareable ? 'success' : 'info'"
effect="light"
@@ -1105,13 +1474,16 @@ function executionTraceText(
</div>
<template v-else>
<ChatTimeline
ref="timelineRef"
class="workflow-chat__timeline"
:style="timelineStyle"
:assistant-avatar="defaultAssistantAvatar"
:items="timelineItems"
:empty-text="emptyText"
:empty-title="descriptor.title || '工作流'"
:copy-action="copyMessage"
:copyable="(item) => item.parts.some((part) => part.content)"
@bottom-pinned-change="timelinePinnedToBottom = $event"
>
<template #custom-item="{ item }">
<WorkflowFinalOutput
@@ -1137,6 +1509,7 @@ function executionTraceText(
@submit.prevent
>
<WorkflowFormItem
:public-share="shareMode"
:parameters="confirmParameters"
:run-params="confirmValues"
@update:run-params="confirmValues = $event"
@@ -1168,7 +1541,21 @@ function executionTraceText(
</div>
</section>
<div class="workflow-chat__composer">
<div ref="composerRef" class="workflow-chat__composer">
<Transition name="workflow-scroll-latest">
<button
v-if="!timelinePinnedToBottom && timelineItems.length > 0"
class="workflow-chat__scroll-latest"
type="button"
aria-label="回到最新消息"
title="回到最新消息"
@click="scrollToLatest"
>
<ElIcon aria-hidden="true">
<ArrowDown />
</ElIcon>
</button>
</Transition>
<section
class="workflow-chat__input-shell"
aria-label="工作流运行输入"
@@ -1252,6 +1639,7 @@ function executionTraceText(
<WorkflowFormItem
:disabled="composerDisabled || parametersLocked"
:parameters="primaryParameters"
:public-share="shareMode"
:run-params="extraValues"
@update:run-params="extraValues = $event"
/>
@@ -1285,6 +1673,7 @@ function executionTraceText(
<WorkflowFormItem
:disabled="composerDisabled || parametersLocked"
:parameters="moreParameters"
:public-share="shareMode"
:run-params="extraValues"
@update:run-params="extraValues = $event"
/>
@@ -1362,7 +1751,7 @@ function executionTraceText(
<div v-if="detailLoadError" class="workflow-chat__detail-load-error">
<span>{{ detailLoadError }}</span>
<ElButton text type="primary" @click="loadExecutionDetail">
<ElButton text type="primary" @click="loadExecutionDetail()">
重试
</ElButton>
</div>
@@ -1555,8 +1944,51 @@ function executionTraceText(
}
.workflow-chat__timeline {
width: 100%;
padding: 0;
margin: 0;
scrollbar-gutter: stable;
scrollbar-color: color-mix(
in srgb,
var(--el-text-color-placeholder) 42%,
transparent
)
transparent;
scrollbar-width: thin;
}
.workflow-chat__timeline::-webkit-scrollbar {
width: var(--space-2);
}
.workflow-chat__timeline::-webkit-scrollbar-track {
background: transparent;
}
.workflow-chat__timeline::-webkit-scrollbar-thumb {
background: color-mix(
in srgb,
var(--el-text-color-placeholder) 42%,
transparent
);
background-clip: padding-box;
border: 2px solid transparent;
border-radius: var(--radius-pill);
}
.workflow-chat__timeline::-webkit-scrollbar-thumb:hover {
background: color-mix(
in srgb,
var(--el-text-color-secondary) 54%,
transparent
);
background-clip: padding-box;
}
.workflow-chat__timeline :deep(.chat-timeline__content) {
width: min(920px, 100%);
padding: 24px 24px 152px;
padding: 24px 24px
calc(var(--workflow-chat-composer-height, 152px) + var(--space-6));
margin: 0 auto;
}
@@ -1644,6 +2076,56 @@ function executionTraceText(
);
}
.workflow-chat__scroll-latest {
display: inline-grid;
place-items: center;
width: var(--space-8);
height: var(--space-8);
padding: 0;
color: var(--el-text-color-regular);
pointer-events: auto;
cursor: pointer;
background: color-mix(in srgb, var(--el-bg-color) 94%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--radius-pill);
box-shadow: var(--shadow-subtle);
backdrop-filter: blur(12px);
transition:
color var(--motion-duration-fast) var(--motion-ease-standard),
background-color var(--motion-duration-fast) var(--motion-ease-standard),
border-color var(--motion-duration-fast) var(--motion-ease-standard),
transform var(--motion-duration-fast) var(--motion-ease-standard);
}
.workflow-chat__scroll-latest:hover {
color: var(--el-color-primary);
background: var(--el-bg-color);
border-color: var(--el-color-primary-light-7);
transform: translateY(-1px);
}
.workflow-chat__scroll-latest:active {
transform: translateY(0);
}
.workflow-chat__scroll-latest:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
.workflow-scroll-latest-enter-active,
.workflow-scroll-latest-leave-active {
transition:
opacity var(--motion-duration-fast) var(--motion-ease-standard),
transform var(--motion-duration-fast) var(--motion-ease-standard);
}
.workflow-scroll-latest-enter-from,
.workflow-scroll-latest-leave-to {
opacity: 0;
transform: translateY(var(--space-2));
}
.workflow-chat__input-shell {
display: flex;
flex-direction: column;
@@ -1670,6 +2152,10 @@ function executionTraceText(
}
.workflow-chat__input-row {
display: flex;
gap: var(--space-3);
align-items: flex-end;
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
overflow: hidden;
border-radius: var(--radius-panel);
box-shadow: var(--shadow-toolbar);
@@ -1904,13 +2390,6 @@ function executionTraceText(
margin-top: var(--space-3);
}
.workflow-chat__input-row {
display: flex;
gap: var(--space-3);
align-items: flex-end;
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
}
.workflow-chat__input-row textarea {
flex: 1;
min-height: 48px;
@@ -2239,7 +2718,12 @@ function executionTraceText(
}
.workflow-chat__timeline {
padding: 16px 16px 168px;
padding: 0;
}
.workflow-chat__timeline :deep(.chat-timeline__content) {
padding: 16px 16px
calc(var(--workflow-chat-composer-height, 168px) + var(--space-6));
}
.workflow-chat__composer {

View File

@@ -31,6 +31,14 @@ const props = defineProps({
type: [Array, Object],
default: undefined,
},
uploadData: {
type: Object,
default: () => ({}),
},
uploadUrl: {
type: String,
default: '/api/v1/commons/upload',
},
});
const emit = defineEmits(['update:modelValue']);
@@ -63,7 +71,11 @@ async function uploadFiles(files: File[]) {
validateWorkflowFileSelection(currentFiles.value, files);
const uploadedFiles = [];
for (const file of files) {
const res = await api.upload('/api/v1/commons/upload', { file }, {});
const res = await api.upload(
props.uploadUrl,
{ file, ...props.uploadData },
{},
);
uploadedFiles.push(
buildWorkflowFileValueFromUpload(file, res?.data?.path),
);

View File

@@ -33,6 +33,10 @@ const props = defineProps({
type: String,
default: '',
},
publicShare: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:runParams']);
function getContentType(item: any) {
@@ -178,13 +182,26 @@ function choose(data: any, propName: string) {
<WorkflowFileInput
:disabled="disabled"
:model-value="runParams[item.name]"
:upload-data="{ parameterName: item.name }"
:upload-url="
publicShare
? '/api/v1/workflowChat/public/upload'
: '/api/v1/commons/upload'
"
@update:model-value="(val) => updateParam(item.name, val)"
/>
</template>
<template v-if="getContentType(item) === 'image'">
<WorkflowImageInput
:allow-resource-picker="!publicShare"
:disabled="disabled"
:model-value="runParams[item.name]"
:upload-data="{ parameterName: item.name }"
:upload-url="
publicShare
? '/api/v1/workflowChat/public/upload'
: '/api/v1/commons/upload'
"
@update:model-value="(val) => updateParam(item.name, val)"
/>
</template>
@@ -196,6 +213,7 @@ function choose(data: any, propName: string) {
:placeholder="item.formPlaceholder"
/>
<ChooseResource
v-if="!publicShare"
:attr-name="item.name"
:disabled="disabled"
@choose="choose"

View File

@@ -27,6 +27,18 @@ const props = defineProps({
type: [String, Object],
default: undefined,
},
allowResourcePicker: {
type: Boolean,
default: true,
},
uploadData: {
type: Object,
default: () => ({}),
},
uploadUrl: {
type: String,
default: '/api/v1/commons/upload',
},
});
const emit = defineEmits(['update:modelValue']);
@@ -74,7 +86,11 @@ async function handleNativeFileChange(event: Event) {
uploadLoading.value = true;
try {
validateWorkflowImageFile(file);
const response = await api.upload('/api/v1/commons/upload', { file }, {});
const response = await api.upload(
props.uploadUrl,
{ file, ...props.uploadData },
{},
);
if (props.disabled) {
return;
}
@@ -178,6 +194,7 @@ function clearImage() {
{{ currentImage ? '替换图片' : $t('button.upload') }}
</ElButton>
<ChooseResource
v-if="allowResourcePicker"
attr-name="image"
:disabled="disabled"
:resource-type="0"

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import {
resolveWorkflowExecutionRecoveryOutput,
resolveWorkflowExecutionRecoveryStatus,
} from '../workflowExecutionRecovery';
describe('workflowExecutionRecovery', () => {
it('uses live runtime state ahead of a stale persisted record', () => {
expect(
resolveWorkflowExecutionRecoveryStatus({
record: { status: 1 },
runtime: { status: 'SUSPEND', statusValue: 5 },
}),
).toBe('SUSPEND');
});
it('falls back to persisted terminal status and JSON output', () => {
const detail = {
record: { output: '{"answer":"done"}', status: 20 },
runtime: {},
};
expect(resolveWorkflowExecutionRecoveryStatus(detail)).toBe('SUCCEEDED');
expect(resolveWorkflowExecutionRecoveryOutput(detail)).toEqual({
answer: 'done',
});
});
it('prefers the runtime output before persistence catches up', () => {
expect(
resolveWorkflowExecutionRecoveryOutput({
record: { output: undefined, status: 1 },
runtime: { output: { answer: 'live' }, status: 'SUCCEEDED' },
}),
).toEqual({ answer: 'live' });
});
});

View File

@@ -0,0 +1,49 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import WorkflowFileInput from '../WorkflowFileInput.vue';
import WorkflowFormItem from '../WorkflowFormItem.vue';
import WorkflowImageInput from '../WorkflowImageInput.vue';
describe('workflow public form item', () => {
it('routes file uploads through the isolated public endpoint', () => {
const wrapper = mount(WorkflowFormItem, {
props: {
parameters: [
{
contentType: 'file',
name: 'attachment',
},
],
publicShare: true,
runParams: {},
},
});
const input = wrapper.getComponent(WorkflowFileInput);
expect(input.props('uploadUrl')).toBe('/api/v1/workflowChat/public/upload');
expect(input.props('uploadData')).toEqual({
parameterName: 'attachment',
});
});
it('keeps image URL and upload while hiding the internal resource picker', () => {
const wrapper = mount(WorkflowFormItem, {
props: {
parameters: [
{
contentType: 'image',
name: 'image',
},
],
publicShare: true,
runParams: {},
},
});
const input = wrapper.getComponent(WorkflowImageInput);
expect(input.props('allowResourcePicker')).toBe(false);
expect(input.props('uploadUrl')).toBe('/api/v1/workflowChat/public/upload');
});
});

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
import {
buildWorkflowRunDraftKey,
getWorkflowRunDraftStorage,
hasWorkflowRunDraftContent,
readWorkflowRunDraft,
removeWorkflowRunDraft,
@@ -16,6 +17,7 @@ const parameters = [
describe('workflowRunDraft', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
@@ -29,6 +31,14 @@ describe('workflowRunDraft', () => {
expect(buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', true)).not.toBe(
buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false),
);
expect(buildWorkflowRunDraftKey('flow-1', 'visitor-1', true)).not.toBe(
buildWorkflowRunDraftKey('flow-1', 'visitor-2', true),
);
});
it('uses local storage only for persistent share drafts', () => {
expect(getWorkflowRunDraftStorage()).toBe(sessionStorage);
expect(getWorkflowRunDraftStorage(true)).toBe(localStorage);
});
it('restores current compatible fields within twelve hours', () => {

View File

@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
buildWorkflowShareConversationKey,
buildWorkflowShareStorageScope,
readWorkflowShareConversation,
removeWorkflowShareConversation,
WORKFLOW_SHARE_CONVERSATION_TTL_MS,
writeWorkflowShareConversation,
} from '../workflowShareConversationStorage';
describe('workflowShareConversationStorage', () => {
beforeEach(() => {
localStorage.clear();
});
it('isolates snapshots without exposing the raw share key', () => {
const shareKey = 'secret-share-key';
const firstScope = buildWorkflowShareStorageScope('visitor-1', shareKey);
const secondScope = buildWorkflowShareStorageScope('visitor-2', shareKey);
const otherShareScope = buildWorkflowShareStorageScope(
'visitor-1',
'other-share-key',
);
const key = buildWorkflowShareConversationKey('flow-1', firstScope);
expect(firstScope).not.toBe(secondScope);
expect(firstScope).not.toBe(otherShareScope);
expect(key).not.toContain(shareKey);
expect(key).not.toContain('visitor-1');
});
it('restores a valid snapshot within twelve hours', () => {
const key = buildWorkflowShareConversationKey(
'flow-1',
buildWorkflowShareStorageScope('visitor-1', 'share-1'),
);
writeWorkflowShareConversation(
localStorage,
key,
{
executeId: 'exec-1',
executionState: 'completed',
parametersLocked: true,
runStatusKey: 'run-1',
timelineItems: [
{
id: 'message-1',
parts: [{ content: '处理完成', id: 'part-1', type: 'text' }],
role: 'user',
type: 'message',
},
],
},
1000,
);
expect(readWorkflowShareConversation(localStorage, key, 2000)).toEqual({
executeId: 'exec-1',
executionElapsed: undefined,
executionStartedAt: undefined,
executionState: 'completed',
parametersLocked: true,
runStatusKey: 'run-1',
timelineItems: [
{
id: 'message-1',
parts: [{ content: '处理完成', id: 'part-1', type: 'text' }],
role: 'user',
type: 'message',
},
],
});
});
it('drops expired or malformed snapshots', () => {
const key = buildWorkflowShareConversationKey(
'flow-1',
buildWorkflowShareStorageScope('visitor-1', 'share-1'),
);
writeWorkflowShareConversation(
localStorage,
key,
{
executeId: '',
executionState: 'idle',
parametersLocked: false,
runStatusKey: '',
timelineItems: [],
},
1000,
);
expect(
readWorkflowShareConversation(
localStorage,
key,
1000 + WORKFLOW_SHARE_CONVERSATION_TTL_MS,
),
).toBeUndefined();
expect(localStorage.getItem(key)).toBeNull();
localStorage.setItem(key, '{"version":1,"timelineItems":"invalid"}');
expect(readWorkflowShareConversation(localStorage, key)).toBeUndefined();
expect(localStorage.getItem(key)).toBeNull();
});
it('supports an explicit reset', () => {
const key = buildWorkflowShareConversationKey(
'flow-1',
buildWorkflowShareStorageScope('visitor-1', 'share-1'),
);
localStorage.setItem(key, 'cached');
removeWorkflowShareConversation(localStorage, key);
expect(localStorage.getItem(key)).toBeNull();
});
});

View File

@@ -150,7 +150,7 @@ export function hydrateWorkflowExecutionSteps(
error: textValue(step.errorInfo) || undefined,
hasInput: step.input !== undefined && step.input !== null,
hasOutput: step.output !== undefined && step.output !== null,
input: parseExecutionValue(step.input),
input: parseWorkflowExecutionValue(step.input),
key:
textValue(step.attemptKey) ||
textValue(step.id) ||
@@ -158,7 +158,7 @@ export function hydrateWorkflowExecutionSteps(
nodeId: textValue(step.nodeId),
nodeName:
textValue(step.nodeName) || textValue(step.nodeId) || '工作流节点',
output: parseExecutionValue(step.output),
output: parseWorkflowExecutionValue(step.output),
startTime: timeValue(step.startTime),
status: resolvePersistedStatus(step.status),
traces: [],
@@ -252,7 +252,7 @@ function findStepIndex(
return -1;
}
function parseExecutionValue(value: unknown) {
export function parseWorkflowExecutionValue(value: unknown) {
if (typeof value !== 'string') {
return value;
}

View File

@@ -0,0 +1,34 @@
import { parseWorkflowExecutionValue } from './workflowExecutionDetails';
export function resolveWorkflowExecutionRecoveryStatus(
detail: Record<string, any>,
) {
const runtimeStatus = String(detail.runtime?.status || '')
.trim()
.toUpperCase();
if (runtimeStatus) {
return runtimeStatus;
}
const statusValue = String(
detail.runtime?.statusValue ?? detail.record?.status ?? '',
);
const labels: Record<string, string> = {
'0': 'READY',
'1': 'RUNNING',
'10': 'ERROR',
'20': 'SUCCEEDED',
'21': 'FAILED',
'22': 'CANCELLED',
'5': 'SUSPEND',
};
return labels[statusValue] || statusValue.toUpperCase();
}
export function resolveWorkflowExecutionRecoveryOutput(
detail: Record<string, any>,
) {
if (Object.prototype.hasOwnProperty.call(detail.runtime || {}, 'output')) {
return detail.runtime.output;
}
return parseWorkflowExecutionValue(detail.record?.output);
}

View File

@@ -15,10 +15,10 @@ interface StoredWorkflowRunDraft extends WorkflowRunDraftPayload {
type DraftStorage = Pick<Storage, 'getItem' | 'removeItem' | 'setItem'>;
/** 获取可用的会话存储。 */
export function getWorkflowRunDraftStorage() {
/** 获取可用的草稿存储;分享模式使用本地存储支持刷新恢复。 */
export function getWorkflowRunDraftStorage(persistent = false) {
try {
return globalThis.sessionStorage;
return persistent ? globalThis.localStorage : globalThis.sessionStorage;
} catch {
return undefined;
}
@@ -31,7 +31,7 @@ export function buildWorkflowRunDraftKey(
shareMode: boolean,
) {
const mode = shareMode ? 'share' : 'private';
const scope = shareMode ? 'public' : identity || 'anonymous';
const scope = identity || (shareMode ? 'public' : 'anonymous');
return [
WORKFLOW_RUN_DRAFT_PREFIX,
`v${WORKFLOW_RUN_DRAFT_VERSION}`,

View File

@@ -0,0 +1,229 @@
import type { ChatTimelineItem } from '@easyflow/common-ui';
const WORKFLOW_SHARE_CONVERSATION_PREFIX =
'easyflow:workflow-share-conversation';
const WORKFLOW_SHARE_CONVERSATION_VERSION = 1;
const MAX_TIMELINE_ITEMS = 200;
export const WORKFLOW_SHARE_CONVERSATION_TTL_MS = 12 * 60 * 60 * 1000;
export type WorkflowShareExecutionState =
| 'cancelled'
| 'completed'
| 'failed'
| 'idle'
| 'running'
| 'waiting';
export interface WorkflowShareConversationSnapshot {
executeId: string;
executionElapsed?: number;
executionStartedAt?: number;
executionState: WorkflowShareExecutionState;
parametersLocked: boolean;
runStatusKey: string;
timelineItems: ChatTimelineItem[];
}
interface StoredWorkflowShareConversation
extends WorkflowShareConversationSnapshot {
expiresAt: number;
version: number;
}
type ConversationStorage = Pick<Storage, 'getItem' | 'removeItem' | 'setItem'>;
/** 获取分享页使用的浏览器本地存储。 */
export function getWorkflowShareConversationStorage() {
try {
return globalThis.localStorage;
} catch {
return undefined;
}
}
/**
* 生成分享页本地快照范围。原始分享密钥和访客标识都不写入本地存储。
*/
export function buildWorkflowShareStorageScope(
visitorId: string,
shareKey: string,
) {
const normalizedVisitorId = visitorId.trim();
const normalizedShareKey = shareKey.trim();
if (!normalizedVisitorId || !normalizedShareKey) {
return '';
}
return fingerprint(`${normalizedVisitorId}\u0000${normalizedShareKey}`);
}
/** 生成按工作流、分享链接和标签页访客隔离的快照键。 */
export function buildWorkflowShareConversationKey(
workflowId: string,
storageScope: string,
) {
if (!workflowId || !storageScope) {
return '';
}
return [
WORKFLOW_SHARE_CONVERSATION_PREFIX,
`v${WORKFLOW_SHARE_CONVERSATION_VERSION}`,
encodeURIComponent(storageScope),
encodeURIComponent(workflowId),
].join(':');
}
/** 读取未过期且结构有效的分享页快照。 */
export function readWorkflowShareConversation(
storage: ConversationStorage | undefined,
key: string,
now = Date.now(),
): undefined | WorkflowShareConversationSnapshot {
if (!storage || !key) {
return undefined;
}
try {
const raw = storage.getItem(key);
if (!raw) {
return undefined;
}
const snapshot = JSON.parse(
raw,
) as Partial<StoredWorkflowShareConversation>;
if (!isValidSnapshot(snapshot, now)) {
storage.removeItem(key);
return undefined;
}
return {
executeId: snapshot.executeId,
executionElapsed: numberValue(snapshot.executionElapsed),
executionStartedAt: numberValue(snapshot.executionStartedAt),
executionState: snapshot.executionState,
parametersLocked: snapshot.parametersLocked,
runStatusKey: snapshot.runStatusKey,
timelineItems: snapshot.timelineItems.slice(-MAX_TIMELINE_ITEMS),
};
} catch {
try {
storage.removeItem(key);
} catch {
// 存储不可用时不影响分享页加载。
}
return undefined;
}
}
/** 保存最近 200 条分享页时间线和最近执行引用。 */
export function writeWorkflowShareConversation(
storage: ConversationStorage | undefined,
key: string,
snapshot: WorkflowShareConversationSnapshot,
now = Date.now(),
) {
if (!storage || !key) {
return;
}
try {
storage.setItem(
key,
JSON.stringify({
...snapshot,
expiresAt: now + WORKFLOW_SHARE_CONVERSATION_TTL_MS,
timelineItems: snapshot.timelineItems.slice(-MAX_TIMELINE_ITEMS),
version: WORKFLOW_SHARE_CONVERSATION_VERSION,
} satisfies StoredWorkflowShareConversation),
);
} catch {
// 存储不可用或空间不足时不阻断工作流运行。
}
}
/** 删除分享页本地快照。 */
export function removeWorkflowShareConversation(
storage: ConversationStorage | undefined,
key: string,
) {
if (!storage || !key) {
return;
}
try {
storage.removeItem(key);
} catch {
// 存储不可用时不影响清空流程。
}
}
function isValidSnapshot(
snapshot: Partial<StoredWorkflowShareConversation>,
now: number,
): snapshot is StoredWorkflowShareConversation {
return (
snapshot.version === WORKFLOW_SHARE_CONVERSATION_VERSION &&
typeof snapshot.expiresAt === 'number' &&
snapshot.expiresAt > now &&
typeof snapshot.executeId === 'string' &&
typeof snapshot.runStatusKey === 'string' &&
typeof snapshot.parametersLocked === 'boolean' &&
isExecutionState(snapshot.executionState) &&
Array.isArray(snapshot.timelineItems) &&
snapshot.timelineItems.every((item) => isTimelineItem(item))
);
}
function isExecutionState(
value: unknown,
): value is WorkflowShareExecutionState {
return (
value === 'cancelled' ||
value === 'completed' ||
value === 'failed' ||
value === 'idle' ||
value === 'running' ||
value === 'waiting'
);
}
function isTimelineItem(value: unknown): value is ChatTimelineItem {
if (!value || typeof value !== 'object') {
return false;
}
const item = value as Record<string, unknown>;
if (typeof item.id !== 'string' || typeof item.type !== 'string') {
return false;
}
if (item.type === 'message') {
return Array.isArray(item.parts) && typeof item.role === 'string';
}
if (item.type === 'status') {
return (
typeof item.label === 'string' &&
typeof item.status === 'string' &&
typeof item.statusKey === 'string'
);
}
if (item.type === 'error') {
return typeof item.message === 'string';
}
return item.type === 'custom';
}
function numberValue(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
function fingerprint(value: string) {
let first = 2_166_136_261;
let second = 2_654_435_769;
for (const character of value) {
const code = character.codePointAt(0) || 0;
first = Math.imul(first ^ code, 16_777_619);
second = Math.imul(second ^ code, 2_246_822_507);
}
return `${unsignedHex(first)}${unsignedHex(second)}`;
}
function unsignedHex(value: number) {
return (value >>> 0).toString(16).padStart(8, '0');
}

View File

@@ -4,9 +4,11 @@ import {
isWorkflowShareRequest,
readWorkflowShareKey,
resolveWorkflowShareFailureReason,
resolveWorkflowShareVisitorId,
resolveWorkflowShareWorkflowId,
withWorkflowShareHeader,
WORKFLOW_SHARE_HEADER,
WORKFLOW_SHARE_VISITOR_HEADER,
} from '#/utils/workflow-share-context';
describe('workflow share context', () => {
@@ -41,12 +43,15 @@ describe('workflow share context', () => {
{
pageUrl: 'https://example.test/share/workflow?shareKey=abc123',
requestMethod: 'GET',
requestUrl: '/api/v1/workflowChat/descriptor?workflowId=1',
requestUrl: '/api/v1/workflowChat/public/descriptor',
visitorId: '00112233445566778899aabbccddeeff',
},
),
).toEqual({
'Accept-Language': 'zh-CN',
'easyflow-token': '',
[WORKFLOW_SHARE_HEADER]: 'abc123',
[WORKFLOW_SHARE_VISITOR_HEADER]: '00112233445566778899aabbccddeeff',
});
});
@@ -57,7 +62,7 @@ describe('workflow share context', () => {
withWorkflowShareHeader(headers, {
pageUrl: 'https://example.test/share/workflow',
requestMethod: 'GET',
requestUrl: '/api/v1/workflowChat/descriptor?workflowId=1',
requestUrl: '/api/v1/workflowChat/public/descriptor',
}),
).toEqual(headers);
});
@@ -120,18 +125,37 @@ describe('workflow share context', () => {
requestUrl: '/api/v1/workflowChat/run',
},
),
).toEqual({});
expect(
withWorkflowShareHeader(
{ 'easyflow-token': 'authenticated-token' },
{
pageUrl,
requestMethod: 'POST',
requestUrl: '/api/v1/workflowChat/public/run',
visitorId: 'ffeeddccbbaa99887766554433221100',
},
),
).toEqual({
'easyflow-token': '',
[WORKFLOW_SHARE_HEADER]: 'workflow-key',
[WORKFLOW_SHARE_VISITOR_HEADER]: 'ffeeddccbbaa99887766554433221100',
});
});
it('matches only the workflow sharing endpoint whitelist', () => {
expect(
isWorkflowShareRequest('/flow/api/v1/workflowChat/run', 'post'),
isWorkflowShareRequest('/flow/api/v1/workflowChat/public/run', 'post'),
).toBe(true);
expect(
isWorkflowShareRequest('/flow/api/v1/workflowChat/execution', 'get'),
isWorkflowShareRequest(
'/flow/api/v1/workflowChat/public/execution',
'get',
),
).toBe(true);
expect(
isWorkflowShareRequest('/flow/api/v1/workflowChat/run', 'post'),
).toBe(false);
expect(isWorkflowShareRequest('/flow/api/v1/workflow/update', 'post')).toBe(
false,
);
@@ -143,6 +167,26 @@ describe('workflow share context', () => {
);
});
it('keeps one cryptographic visitor id in the current tab storage', () => {
const values = new Map<string, string>();
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
};
const randomBytes = (size: number) =>
Uint8Array.from({ length: size }, (_, index) => index);
const first = resolveWorkflowShareVisitorId(storage, randomBytes);
const second = resolveWorkflowShareVisitorId(storage, () => {
throw new Error('should not regenerate');
});
expect(first).toBe('000102030405060708090a0b0c0d0e0f');
expect(second).toBe(first);
});
it('resolves the workflow id for a shared URL', async () => {
const resolve = vi.fn().mockResolvedValue('workflow-1');
const onFailure = vi.fn();

View File

@@ -9,7 +9,14 @@ import type {
ChatTimelineToolApprovalPayload,
} from './types';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';
import ChatAssistantAvatar from './ChatAssistantAvatar.vue';
import ChatTimelineItem from './ChatTimelineItem.vue';
@@ -35,6 +42,7 @@ const props = defineProps<{
const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload];
bottomPinnedChange: [pinned: boolean];
copyMessage: [item: ChatTimelineMessageItem];
errorAction: [item: ChatTimelineErrorItem];
regenerateMessage: [item: ChatTimelineMessageItem];
@@ -44,12 +52,14 @@ const emit = defineEmits<{
}>();
const containerRef = ref<HTMLElement>();
const contentRef = ref<HTMLElement>();
const isPinnedToBottom = ref(true);
const suppressNextAutoScroll = ref(false);
let preservedAnchor: undefined | { element: HTMLElement; relativeTop: number };
const bottomThreshold = 24;
let scrollFrame = 0;
let contentResizeObserver: ResizeObserver | undefined;
const assistantActionAnchorByRound = computed(() => {
const latestAssistantByRound = new Map<string, string>();
for (const item of props.items) {
@@ -116,7 +126,12 @@ function updatePinnedState() {
if (suppressNextAutoScroll.value && preservedAnchor) {
return;
}
isPinnedToBottom.value = isNearBottom(container);
const pinned = isNearBottom(container);
if (pinned === isPinnedToBottom.value) {
return;
}
isPinnedToBottom.value = pinned;
emit('bottomPinnedChange', pinned);
}
function scrollToBottom() {
@@ -206,6 +221,22 @@ function handleLegacyLayoutToggle() {
});
}
defineExpose({
scrollToBottom,
});
onMounted(() => {
if (typeof ResizeObserver === 'undefined' || !contentRef.value) {
return;
}
contentResizeObserver = new ResizeObserver(() => {
if (isPinnedToBottom.value && !suppressNextAutoScroll.value) {
scrollToBottom();
}
});
contentResizeObserver.observe(contentRef.value);
});
function canCopyMessage(item: ChatTimelineItemType) {
return item.type === 'message' && (props.copyable?.(item) ?? false);
}
@@ -232,6 +263,7 @@ function isVariantLoading(item: ChatTimelineItemType) {
}
onBeforeUnmount(() => {
contentResizeObserver?.disconnect();
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
}
@@ -257,86 +289,88 @@ watch(
class="chat-timeline"
@scroll.passive="handleTimelineScroll"
>
<div v-if="items.length === 0" class="chat-timeline__empty">
<div
class="chat-timeline__empty-icon"
:class="{ 'has-avatar': assistantAvatar }"
aria-hidden="true"
>
<ChatAssistantAvatar v-if="assistantAvatar" :src="assistantAvatar" />
</div>
<div v-if="emptyTitle" class="chat-timeline__empty-title">
{{ emptyTitle }}
</div>
<div class="chat-timeline__empty-text">
{{ emptyText || '开始对话' }}
</div>
</div>
<template v-else>
<template v-for="entry in displayEntries" :key="entry.id">
<ChatTimelineTurn
v-if="entry.type === 'turn'"
:action-anchor-id="assistantActionAnchorByRound.get(entry.roundId)"
:artifact-loader="artifactLoader"
:approval-loading="approvalLoading"
:assistant-avatar="assistantAvatar"
:copy-action="copyAction"
:copyable="copyable"
:document-loader="documentLoader"
:error-action="errorAction"
:error-action-disabled="errorActionDisabled"
:image-loader="imageLoader"
:items="entry.items"
:regenerable="regenerable"
:regenerate-disabled="regenerateDisabled"
:round-id="entry.roundId"
: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)"
@reject="emit('reject', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
<div ref="contentRef" class="chat-timeline__content">
<div v-if="items.length === 0" class="chat-timeline__empty">
<div
class="chat-timeline__empty-icon"
:class="{ 'has-avatar': assistantAvatar }"
aria-hidden="true"
>
<template #custom-item="{ item }">
<slot name="custom-item" :item="item"></slot>
</template>
</ChatTimelineTurn>
<slot
v-else-if="entry.item.type === 'custom'"
name="custom-item"
:item="entry.item"
></slot>
<ChatTimelineItem
v-else
:assistant-actions-visible="isAssistantActionAnchor(entry.item)"
:artifact-loader="artifactLoader"
: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"
:copyable="canCopyMessage(entry.item)"
:regenerable="canRegenerateMessage(entry.item)"
:regenerate-disabled="regenerateDisabled"
: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)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
@thinking-toggle="handleLegacyLayoutToggle"
/>
<ChatAssistantAvatar v-if="assistantAvatar" :src="assistantAvatar" />
</div>
<div v-if="emptyTitle" class="chat-timeline__empty-title">
{{ emptyTitle }}
</div>
<div class="chat-timeline__empty-text">
{{ emptyText || '开始对话' }}
</div>
</div>
<template v-else>
<template v-for="entry in displayEntries" :key="entry.id">
<ChatTimelineTurn
v-if="entry.type === 'turn'"
:action-anchor-id="assistantActionAnchorByRound.get(entry.roundId)"
:artifact-loader="artifactLoader"
:approval-loading="approvalLoading"
:assistant-avatar="assistantAvatar"
:copy-action="copyAction"
:copyable="copyable"
:document-loader="documentLoader"
:error-action="errorAction"
:error-action-disabled="errorActionDisabled"
:image-loader="imageLoader"
:items="entry.items"
:regenerable="regenerable"
:regenerate-disabled="regenerateDisabled"
:round-id="entry.roundId"
: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)"
@reject="emit('reject', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
>
<template #custom-item="{ item }">
<slot name="custom-item" :item="item"></slot>
</template>
</ChatTimelineTurn>
<slot
v-else-if="entry.item.type === 'custom'"
name="custom-item"
:item="entry.item"
></slot>
<ChatTimelineItem
v-else
:assistant-actions-visible="isAssistantActionAnchor(entry.item)"
:artifact-loader="artifactLoader"
: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"
:copyable="canCopyMessage(entry.item)"
:regenerable="canRegenerateMessage(entry.item)"
:regenerate-disabled="regenerateDisabled"
: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)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
@thinking-toggle="handleLegacyLayoutToggle"
/>
</template>
</template>
</template>
</div>
</div>
</template>
@@ -345,12 +379,19 @@ watch(
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-3);
min-height: 0;
padding: 16px;
overflow: auto;
}
.chat-timeline__content {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: var(--space-3);
min-height: 100%;
padding: 16px;
}
.chat-timeline__empty-icon {
width: 72px;
height: 72px;

View File

@@ -395,6 +395,28 @@ describe('chat timeline turn', () => {
wrapper.unmount();
});
it('reports when the reader leaves and returns to the latest message', async () => {
const wrapper = mount(ChatTimeline, {
props: {
items: completedTurnItems(),
},
});
const container = wrapper.find('.chat-timeline').element as HTMLElement;
Object.defineProperties(container, {
clientHeight: { configurable: true, value: 500 },
scrollHeight: { configurable: true, value: 1200 },
scrollTop: { configurable: true, value: 200, writable: true },
});
await wrapper.find('.chat-timeline').trigger('scroll');
expect(wrapper.emitted('bottomPinnedChange')).toEqual([[false]]);
container.scrollTop = 700;
await wrapper.find('.chat-timeline').trigger('scroll');
expect(wrapper.emitted('bottomPinnedChange')).toEqual([[false], [true]]);
expect(wrapper.find('.chat-timeline__content').exists()).toBe(true);
});
it('keeps running and approval content expanded in one turn', () => {
const items: ChatTimelineItem[] = [
{

View File

@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
import { authenticateResponseInterceptor } from './preset-interceptors';
function createInterceptor(
shouldHandleUnauthorized?: (config: any) => boolean,
) {
const doReAuthenticate = vi.fn(async () => undefined);
const interceptor = authenticateResponseInterceptor({
client: {} as any,
doReAuthenticate,
doRefreshToken: vi.fn(async () => 'new-token'),
enableRefreshToken: false,
formatToken: (token) => token,
shouldHandleUnauthorized,
});
return { doReAuthenticate, interceptor };
}
describe('authenticate response interceptor', () => {
it('leaves anonymous endpoint 401 errors to the page', async () => {
const { doReAuthenticate, interceptor } = createInterceptor(() => false);
const error = {
config: { method: 'get', url: '/api/v1/workflowShare/resolve' },
response: { status: 401 },
};
await expect(interceptor.rejected?.(error)).rejects.toBe(error);
expect(doReAuthenticate).not.toHaveBeenCalled();
});
it('keeps the existing reauthentication behavior by default', async () => {
const { doReAuthenticate, interceptor } = createInterceptor();
const error = {
config: { method: 'get', url: '/api/v1/workflow/page' },
response: { status: 401 },
};
await expect(interceptor.rejected?.(error)).rejects.toBe(error);
expect(doReAuthenticate).toHaveBeenCalledOnce();
});
});

View File

@@ -59,12 +59,14 @@ export const authenticateResponseInterceptor = ({
doRefreshToken,
enableRefreshToken,
formatToken,
shouldHandleUnauthorized,
}: {
client: RequestClient;
doReAuthenticate: () => Promise<void>;
doRefreshToken: () => Promise<string>;
enableRefreshToken: boolean;
formatToken: (token: string) => null | string;
shouldHandleUnauthorized?: (config: any) => boolean;
}): ResponseInterceptorConfig => {
return {
rejected: async (error) => {
@@ -73,6 +75,10 @@ export const authenticateResponseInterceptor = ({
if (response?.status !== 401) {
throw error;
}
// 匿名接口的 401 由页面自身处理,不能触发刷新登录态或跳转登录页。
if (shouldHandleUnauthorized?.(config) === false) {
throw error;
}
// 判断是否启用了 refreshToken 功能
// 如果没有启用或者已经是重试请求了,直接跳转到重新登录
if (!enableRefreshToken || config.__isRetryRequest) {