feat: 支持工作流对话匿名分享
- 增加免登录公共接口、访客隔离、限流和匿名上传校验 - 分离 SSE 连接与运行生命周期,支持刷新恢复服务端权威状态 - 持久化分享页对话并优化时间线滚动与输入区交互
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user