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

@@ -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) {