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

@@ -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);
}
/**
* 解析分享地址对应的工作流,并在链接失效时统一收口异常。
*/