feat: 完善智能体图片聊天与会话恢复
- 增加私有图片上传、绑定、历史回显与生命周期清理 - 支持输入草稿恢复、图片交互和模型图片能力约束 - 修复旧脏会话幂等删除与前端会话恢复
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatImageAttachment, ChatImageLoader } from './types';
|
||||
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import { useEasyFlowModal } from '@easyflow-core/popup-ui';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
compact?: boolean;
|
||||
imageLoader?: ChatImageLoader;
|
||||
items: ChatImageAttachment[];
|
||||
removable?: boolean;
|
||||
retryable?: boolean;
|
||||
}>(),
|
||||
{
|
||||
compact: false,
|
||||
imageLoader: undefined,
|
||||
removable: false,
|
||||
retryable: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [item: ChatImageAttachment];
|
||||
retry: [item: ChatImageAttachment];
|
||||
}>();
|
||||
|
||||
const resolvedUrls = ref<Record<string, string>>({});
|
||||
const failedSources = ref<Set<string>>(new Set());
|
||||
const previewItem = ref<ChatImageAttachment>();
|
||||
const ownedUrls = new Set<string>();
|
||||
let resolutionVersion = 0;
|
||||
|
||||
const [ImagePreviewModal, imagePreviewModalApi] = useEasyFlowModal({
|
||||
onOpenChange(open) {
|
||||
if (!open) previewItem.value = undefined;
|
||||
},
|
||||
});
|
||||
|
||||
function sourceOf(item: ChatImageAttachment) {
|
||||
return item.previewUrl || '';
|
||||
}
|
||||
|
||||
function resolvedUrl(item: ChatImageAttachment) {
|
||||
return resolvedUrls.value[sourceOf(item)] || '';
|
||||
}
|
||||
|
||||
const previewUrl = computed(() =>
|
||||
previewItem.value ? resolvedUrl(previewItem.value) : '',
|
||||
);
|
||||
|
||||
function canPreview(item: ChatImageAttachment) {
|
||||
const source = sourceOf(item);
|
||||
return (
|
||||
Boolean(resolvedUrl(item)) &&
|
||||
item.status !== 'error' &&
|
||||
item.status !== 'uploading' &&
|
||||
!failedSources.value.has(source)
|
||||
);
|
||||
}
|
||||
|
||||
function openPreview(item: ChatImageAttachment) {
|
||||
if (!canPreview(item)) return;
|
||||
previewItem.value = item;
|
||||
imagePreviewModalApi.open();
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
previewItem.value = undefined;
|
||||
imagePreviewModalApi.close();
|
||||
}
|
||||
|
||||
function releaseOwnedUrl(url?: string) {
|
||||
if (!url || !ownedUrls.delete(url)) return;
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function resolveImages() {
|
||||
const version = ++resolutionVersion;
|
||||
const activeSources = new Set(
|
||||
props.items.map((item) => sourceOf(item)).filter(Boolean),
|
||||
);
|
||||
if (previewItem.value && !activeSources.has(sourceOf(previewItem.value))) {
|
||||
closePreview();
|
||||
}
|
||||
const nextUrls = { ...resolvedUrls.value };
|
||||
for (const [source, url] of Object.entries(nextUrls)) {
|
||||
if (!activeSources.has(source)) {
|
||||
releaseOwnedUrl(url);
|
||||
delete nextUrls[source];
|
||||
}
|
||||
}
|
||||
resolvedUrls.value = nextUrls;
|
||||
failedSources.value = new Set(
|
||||
[...failedSources.value].filter((source) => activeSources.has(source)),
|
||||
);
|
||||
|
||||
for (const source of activeSources) {
|
||||
if (resolvedUrls.value[source]) continue;
|
||||
if (!props.imageLoader || /^(?:blob:|data:)/i.test(source)) {
|
||||
resolvedUrls.value = { ...resolvedUrls.value, [source]: source };
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const url = await props.imageLoader(source);
|
||||
if (version !== resolutionVersion || !activeSources.has(source)) {
|
||||
if (url.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||
continue;
|
||||
}
|
||||
if (url.startsWith('blob:')) ownedUrls.add(url);
|
||||
resolvedUrls.value = { ...resolvedUrls.value, [source]: url };
|
||||
} catch {
|
||||
if (version === resolutionVersion) {
|
||||
failedSources.value = new Set([source, ...failedSources.value]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
props.items.map((item) => sourceOf(item)).join('|'),
|
||||
props.imageLoader,
|
||||
] as const,
|
||||
() => void resolveImages(),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resolutionVersion += 1;
|
||||
for (const url of ownedUrls) URL.revokeObjectURL(url);
|
||||
ownedUrls.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="items.length > 0"
|
||||
class="chat-image-attachments"
|
||||
:class="{ 'is-compact': compact }"
|
||||
>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.uploadId || item.imageRef || item.localId || item.previewUrl"
|
||||
class="chat-image-attachments__item"
|
||||
:class="`is-${item.status || 'ready'}`"
|
||||
>
|
||||
<button
|
||||
v-if="resolvedUrl(item)"
|
||||
type="button"
|
||||
class="chat-image-attachments__preview-trigger"
|
||||
:disabled="!canPreview(item)"
|
||||
:aria-label="`查看完整图片:${item.name}`"
|
||||
title="查看完整图片"
|
||||
@click="openPreview(item)"
|
||||
>
|
||||
<img
|
||||
class="chat-image-attachments__image"
|
||||
:src="resolvedUrl(item)"
|
||||
:alt="item.name"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="item.status === 'uploading'"
|
||||
class="chat-image-attachments__state"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="chat-image-attachments__spinner"></span>
|
||||
上传中
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.status === 'error'"
|
||||
class="chat-image-attachments__state is-error"
|
||||
:title="item.error || '上传失败'"
|
||||
>
|
||||
上传失败
|
||||
</div>
|
||||
<div
|
||||
v-else-if="failedSources.has(sourceOf(item))"
|
||||
class="chat-image-attachments__state is-error"
|
||||
>
|
||||
加载失败
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!resolvedUrl(item)"
|
||||
class="chat-image-attachments__state"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="chat-image-attachments__spinner"></span>
|
||||
加载中
|
||||
</div>
|
||||
<div class="chat-image-attachments__name" :title="item.name">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="chat-image-attachments__actions">
|
||||
<button
|
||||
v-if="retryable && item.status === 'error'"
|
||||
type="button"
|
||||
class="chat-image-attachments__action"
|
||||
aria-label="重新上传"
|
||||
title="重新上传"
|
||||
@click.stop="emit('retry', item)"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
<button
|
||||
v-if="removable"
|
||||
type="button"
|
||||
class="chat-image-attachments__action"
|
||||
aria-label="移除图片"
|
||||
title="移除图片"
|
||||
@click.stop="emit('remove', item)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImagePreviewModal
|
||||
:bordered="false"
|
||||
centered
|
||||
class="!max-h-[calc(100vh-24px)] !w-fit max-w-[calc(100vw-24px)]"
|
||||
close-on-click-modal
|
||||
content-class="!p-0 !overflow-hidden"
|
||||
destroy-on-close
|
||||
:footer="false"
|
||||
:fullscreen-button="false"
|
||||
:header="false"
|
||||
>
|
||||
<img
|
||||
v-if="previewUrl"
|
||||
class="chat-image-preview__image"
|
||||
:src="previewUrl"
|
||||
:alt="previewItem?.name || '聊天图片'"
|
||||
/>
|
||||
</ImagePreviewModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-image-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2, 8px);
|
||||
align-items: flex-start;
|
||||
width: min(100%, 640px);
|
||||
}
|
||||
|
||||
.chat-image-attachments__item {
|
||||
position: relative;
|
||||
flex: 0 1 auto;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
}
|
||||
|
||||
.chat-image-attachments__preview-trigger {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
background: hsl(var(--surface-subtle));
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.chat-image-attachments__preview-trigger:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chat-image-attachments__preview-trigger:not(:disabled):hover {
|
||||
background: hsl(var(--surface-contrast-soft));
|
||||
}
|
||||
|
||||
.chat-image-attachments__preview-trigger:not(:disabled):focus-visible {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
outline: 2px solid var(--el-color-primary-light-5);
|
||||
outline: 2px solid
|
||||
color-mix(in srgb, var(--el-color-primary) 48%, transparent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.chat-image-attachments__image {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 160px;
|
||||
height: auto;
|
||||
max-height: 120px;
|
||||
}
|
||||
|
||||
.chat-image-attachments__name {
|
||||
max-width: 160px;
|
||||
padding: var(--space-1, 4px) var(--space-2, 8px);
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-image-attachments__state {
|
||||
position: absolute;
|
||||
inset: 0 0 25px;
|
||||
display: flex;
|
||||
gap: var(--space-1, 4px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
background: var(--el-bg-color);
|
||||
background: color-mix(in srgb, var(--el-bg-color) 84%, transparent);
|
||||
}
|
||||
|
||||
.chat-image-attachments__state.is-error {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.chat-image-attachments__spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--el-border-color);
|
||||
border-top-color: var(--el-color-primary);
|
||||
border-radius: 50%;
|
||||
animation: chat-image-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.chat-image-attachments__actions {
|
||||
position: absolute;
|
||||
top: var(--space-1, 4px);
|
||||
right: var(--space-1, 4px);
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chat-image-attachments__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
font-size: 17px;
|
||||
color: var(--el-text-color-primary);
|
||||
cursor: pointer;
|
||||
background: var(--el-bg-color);
|
||||
background: color-mix(in srgb, var(--el-bg-color) 88%, transparent);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.chat-image-attachments__action:hover,
|
||||
.chat-image-attachments__action:focus-visible {
|
||||
color: var(--el-color-primary);
|
||||
outline: 2px solid var(--el-color-primary-light-5);
|
||||
outline: 2px solid
|
||||
color-mix(in srgb, var(--el-color-primary) 36%, transparent);
|
||||
}
|
||||
|
||||
.chat-image-attachments.is-compact .chat-image-attachments__image {
|
||||
max-width: 240px;
|
||||
max-height: 160px;
|
||||
}
|
||||
|
||||
.chat-image-attachments.is-compact .chat-image-attachments__name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-image-attachments.is-compact .chat-image-attachments__state {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.chat-image-preview__image {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: calc(100vw - var(--space-6, 24px));
|
||||
height: auto;
|
||||
max-height: calc(100vh - var(--space-6, 24px));
|
||||
object-fit: scale-down;
|
||||
}
|
||||
|
||||
@keyframes chat-image-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ChatTimelineItem as ChatTimelineItemType,
|
||||
ChatImageLoader,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineToolApprovalPayload,
|
||||
} from './types';
|
||||
|
||||
import {nextTick, onBeforeUnmount, ref, watch} from 'vue';
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import ChatTimelineItem from './ChatTimelineItem.vue';
|
||||
|
||||
@@ -13,6 +14,7 @@ const props = defineProps<{
|
||||
approvalLoading?: boolean;
|
||||
copyable?: (item: ChatTimelineMessageItem) => boolean;
|
||||
emptyText?: string;
|
||||
imageLoader?: ChatImageLoader;
|
||||
items: ChatTimelineItemType[];
|
||||
regenerable?: (item: ChatTimelineMessageItem) => boolean;
|
||||
regenerateDisabled?: boolean;
|
||||
@@ -153,6 +155,7 @@ watch(
|
||||
:key="item.id"
|
||||
:assistant-actions-visible="isAssistantActionAnchor(item, index, items)"
|
||||
:item="item"
|
||||
:image-loader="imageLoader"
|
||||
:approval-loading="approvalLoading"
|
||||
:copyable="canCopyMessage(item)"
|
||||
:regenerable="canRegenerateMessage(item)"
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ChatTimelineItem,
|
||||
ChatImageLoader,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineMessagePart,
|
||||
ChatTimelineToolApprovalPayload,
|
||||
} from './types';
|
||||
|
||||
import {computed} from 'vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import ChatThinkingBlock from '../chat-thinking/ChatThinkingBlock.vue';
|
||||
import ChatErrorNotice from './ChatErrorNotice.vue';
|
||||
import ChatKnowledgeCard from './ChatKnowledgeCard.vue';
|
||||
import ChatImageAttachments from './ChatImageAttachments.vue';
|
||||
import ChatMessageToolbar from './ChatMessageToolbar.vue';
|
||||
import ChatTextBlock from './ChatTextBlock.vue';
|
||||
import ChatTimelineStatusRow from './ChatTimelineStatusRow.vue';
|
||||
@@ -22,6 +24,7 @@ const props = defineProps<{
|
||||
regenerable?: boolean;
|
||||
regenerateDisabled?: boolean;
|
||||
assistantActionsVisible?: boolean;
|
||||
imageLoader?: ChatImageLoader;
|
||||
item: ChatTimelineItem;
|
||||
variantLoading?: boolean;
|
||||
}>();
|
||||
@@ -127,6 +130,12 @@ function updateThinkingExpanded(partId: string, expanded: boolean) {
|
||||
{ 'has-variant-navigator': showVariantNavigator },
|
||||
]"
|
||||
>
|
||||
<ChatImageAttachments
|
||||
v-if="messageItem.images?.length"
|
||||
:items="messageItem.images"
|
||||
:image-loader="imageLoader"
|
||||
compact
|
||||
/>
|
||||
<template v-for="part in getMessageParts(messageItem)" :key="part.id">
|
||||
<ChatThinkingBlock
|
||||
v-if="part.type === 'thinking'"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ChatImageAttachment } from '../types';
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import ChatImageAttachments from '../ChatImageAttachments.vue';
|
||||
|
||||
const image: ChatImageAttachment = {
|
||||
name: '界面截图.png',
|
||||
previewUrl: 'data:image/png;base64,aW1hZ2U=',
|
||||
status: 'ready',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('chat image attachments', () => {
|
||||
it('opens the complete image preview from a ready thumbnail', async () => {
|
||||
const wrapper = mount(ChatImageAttachments, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
items: [image],
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const trigger = wrapper.get('[aria-label="查看完整图片:界面截图.png"]');
|
||||
expect(trigger.attributes('disabled')).toBeUndefined();
|
||||
expect(
|
||||
wrapper.get('.chat-image-attachments__image').attributes('src'),
|
||||
).toBe(image.previewUrl);
|
||||
|
||||
await trigger.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
const dialog = document.body.querySelector('[role="dialog"]');
|
||||
const preview = document.body.querySelector<HTMLImageElement>(
|
||||
'.chat-image-preview__image',
|
||||
);
|
||||
expect(dialog).not.toBeNull();
|
||||
expect(preview?.src).toBe(image.previewUrl);
|
||||
expect(preview?.alt).toBe(image.name);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps an uploading image unavailable for preview', async () => {
|
||||
const wrapper = mount(ChatImageAttachments, {
|
||||
props: {
|
||||
items: [{ ...image, status: 'uploading' }],
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const trigger = wrapper.get('[aria-label="查看完整图片:界面截图.png"]');
|
||||
expect(trigger.attributes('disabled')).toBeDefined();
|
||||
|
||||
await trigger.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the preview when its image is removed', async () => {
|
||||
const wrapper = mount(ChatImageAttachments, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
items: [image],
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
await wrapper
|
||||
.get('[aria-label="查看完整图片:界面截图.png"]')
|
||||
.trigger('click');
|
||||
await flushPromises();
|
||||
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
|
||||
await wrapper.setProps({ items: [] });
|
||||
await flushPromises();
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -319,7 +319,7 @@ export const ChatTimelineBuilder = {
|
||||
metadata?: Partial<ChatTimelineMessageItem>,
|
||||
) {
|
||||
const text = normalizeText(content);
|
||||
if (!text) {
|
||||
if (!text && !metadata?.images?.length) {
|
||||
return;
|
||||
}
|
||||
const item: ChatTimelineMessageItem = {
|
||||
@@ -327,13 +327,15 @@ export const ChatTimelineBuilder = {
|
||||
role: 'user',
|
||||
status: 'done',
|
||||
createdAt: Date.now(),
|
||||
parts: [
|
||||
{
|
||||
id: createId('text'),
|
||||
content: text,
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
parts: text
|
||||
? [
|
||||
{
|
||||
id: createId('text'),
|
||||
content: text,
|
||||
type: 'text' as const,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
type: 'message',
|
||||
...metadata,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { ChatTimelineBuilder } from './builder';
|
||||
export { default as ChatErrorNotice } from './ChatErrorNotice.vue';
|
||||
export { default as ChatKnowledgeCard } from './ChatKnowledgeCard.vue';
|
||||
export { default as ChatImageAttachments } from './ChatImageAttachments.vue';
|
||||
export { default as ChatMessageToolbar } from './ChatMessageToolbar.vue';
|
||||
export { default as ChatTextBlock } from './ChatTextBlock.vue';
|
||||
export { default as ChatTimeline } from './ChatTimeline.vue';
|
||||
@@ -11,6 +12,8 @@ export { default as ChatToolCard } from './ChatToolCard.vue';
|
||||
export { default as ChatVariantNavigator } from './ChatVariantNavigator.vue';
|
||||
export type {
|
||||
ChatTimelineErrorItem,
|
||||
ChatImageAttachment,
|
||||
ChatImageLoader,
|
||||
ChatTimelineItem,
|
||||
ChatTimelineItemStatus,
|
||||
ChatTimelineKnowledgeHit,
|
||||
|
||||
@@ -12,6 +12,22 @@ export type ChatTimelineToolStatus =
|
||||
export type ChatTimelineStatusStatus = 'done' | 'running';
|
||||
export type ChatTimelineStatusTone = 'muted';
|
||||
|
||||
export interface ChatImageAttachment {
|
||||
error?: string;
|
||||
height?: number;
|
||||
imageRef?: string;
|
||||
localId?: string;
|
||||
mimeType?: string;
|
||||
name: string;
|
||||
previewUrl: string;
|
||||
size?: number;
|
||||
status?: 'error' | 'ready' | 'uploading';
|
||||
uploadId?: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export type ChatImageLoader = (previewUrl: string) => Promise<string>;
|
||||
|
||||
export interface ChatTimelineToolApprovalPayload {
|
||||
requestId: string;
|
||||
resumeToken: string;
|
||||
@@ -50,6 +66,7 @@ export interface ChatTimelineItemBase {
|
||||
}
|
||||
|
||||
export interface ChatTimelineMessageItem extends ChatTimelineItemBase {
|
||||
images?: ChatImageAttachment[];
|
||||
knowledgeItems?: ChatTimelineKnowledgeHit[];
|
||||
parts: ChatTimelineMessagePart[];
|
||||
regenerable?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user