feat: 完善智能体图片聊天与会话恢复

- 增加私有图片上传、绑定、历史回显与生命周期清理

- 支持输入草稿恢复、图片交互和模型图片能力约束

- 修复旧脏会话幂等删除与前端会话恢复
This commit is contained in:
2026-07-17 19:54:26 +08:00
parent 62d763199f
commit 1e6158be77
62 changed files with 5333 additions and 189 deletions

View File

@@ -1,15 +1,16 @@
import type {ServerSentEventMessage} from 'fetch-event-stream';
import type { ServerSentEventMessage } from 'fetch-event-stream';
import type {
ChatImageAttachment,
ChatTimelineItem,
ChatTimelineKnowledgeHit,
ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload,
ChatTimelineToolStatus,
} from '@easyflow/common-ui';
import {ChatTimelineBuilder} from '@easyflow/common-ui';
import { ChatTimelineBuilder } from '@easyflow/common-ui';
import type {AgentChatMessageRecord} from '../api';
import type { AgentChatMessageRecord } from '../api';
export interface AgentSseEnvelope {
domain: string;
@@ -73,8 +74,7 @@ function isBlankToolName(value: unknown) {
function shouldSkipToolProjection(value: unknown) {
const normalizedName = normalizeToolName(value).toLowerCase();
return (
normalizedName === 'context_reload' ||
normalizedName === '__fragment__'
normalizedName === 'context_reload' || normalizedName === '__fragment__'
);
}
@@ -160,6 +160,26 @@ function normalizeKnowledgeItems(payload: Record<string, any>) {
.filter((item) => item.chunkContent || item.title || item.documentName);
}
function normalizeImages(payload: Record<string, any>) {
return asArray(payload.images)
.map((value): ChatImageAttachment | undefined => {
const image = asRecord(value);
const previewUrl = asText(image.previewUrl);
if (!previewUrl) return undefined;
return {
height: Number(image.height || 0) || undefined,
imageRef: asText(image.imageRef),
mimeType: asText(image.mimeType),
name: asText(image.name) || '图片',
previewUrl,
size: Number(image.size || 0) || undefined,
status: 'ready',
width: Number(image.width || 0) || undefined,
};
})
.filter((item): item is ChatImageAttachment => Boolean(item));
}
function buildApprovalPayload(payload: Record<string, any>) {
return {
expiresAt: asText(payload.expiresAt),
@@ -318,7 +338,10 @@ function appendHistoryRecord(
const role = normalizeRole(record.senderRole);
const metadata = normalizeMetadata(record);
if (role === 'user') {
ChatTimelineBuilder.appendUserMessage(items, record.contentText, metadata);
ChatTimelineBuilder.appendUserMessage(items, record.contentText, {
...metadata,
images: normalizeImages(asRecord(record.contentPayload)),
});
return;
}
if (role === 'system') {
@@ -446,8 +469,12 @@ export function applyAgentSseEnvelope(
ChatTimelineBuilder.upsertToolCall(items, {
input: payload.input ?? payload.toolInput,
output: asyncTool
? payload.summary ?? payload.label ?? payload.output ?? payload.result ?? payload.text
: payload.output ?? payload.result ?? payload.text,
? (payload.summary ??
payload.label ??
payload.output ??
payload.result ??
payload.text)
: (payload.output ?? payload.result ?? payload.text),
status: asyncTool
? asyncToolTimelineStatus(payload)
: type === 'TOOL_RESULT'

View File

@@ -0,0 +1,98 @@
// @vitest-environment happy-dom
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useUserStore } from '@easyflow/stores';
import { clearAgentChatBrowserCache } from '#/utils/agent-chat-cache';
import { sendAgentChat } from './api';
import { agentChatRuntimeManager } from './agentChatRuntimeManager';
vi.mock('./api', () => ({
generateAgentSessionId: vi.fn(),
sendAgentChat: vi.fn(),
stopAgentChatStream: vi.fn(),
}));
describe('agentChatRuntimeManager', () => {
beforeEach(() => {
setActivePinia(createPinia());
sessionStorage.clear();
vi.clearAllMocks();
});
it('replaces draft image URLs and isolates snapshots by account', async () => {
let callbacks: any;
vi.mocked(sendAgentChat).mockImplementation((_data, options) => {
callbacks = options;
return Promise.resolve() as any;
});
const userStore = useUserStore();
const firstAccount = {
avatar: '',
id: 'user-1',
loginName: 'admin',
nickname: '管理员',
tenantId: 'tenant-1',
};
userStore.setUserInfo(firstAccount);
await agentChatRuntimeManager.start({
agentId: 'agent-1',
images: [
{
name: 'draft.png',
previewUrl: '/api/v1/agent/media/content?reference=draft%3Aupload-1',
status: 'ready',
uploadId: 'upload-1',
},
],
prompt: '识别图片',
sessionId: '101',
});
callbacks.onMessage({
data: JSON.stringify({
domain: 'SYSTEM',
payload: {
images: [
{
imageRef: 'formal:101:201:0:png',
name: 'draft.png',
previewUrl:
'/api/v1/agent/media/content?reference=formal:101:201:0:png',
},
],
},
type: 'INPUT_ACCEPTED',
}),
});
const accepted = agentChatRuntimeManager.getSnapshot('101');
const userMessage = accepted?.items.find(
(item) => item.type === 'message' && item.role === 'user',
);
expect(
userMessage?.type === 'message' ? userMessage.images?.[0] : null,
).toEqual(
expect.objectContaining({
imageRef: 'formal:101:201:0:png',
previewUrl:
'/api/v1/agent/media/content?reference=formal:101:201:0:png',
}),
);
userStore.setUserInfo({
...firstAccount,
id: 'user-2',
loginName: 'other',
nickname: '其他用户',
});
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
clearAgentChatBrowserCache(firstAccount);
userStore.setUserInfo(firstAccount);
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
});
});

View File

@@ -1,16 +1,35 @@
import type {ChatTimelineItem} from '@easyflow/common-ui';
import {ChatTimelineBuilder} from '@easyflow/common-ui';
import type {
ChatImageAttachment,
ChatTimelineItem,
ChatTimelineMessageItem,
} from '@easyflow/common-ui';
import { ChatTimelineBuilder } from '@easyflow/common-ui';
import { useUserStore } from '@easyflow/stores';
import type {AgentChatCapabilityPayload} from './api';
import {generateAgentSessionId, sendAgentChat, stopAgentChatStream,} from './api';
import {
onAgentChatCacheClear,
resolveAgentChatIdentity,
RUNTIME_STORAGE_PREFIX,
} from '#/utils/agent-chat-cache';
import {applyAgentSseEnvelope, parseAgentSseMessage,} from './adapters/agentTimelineAdapter';
import type { AgentChatCapabilityPayload } from './api';
import {
generateAgentSessionId,
sendAgentChat,
stopAgentChatStream,
} from './api';
import {
applyAgentSseEnvelope,
parseAgentSseMessage,
} from './adapters/agentTimelineAdapter';
interface RuntimeSessionState {
agentId: string;
agentName?: string;
completed: boolean;
error?: string;
identity: string;
items: ChatTimelineItem[];
prompt: string;
roundId: string;
@@ -37,17 +56,18 @@ interface StartOptions {
agentName?: string;
baseItems?: ChatTimelineItem[];
capabilities?: AgentChatCapabilityPayload[];
imageUploadIds?: string[];
images?: ChatImageAttachment[];
onInputAccepted?: () => void | Promise<void>;
prompt: string;
sessionId?: string;
}
const STORAGE_PREFIX = 'easyflow:agent-chat-runtime';
const LATEST_STORAGE_KEY = `${STORAGE_PREFIX}:latest`;
const STORAGE_VERSION = 1;
const STORAGE_VERSION = 2;
const sessions = new Map<string, RuntimeSessionState>();
const listeners = new Set<() => void>();
let latestSessionId = '';
const latestSessionIds = new Map<string, string>();
function clone<T>(value: T): T {
const serialized = JSON.stringify(value);
@@ -58,8 +78,20 @@ function createRoundId() {
return `agent-chat-round-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
function storageKey(sessionId: string) {
return `${STORAGE_PREFIX}:${sessionId}`;
function identityScope() {
return resolveAgentChatIdentity(useUserStore().userInfo);
}
function sessionKey(identity: string, sessionId: string) {
return `${identity}:${sessionId}`;
}
function storageKey(identity: string, sessionId: string) {
return `${RUNTIME_STORAGE_PREFIX}:${identity}:${sessionId}`;
}
function latestStorageKey(identity: string) {
return `${RUNTIME_STORAGE_PREFIX}:${identity}:latest`;
}
function safeSessionStorage() {
@@ -94,15 +126,22 @@ function persistSession(state: RuntimeSessionState) {
version: STORAGE_VERSION,
};
try {
storage.setItem(storageKey(state.sessionId), JSON.stringify(snapshot));
storage.setItem(LATEST_STORAGE_KEY, state.sessionId);
storage.setItem(
storageKey(state.identity, state.sessionId),
JSON.stringify(snapshot),
);
storage.setItem(latestStorageKey(state.identity), state.sessionId);
} catch {
// 缓存失败不影响正式聊天主流程。
}
}
function restoreSession(sessionId: string) {
const existing = sessions.get(sessionId);
function restoreSession(identity: string, sessionId: string) {
if (!identity) {
return undefined;
}
const scopedSessionKey = sessionKey(identity, sessionId);
const existing = sessions.get(scopedSessionKey);
if (existing) {
return existing;
}
@@ -111,7 +150,7 @@ function restoreSession(sessionId: string) {
return undefined;
}
try {
const raw = storage.getItem(storageKey(sessionId));
const raw = storage.getItem(storageKey(identity, sessionId));
if (!raw) {
return undefined;
}
@@ -124,6 +163,7 @@ function restoreSession(sessionId: string) {
agentName: parsed.agentName,
completed: parsed.completed,
error: parsed.error,
identity,
items: Array.isArray(parsed.items) ? parsed.items : [],
prompt: parsed.prompt,
roundId: parsed.roundId,
@@ -131,7 +171,7 @@ function restoreSession(sessionId: string) {
sessionId,
updatedAt: parsed.updatedAt,
};
sessions.set(sessionId, restored);
sessions.set(scopedSessionKey, restored);
return restored;
} catch {
return undefined;
@@ -140,25 +180,30 @@ function restoreSession(sessionId: string) {
function upsertState(state: RuntimeSessionState) {
state.updatedAt = Date.now();
latestSessionId = state.sessionId;
sessions.set(state.sessionId, state);
latestSessionIds.set(state.identity, state.sessionId);
sessions.set(sessionKey(state.identity, state.sessionId), state);
persistSession(state);
notify();
}
function runningSession() {
return [...sessions.values()].find((session) => session.sending);
function runningSession(identity = identityScope()) {
return [...sessions.values()].find(
(session) => session.identity === identity && session.sending,
);
}
function restoreLatestSession() {
const running = runningSession();
function restoreLatestSession(identity = identityScope()) {
if (!identity) {
return undefined;
}
const running = runningSession(identity);
if (running) {
return running;
}
const storage = safeSessionStorage();
const storedSessionId = storage?.getItem(LATEST_STORAGE_KEY) || '';
const sessionId = latestSessionId || storedSessionId;
return sessionId ? restoreSession(sessionId) : undefined;
const storedSessionId = storage?.getItem(latestStorageKey(identity)) || '';
const sessionId = latestSessionIds.get(identity) || storedSessionId;
return sessionId ? restoreSession(identity, sessionId) : undefined;
}
async function resolveSessionId(sessionId?: string) {
@@ -176,12 +221,70 @@ function errorMessage(error: unknown) {
return error instanceof Error ? error.message : '发送失败,请稍后再试';
}
function normalizeAcceptedImages(payload: Record<string, any>) {
if (!Array.isArray(payload.images)) {
return [];
}
return payload.images
.map((value): ChatImageAttachment | undefined => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const image = value as Record<string, any>;
const previewUrl = String(image.previewUrl || '');
if (!previewUrl) {
return undefined;
}
return {
height: Number(image.height || 0) || undefined,
imageRef: String(image.imageRef || ''),
mimeType: String(image.mimeType || ''),
name: String(image.name || '图片'),
previewUrl,
size: Number(image.size || 0) || undefined,
status: 'ready',
width: Number(image.width || 0) || undefined,
};
})
.filter((image): image is ChatImageAttachment => Boolean(image));
}
function replaceAcceptedImages(
items: ChatTimelineItem[],
roundId: string,
payload: Record<string, any>,
) {
const acceptedImages = normalizeAcceptedImages(payload);
if (acceptedImages.length === 0) {
return;
}
const userMessage = items.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'user' &&
item.roundId === roundId,
);
if (userMessage) {
userMessage.images = acceptedImages;
}
}
onAgentChatCacheClear((identity) => {
for (const [key, session] of sessions) {
if (session.identity === identity) {
sessions.delete(key);
}
}
latestSessionIds.delete(identity);
notify();
});
export const agentChatRuntimeManager = {
getSnapshot(sessionId?: string) {
if (!sessionId) {
return undefined;
}
const state = restoreSession(sessionId);
const state = restoreSession(identityScope(), sessionId);
return state ? clone(state) : undefined;
},
@@ -195,7 +298,7 @@ export const agentChatRuntimeManager = {
},
replaceItems(sessionId: string, items: ChatTimelineItem[]) {
const state = restoreSession(sessionId);
const state = restoreSession(identityScope(), sessionId);
if (!state) {
return;
}
@@ -204,7 +307,11 @@ export const agentChatRuntimeManager = {
},
async start(options: StartOptions) {
const active = runningSession();
const identity = identityScope();
if (!identity) {
throw new Error('当前登录状态失效');
}
const active = runningSession(identity);
if (active) {
throw new Error('当前回复完成后再发送新消息');
}
@@ -214,6 +321,7 @@ export const agentChatRuntimeManager = {
agentId: options.agentId,
agentName: options.agentName,
completed: false,
identity,
items: clone(options.baseItems || []),
prompt: options.prompt,
roundId,
@@ -222,6 +330,7 @@ export const agentChatRuntimeManager = {
updatedAt: Date.now(),
};
ChatTimelineBuilder.appendUserMessage(state.items, options.prompt, {
images: options.images,
roundId,
});
upsertState(state);
@@ -230,12 +339,13 @@ export const agentChatRuntimeManager = {
{
agentId: options.agentId,
capabilities: options.capabilities,
imageUploadIds: options.imageUploadIds,
prompt: options.prompt,
sessionId,
},
{
onError(error) {
const current = sessions.get(sessionId);
const current = sessions.get(sessionKey(identity, sessionId));
if (!current || !current.sending) {
return;
}
@@ -247,7 +357,7 @@ export const agentChatRuntimeManager = {
upsertState(current);
},
onFinished() {
const current = sessions.get(sessionId);
const current = sessions.get(sessionKey(identity, sessionId));
if (!current) {
return;
}
@@ -257,7 +367,7 @@ export const agentChatRuntimeManager = {
upsertState(current);
},
onMessage(message) {
const current = sessions.get(sessionId);
const current = sessions.get(sessionKey(identity, sessionId));
if (!current || !current.sending) {
return;
}
@@ -265,6 +375,13 @@ export const agentChatRuntimeManager = {
if (!envelope) {
return;
}
if (
envelope.domain === 'SYSTEM' &&
envelope.type === 'INPUT_ACCEPTED'
) {
replaceAcceptedImages(current.items, roundId, envelope.payload);
void options.onInputAccepted?.();
}
applyAgentSseEnvelope(current.items, envelope, { roundId });
upsertState(current);
},
@@ -275,7 +392,10 @@ export const agentChatRuntimeManager = {
},
stop(sessionId?: string) {
const state = sessionId ? restoreSession(sessionId) : runningSession();
const identity = identityScope();
const state = sessionId
? restoreSession(identity, sessionId)
: runningSession(identity);
if (!state || !state.sending) {
return;
}

View File

@@ -1,8 +1,8 @@
import type {ServerSentEventMessage} from 'fetch-event-stream';
import type { ServerSentEventMessage } from 'fetch-event-stream';
import type {AgentInfo} from '../agents/types';
import type { AgentInfo } from '../agents/types';
import {api, SseClient} from '#/api/request';
import { api, SseClient } from '#/api/request';
const agentChatSseClient = new SseClient();
@@ -170,6 +170,7 @@ export function sendAgentChat(
data: {
agentId: number | string;
capabilities?: AgentChatCapabilityPayload[];
imageUploadIds?: string[];
prompt: string;
sessionId?: number | string;
},

View File

@@ -16,12 +16,17 @@ import type {
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ChatTimeline, ChatTimelineBuilder } from '@easyflow/common-ui';
import {
ChatImageAttachments,
ChatTimeline,
ChatTimelineBuilder,
} from '@easyflow/common-ui';
import {
Delete,
EditPen,
MoreFilled,
Paperclip,
Plus,
Promotion,
} from '@element-plus/icons-vue';
@@ -41,6 +46,8 @@ import {
import ChatCapabilityMenu from '#/components/chat-workspace/ChatCapabilityMenu.vue';
import ChatInputTriggerPanel from '#/components/chat-workspace/ChatInputTriggerPanel.vue';
import { useChatInputTrigger } from '#/components/chat-workspace/input-triggers/useChatInputTrigger';
import { useAgentComposerDraft } from '#/components/ai-chat/useAgentComposerDraft';
import { loadAgentChatImage } from '#/components/ai-chat/mediaApi';
import AgentWelcomeState from '../agents/components/AgentWelcomeState.vue';
import { resolveInteractionDisplay } from '../agents/interaction-config';
@@ -58,6 +65,10 @@ import {
renameAgentSession,
saveAgentSessionExtraKnowledges,
} from './api';
import {
isMissingAgentSessionError,
resolveAgentSessionErrorMessage,
} from './sessionRecovery';
const route = useRoute();
const router = useRouter();
@@ -67,8 +78,11 @@ const sessions = ref<AgentChatSessionView[]>([]);
const timelineItems = ref<ChatTimelineItem[]>([]);
const selectedAgentId = ref('');
const currentSessionId = ref('');
const promptText = ref('');
const composer = useAgentComposerDraft('FORMAL');
const promptText = composer.text;
const promptInputRef = ref();
const imageFileInputRef = ref<HTMLInputElement>();
const composerDragActive = ref(false);
const loadingAgents = ref(false);
const agentLoadError = ref('');
const loadingSessions = ref(false);
@@ -88,6 +102,11 @@ let runtimeUnsubscribe: (() => void) | undefined;
const selectedAgent = computed(() =>
agents.value.find((agent) => String(agent.id) === selectedAgentId.value),
);
const selectedAgentImageSupport = computed(() =>
Boolean(
selectedAgent.value?.publishedSnapshotJson?.modelSummary?.supportImage,
),
);
const interactionDisplay = computed(() =>
resolveInteractionDisplay(selectedAgent.value),
);
@@ -99,7 +118,12 @@ const currentSession = computed(() =>
const canStopRuntime = computed(() => sending.value || runtimeRunning.value);
const canSend = computed(
() =>
Boolean(promptText.value.trim()) &&
(Boolean(promptText.value.trim()) ||
composer.images.readyItems.value.length > 0) &&
!composer.images.uploading.value &&
!composer.images.items.value.some((item) => item.status === 'error') &&
(selectedAgentImageSupport.value !== false ||
composer.images.readyItems.value.length === 0) &&
Boolean(selectedAgentId.value) &&
!sending.value &&
!runtimeRunning.value,
@@ -217,6 +241,25 @@ async function syncSessionRoute(sessionId?: string) {
await router.replace({ query: nextQuery });
}
async function removeSessionFromPage(sessionId: string) {
sessions.value = sessions.value.filter(
(item) => String(item.sessionId) !== sessionId,
);
const isCurrentSession =
currentSessionId.value === sessionId ||
String(route.query.sessionId || '') === sessionId;
if (isCurrentSession) {
try {
await composer.clear();
} catch (error) {
ElMessage.warning(
error instanceof Error ? error.message : '会话草稿清理失败',
);
}
await createNewSession();
}
}
async function loadAgents() {
loadingAgents.value = true;
agentLoadError.value = '';
@@ -381,10 +424,10 @@ function buildOptimisticSession(
assistantName: selectedAgent.value?.name,
continuable: true,
lastMessageAt: new Date().toISOString(),
lastMessagePreview: prompt,
lastMessagePreview: prompt || '发送了图片',
messageCount: 1,
sessionId,
title: prompt.slice(0, 48) || '对话',
title: prompt.slice(0, 48) || '图片对话',
};
}
@@ -473,10 +516,17 @@ async function loadConversation(sessionId: string) {
}
}
currentSessionId.value = sessionId;
if (selectedAgentId.value) {
await activateComposer(selectedAgentId.value, sessionId);
}
sending.value = false;
await syncSessionRoute(sessionId);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '会话加载失败');
if (isMissingAgentSessionError(error)) {
await removeSessionFromPage(sessionId);
return;
}
ElMessage.error(resolveAgentSessionErrorMessage(error) || '会话加载失败');
} finally {
loadingConversation.value = false;
}
@@ -485,9 +535,17 @@ async function loadConversation(sessionId: string) {
async function createNewSession() {
currentSessionId.value = '';
timelineItems.value = [];
promptText.value = '';
extraKnowledgeIds.value = [];
sending.value = false;
if (selectedAgentId.value) {
try {
await composer.startNew(selectedAgentId.value);
} catch (error) {
ElMessage.warning(
error instanceof Error ? error.message : '新会话创建失败',
);
}
}
await syncSessionRoute();
}
@@ -508,10 +566,18 @@ async function bindCreatedSession(sessionId: string, prompt: string) {
await syncSessionRoute(sessionId);
}
function handleAgentChange() {
async function handleAgentChange() {
extraKnowledgeIds.value = [];
if (timelineItems.value.length > 0 || currentSessionId.value) {
void createNewSession();
await createNewSession();
} else {
await activateComposer(selectedAgentId.value);
}
if (
selectedAgentImageSupport.value === false &&
composer.images.items.value.length > 0
) {
ElMessage.warning('当前智能体不支持图片,请先移除图片');
}
}
@@ -573,14 +639,29 @@ function buildCapabilities() {
async function sendContent(rawContent: string) {
const content = rawContent.trim();
if (!content || !selectedAgentId.value || sending.value) {
if (
(!content && composer.images.readyItems.value.length === 0) ||
!selectedAgentId.value ||
sending.value
) {
return;
}
if (runtimeRunning.value) {
ElMessage.warning('当前回复完成后再发送新消息');
return;
}
promptText.value = '';
if (composer.images.uploading.value) {
ElMessage.warning('图片上传完成后再发送');
return;
}
const failedImage = composer.images.items.value.find(
(item) => item.status === 'error',
);
if (failedImage) {
ElMessage.error(failedImage.error || '请处理上传失败的图片');
return;
}
await composer.flush();
sending.value = true;
try {
const sessionId = await agentChatRuntimeManager.start({
@@ -588,14 +669,19 @@ async function sendContent(rawContent: string) {
agentName: selectedAgent.value?.name,
baseItems: timelineItems.value,
capabilities: buildCapabilities(),
imageUploadIds: composer.images.uploadIds.value,
images: composer.images.readyItems.value.map((item) => ({ ...item })),
onInputAccepted: () =>
composer.markAccepted().catch(() => {
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
}),
prompt: content,
sessionId: currentSessionId.value,
sessionId: composer.sessionId.value,
});
await bindCreatedSession(sessionId, content);
syncRuntimeSnapshot(sessionId);
} catch (error) {
sending.value = false;
promptText.value = content;
ElMessage.error(
error instanceof Error ? error.message : '发送失败,请稍后再试',
);
@@ -612,6 +698,112 @@ function handleSuggestedQuestion(question: string) {
function handlePromptInput() {
chatInputTrigger.sync();
composer.scheduleSave();
}
async function activateComposer(agentId: string, sessionId?: string) {
if (!agentId) return;
try {
await composer.activate(agentId, sessionId);
} catch (error) {
ElMessage.warning(
error instanceof Error ? error.message : '输入草稿恢复失败',
);
}
}
function chooseImageFiles() {
imageFileInputRef.value?.click();
}
async function addImageFiles(files: File[]) {
if (!selectedAgentId.value) {
ElMessage.warning('请先选择智能体');
return;
}
if (selectedAgentImageSupport.value === false) {
ElMessage.warning('当前智能体不支持图片');
return;
}
await composer.ensureSession();
const rejected = await composer.images.addFiles(files, {
agentId: selectedAgentId.value,
mode: 'FORMAL',
sessionId: composer.sessionId.value,
});
composer.scheduleSave();
if (rejected > 0) {
ElMessage.warning('每次最多添加 5 张图片');
}
}
function handleImageFiles(event: Event) {
const target = event.target as HTMLInputElement;
const files = [...(target.files || [])];
if (files.length) void addImageFiles(files);
target.value = '';
}
function handleImagePaste(event: ClipboardEvent) {
const files = [...(event.clipboardData?.files || [])].filter((file) =>
file.type.startsWith('image/'),
);
if (!files.length) return;
event.preventDefault();
void addImageFiles(files);
}
function handleImageDragEnter(event: DragEvent) {
if (
selectedAgentImageSupport.value !== false &&
!capabilityDisabled.value &&
composer.images.items.value.length < 5 &&
[...(event.dataTransfer?.types || [])].includes('Files')
) {
composerDragActive.value = true;
}
}
function handleImageDragLeave(event: DragEvent) {
const container = event.currentTarget as HTMLElement;
if (
!(event.relatedTarget instanceof Node) ||
!container.contains(event.relatedTarget)
) {
composerDragActive.value = false;
}
}
function handleImageDrop(event: DragEvent) {
composerDragActive.value = false;
if (
selectedAgentImageSupport.value === false ||
capabilityDisabled.value ||
composer.images.items.value.length >= 5
)
return;
const files = [...(event.dataTransfer?.files || [])].filter((file) =>
file.type.startsWith('image/'),
);
if (files.length) void addImageFiles(files);
}
async function retryImage(item: any) {
await composer.images.retry(item, {
agentId: selectedAgentId.value,
mode: 'FORMAL',
sessionId: composer.sessionId.value,
});
composer.scheduleSave();
}
async function removeImage(item: any) {
try {
await composer.images.remove(item);
composer.scheduleSave();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '图片删除失败');
}
}
function handlePromptKeyup() {
@@ -731,16 +923,15 @@ async function handleDeleteSession(session: AgentChatSessionView) {
if (res.errorCode !== 0) {
throw new Error(res.message || '删除失败');
}
sessions.value = sessions.value.filter(
(item) => String(item.sessionId) !== sessionId,
);
if (currentSessionId.value === sessionId) {
await createNewSession();
}
await removeSessionFromPage(sessionId);
ElMessage.success('已删除');
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error instanceof Error ? error.message : '删除失败');
if (isMissingAgentSessionError(error)) {
await removeSessionFromPage(sessionId);
return;
}
ElMessage.error(resolveAgentSessionErrorMessage(error) || '删除失败');
}
}
}
@@ -801,7 +992,10 @@ async function bootstrap() {
const latestSnapshot = agentChatRuntimeManager.getLatestSnapshot();
if (latestSnapshot?.items.length) {
syncRuntimeSnapshot(latestSnapshot.sessionId);
await activateComposer(selectedAgentId.value, latestSnapshot.sessionId);
return;
}
await activateComposer(selectedAgentId.value);
}
onMounted(() => {
@@ -936,6 +1130,7 @@ onBeforeUnmount(() => {
<ChatTimeline
v-else
:items="timelineItems"
:image-loader="loadAgentChatImage"
empty-text="选择智能体后开始对话"
:approval-loading="Boolean(approvalLoadingKey)"
:copyable="canCopyMessage"
@@ -947,7 +1142,14 @@ onBeforeUnmount(() => {
/>
</div>
<div class="agent-chat__composer">
<div
class="agent-chat__composer"
:class="{ 'is-dragging': composerDragActive }"
@dragenter.prevent="handleImageDragEnter"
@dragover.prevent
@dragleave.prevent="handleImageDragLeave"
@drop.prevent="handleImageDrop"
>
<ChatCapabilityMenu
:disabled="capabilityDisabled"
:extra-knowledge-ids="extraKnowledgeIds"
@@ -967,6 +1169,15 @@ onBeforeUnmount(() => {
@select="handleTriggerSelect"
@set-active="chatInputTrigger.setActiveIndex"
/>
<ChatImageAttachments
v-if="composer.images.items.value.length"
:items="composer.images.items.value"
:image-loader="loadAgentChatImage"
removable
retryable
@remove="removeImage"
@retry="retryImage"
/>
<ElInput
ref="promptInputRef"
v-model="promptText"
@@ -980,9 +1191,31 @@ onBeforeUnmount(() => {
@input="handlePromptInput"
@keydown="handlePromptKeydown"
@keyup="handlePromptKeyup"
@paste="handleImagePaste"
/>
<div class="agent-chat__composer-footer">
<div class="agent-chat__composer-tools">
<template v-if="selectedAgentImageSupport !== false">
<input
ref="imageFileInputRef"
class="agent-chat__image-file-input"
type="file"
accept=".png,.jpg,.jpeg,.webp,.gif,.bmp,image/png,image/jpeg,image/webp,image/gif,image/bmp"
multiple
@change="handleImageFiles"
/>
<ElButton
:icon="Paperclip"
circle
text
:disabled="
capabilityDisabled || composer.images.items.value.length >= 5
"
aria-label="添加图片"
title="添加图片"
@click="chooseImageFiles"
/>
</template>
<ChatCapabilityMenu
class="agent-chat__capability-entry"
:disabled="capabilityDisabled"
@@ -1177,12 +1410,11 @@ onBeforeUnmount(() => {
flex: 1;
flex-direction: column;
min-height: 0;
padding-bottom: 176px;
overflow: hidden;
}
.agent-chat__timeline-wrap.is-welcome {
padding: 0 min(8vw, 96px) 190px;
padding: 0 min(8vw, 96px);
overflow: hidden auto;
}
@@ -1195,20 +1427,24 @@ onBeforeUnmount(() => {
}
.agent-chat__composer {
position: absolute;
right: min(8vw, 96px);
bottom: 24px;
left: min(8vw, 96px);
position: relative;
display: flex;
flex: none;
flex-direction: column;
gap: 8px;
padding: 16px;
margin: 0 min(8vw, 96px) 24px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 24px;
box-shadow: var(--el-box-shadow-light);
}
.agent-chat__composer.is-dragging {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
.agent-chat__trigger-panel {
position: absolute;
bottom: calc(100% + 10px);
@@ -1241,6 +1477,10 @@ onBeforeUnmount(() => {
max-width: calc(100% - 64px);
}
.agent-chat__image-file-input {
display: none;
}
.agent-chat__capability-entry {
flex: none;
}
@@ -1343,7 +1583,7 @@ onBeforeUnmount(() => {
}
.agent-chat__timeline-wrap {
padding-bottom: 184px;
padding-bottom: 0;
}
.agent-chat__timeline-wrap.is-welcome {
@@ -1353,7 +1593,7 @@ onBeforeUnmount(() => {
.agent-chat__timeline-wrap.is-welcome :deep(.agent-welcome) {
flex: 0 0 auto;
min-height: 100%;
padding-bottom: 206px;
padding-bottom: 16px;
}
.agent-chat__timeline-wrap :deep(.chat-timeline) {
@@ -1361,9 +1601,7 @@ onBeforeUnmount(() => {
}
.agent-chat__composer {
right: 16px;
bottom: 16px;
left: 16px;
margin: 0 16px 16px;
}
.agent-chat__composer-footer {

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import {
isMissingAgentSessionError,
resolveAgentSessionErrorMessage,
} from './sessionRecovery';
describe('agent chat session recovery', () => {
it('识别请求层直接抛出的业务响应', () => {
const error = {
errorCode: 400,
message: 'Agent 会话不存在',
};
expect(isMissingAgentSessionError(error)).toBe(true);
});
it('识别包含响应体的网络错误', () => {
const error = {
response: {
data: {
message: 'Agent 会话不存在',
},
},
};
expect(isMissingAgentSessionError(error)).toBe(true);
});
it('保留其他错误消息供页面展示', () => {
const error = new Error('服务暂时不可用');
expect(isMissingAgentSessionError(error)).toBe(false);
expect(resolveAgentSessionErrorMessage(error)).toBe('服务暂时不可用');
});
});

View File

@@ -0,0 +1,36 @@
const MISSING_AGENT_SESSION_MESSAGE = 'Agent 会话不存在';
/**
* 提取请求层抛出的错误消息。
*/
export function resolveAgentSessionErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (!error || typeof error !== 'object') {
return '';
}
const payload = error as Record<string, unknown>;
if (typeof payload.message === 'string') {
return payload.message;
}
const response = payload.response;
if (!response || typeof response !== 'object') {
return '';
}
const data = (response as Record<string, unknown>).data;
if (!data || typeof data !== 'object') {
return '';
}
const message = (data as Record<string, unknown>).message;
return typeof message === 'string' ? message : '';
}
/**
* 判断请求是否因 Agent 会话已不存在而失败。
*/
export function isMissingAgentSessionError(error: unknown): boolean {
return resolveAgentSessionErrorMessage(error).includes(
MISSING_AGENT_SESSION_MESSAGE,
);
}

View File

@@ -42,6 +42,12 @@ const selectedKnowledge = computed(() => {
const activeBaseTab = ref<'basic' | 'interaction'>('basic');
const interactionForm = ref<InstanceType<typeof AgentInteractionForm>>();
const selectedModel = computed(() =>
props.models.find((item) => item.value === String(props.state.agent.modelId)),
);
const tryoutImageEnabled = computed(() =>
Boolean(selectedModel.value?.raw?.supportImage),
);
function isInteractionIssue(issue?: AgentValidationIssue) {
return issue?.field?.startsWith('interaction.');
@@ -95,6 +101,7 @@ const selectedToolOptions = computed(() => {
<template v-if="state.panelMode === 'tryout'">
<AgentTryoutPanel
:agent="state.agent"
:image-enabled="tryoutImageEnabled"
:tool-bindings="state.toolBindings"
:knowledge-bindings="state.knowledgeBindings"
@close="emit('closeTryout')"

View File

@@ -18,6 +18,8 @@ import { BrushCleaning } from '@easyflow/icons';
import { ElButton, ElMessage } from 'element-plus';
import AiChatPanel from '#/components/ai-chat/AiChatPanel.vue';
import { loadAgentChatImage } from '#/components/ai-chat/mediaApi';
import { useAgentComposerDraft } from '#/components/ai-chat/useAgentComposerDraft';
import { approveAgentRun, rejectAgentRun } from '../api';
import { useAgentTryoutStream } from '../composables/useAgentTryoutStream';
@@ -26,6 +28,7 @@ import AgentWelcomeState from './AgentWelcomeState.vue';
const props = defineProps<{
agent: AgentInfo;
imageEnabled?: boolean;
knowledgeBindings: AgentKnowledgeBinding[];
toolBindings: AgentToolBinding[];
}>();
@@ -45,6 +48,7 @@ const {
stop,
} = useAgentTryoutStream();
const approvalLoading = ref(false);
const composer = useAgentComposerDraft('DRAFT');
const interactionDisplay = computed(() =>
resolveInteractionDisplay(props.agent),
);
@@ -58,18 +62,27 @@ function getDraftContext() {
}
function syncCurrentDraftContext(restore = false) {
syncDraftContext(getDraftContext(), restore);
syncDraftContext(getDraftContext(), restore, composer.sessionId.value);
}
onMounted(() => {
syncCurrentDraftContext(true);
});
async function activateComposer() {
const agentId = String(props.agent.id || '');
if (!agentId) return;
try {
await composer.activate(agentId, `agent-draft-${agentId}`);
syncCurrentDraftContext(true);
} catch (error) {
ElMessage.warning(
error instanceof Error ? error.message : '输入草稿恢复失败',
);
}
}
onMounted(() => void activateComposer());
watch(
() => [props.agent.id, props.agent.localId],
() => {
syncCurrentDraftContext(true);
},
() => void activateComposer(),
);
watch(
@@ -82,12 +95,35 @@ watch(
async function handleSend(prompt: string) {
if (loading.value || approvalLoading.value) return;
if (composer.images.uploading.value) {
ElMessage.warning('图片上传完成后再发送');
return;
}
const failedImage = composer.images.items.value.find(
(item) => item.status === 'error',
);
if (failedImage) {
ElMessage.error(failedImage.error || '请处理上传失败的图片');
return;
}
await composer.flush();
await sendDraft({
...getDraftContext(),
prompt,
imageUploadIds: composer.images.uploadIds.value,
images: composer.images.readyItems.value.map((item) => ({ ...item })),
sessionId: composer.sessionId.value,
onAccepted: () =>
composer.markAccepted().catch(() => {
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
}),
});
}
function handleDraftTextInput() {
composer.scheduleSave();
}
function handleSuggestedQuestion(question: string) {
void handleSend(question);
}
@@ -137,12 +173,49 @@ function handleSelectNextVariant(item: ChatTimelineMessageItem) {
async function handleClearSession() {
try {
await clearDraftSession();
await composer.clear();
await activateComposer();
ElMessage.success('已清理会话');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清理会话失败');
}
}
async function handleAddFiles(files: File[]) {
if (!props.agent.id) {
ElMessage.warning('请先保存智能体');
return;
}
await composer.ensureSession();
const rejected = await composer.images.addFiles(files, {
agentId: String(props.agent.id),
mode: 'DRAFT',
sessionId: composer.sessionId.value,
});
if (rejected > 0) {
ElMessage.warning('每次最多添加 5 张图片');
}
composer.scheduleSave();
}
async function handleRetryImage(item: any) {
await composer.images.retry(item, {
agentId: String(props.agent.id),
mode: 'DRAFT',
sessionId: composer.sessionId.value,
});
composer.scheduleSave();
}
async function handleRemoveImage(item: any) {
try {
await composer.images.remove(item);
composer.scheduleSave();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '图片删除失败');
}
}
function handleStop() {
if (!loading.value) {
return;
@@ -189,14 +262,22 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
<template>
<AiChatPanel
v-model="composer.text.value"
:title="agent.name || '草稿试运行'"
empty-text="输入问题试运行当前智能体"
closable
:messages="[]"
:loading="loading"
:images="composer.images.items.value"
:image-enabled="imageEnabled"
:image-loader="loadAgentChatImage"
:placeholder="interactionDisplay.inputPlaceholder"
:approval-loading="approvalLoading"
@send="handleSend"
@update:model-value="handleDraftTextInput"
@add-files="handleAddFiles"
@remove-image="handleRemoveImage"
@retry-image="handleRetryImage"
@stop="handleStop"
@approve="handleApprove"
@reject="handleReject"
@@ -226,6 +307,7 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
<ChatTimeline
v-else
:items="timelineItems"
:image-loader="loadAgentChatImage"
empty-text="输入问题试运行当前智能体"
:approval-loading="approvalLoading"
:copyable="canCopyMessage"

View File

@@ -1,10 +1,11 @@
import type {
ChatImageAttachment,
ChatTimelineItem,
ChatTimelineKnowledgeHit,
ChatTimelineMessageItem,
ChatTimelineToolStatus,
} from '@easyflow/common-ui';
import {ChatTimelineBuilder} from '@easyflow/common-ui';
import { ChatTimelineBuilder } from '@easyflow/common-ui';
interface AgentTryoutRuntimeEvent {
createdAt: number;
@@ -25,6 +26,7 @@ interface AgentTryoutRawVariant {
interface AgentTryoutRawRound {
createdAt: number;
images?: ChatImageAttachment[];
prompt: string;
roundId: string;
selectedVariantIndex: number;
@@ -110,7 +112,9 @@ function createVariant(variantIndex: number): AgentTryoutRawVariant {
};
}
function normalizeRuntimeEvent(value: any): AgentTryoutRuntimeEvent | undefined {
function normalizeRuntimeEvent(
value: any,
): AgentTryoutRuntimeEvent | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
@@ -135,8 +139,9 @@ function normalizeVariant(value: any, index: number) {
? value.runtimeEvents
.map((item: any) => normalizeRuntimeEvent(item))
.filter(
(item: AgentTryoutRuntimeEvent | undefined): item is AgentTryoutRuntimeEvent =>
Boolean(item),
(
item: AgentTryoutRuntimeEvent | undefined,
): item is AgentTryoutRuntimeEvent => Boolean(item),
)
: [];
return {
@@ -157,7 +162,8 @@ function normalizeRound(value: any): AgentTryoutRawRound | undefined {
}
const prompt = asText(value.prompt);
const roundId = asText(value.roundId);
if (!prompt || !roundId) {
const images = Array.isArray(value.images) ? value.images.slice(0, 5) : [];
if ((!prompt && images.length === 0) || !roundId) {
return undefined;
}
const variants = Array.isArray(value.variants)
@@ -174,6 +180,7 @@ function normalizeRound(value: any): AgentTryoutRawRound | undefined {
);
return {
createdAt: Number(value.createdAt || Date.now()),
images,
prompt,
roundId,
selectedVariantIndex,
@@ -210,7 +217,10 @@ function restoreSession(mode: string, sessionId: string) {
.map((item) => normalizeRound(item))
.filter((item): item is AgentTryoutRawRound => Boolean(item))
: [];
memorySessions.set(key, rounds.map((item) => clone(item)));
memorySessions.set(
key,
rounds.map((item) => clone(item)),
);
return rounds;
} catch {
return [];
@@ -228,7 +238,10 @@ function persistSession(
sessionId,
version: STORAGE_VERSION,
};
memorySessions.set(key, snapshot.rounds.map((item) => clone(item)));
memorySessions.set(
key,
snapshot.rounds.map((item) => clone(item)),
);
const storage = safeSessionStorage();
if (!storage) {
return;
@@ -269,7 +282,9 @@ function visibleText(item: ChatTimelineMessageItem) {
.join('');
}
function isUserMessage(item: ChatTimelineItem): item is ChatTimelineMessageItem {
function isUserMessage(
item: ChatTimelineItem,
): item is ChatTimelineMessageItem {
return item.type === 'message' && item.role === 'user';
}
@@ -326,7 +341,10 @@ function normalizeAssistantPartIds(
const segment = assistantSegmentIndex(items, roundId);
const latest = [...items]
.reverse()
.find((item): item is ChatTimelineMessageItem => isAssistantMessage(item) && item.roundId === roundId);
.find(
(item): item is ChatTimelineMessageItem =>
isAssistantMessage(item) && item.roundId === roundId,
);
if (!latest) {
return;
}
@@ -468,7 +486,9 @@ function projectEventToTimeline(
metadata: payload.metadata,
requestId: asText(payload.requestId),
resumeToken: asText(payload.resumeToken),
toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id),
toolCallId: asText(
payload.toolCallId ?? payload.tool_call_id ?? payload.id,
),
toolDisplayName: asText(payload.toolDisplayName),
toolName: asText(payload.toolName),
toolType: asText(payload.toolType),
@@ -482,7 +502,9 @@ function projectEventToTimeline(
ChatTimelineBuilder.markToolApproving(items, {
requestId: asText(payload.requestId),
resumeToken: asText(payload.resumeToken),
toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id),
toolCallId: asText(
payload.toolCallId ?? payload.tool_call_id ?? payload.id,
),
});
return;
}
@@ -491,7 +513,9 @@ function projectEventToTimeline(
reason: asText(payload.reason),
requestId: asText(payload.requestId),
resumeToken: asText(payload.resumeToken),
toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id),
toolCallId: asText(
payload.toolCallId ?? payload.tool_call_id ?? payload.id,
),
});
return;
}
@@ -508,8 +532,12 @@ function projectEventToTimeline(
ChatTimelineBuilder.upsertToolCall(items, {
input: payload.input ?? payload.toolInput,
output: asyncTool
? payload.summary ?? payload.label ?? payload.output ?? payload.result ?? payload.text
: payload.output ?? payload.result ?? payload.text,
? (payload.summary ??
payload.label ??
payload.output ??
payload.result ??
payload.text)
: (payload.output ?? payload.result ?? payload.text),
status: asyncTool
? asyncToolTimelineStatus(payload)
: type === 'TOOL_RESULT'
@@ -521,8 +549,17 @@ function projectEventToTimeline(
variantIndex,
'knowledge-retrieval',
),
toolCallId: asText(payload.toolCallId ?? payload.taskId ?? payload.tool_call_id ?? payload.id),
toolName: asyncTool ? displayToolName : isHiddenToolName(rawToolName) ? rawToolName : displayToolName,
toolCallId: asText(
payload.toolCallId ??
payload.taskId ??
payload.tool_call_id ??
payload.id,
),
toolName: asyncTool
? displayToolName
: isHiddenToolName(rawToolName)
? rawToolName
: displayToolName,
});
return;
}
@@ -568,7 +605,9 @@ function projectEventToTimeline(
}
}
function asyncToolTimelineStatus(payload: Record<string, unknown>): ChatTimelineToolStatus {
function asyncToolTimelineStatus(
payload: Record<string, unknown>,
): ChatTimelineToolStatus {
const status = asText(payload.status).toUpperCase();
if (status === 'SUCCEEDED') return 'success';
if (status === 'FAILED' || status === 'TIMEOUT' || status === 'CANCELLED') {
@@ -612,7 +651,10 @@ export function useAgentTryoutRawRounds(options: {
function schedulePersist() {
const key = storageKey(options.mode, options.sessionId);
memorySessions.set(key, [...rounds.values()].map((item) => clone(item)));
memorySessions.set(
key,
[...rounds.values()].map((item) => clone(item)),
);
if (persistTimer) {
return;
}
@@ -630,11 +672,12 @@ export function useAgentTryoutRawRounds(options: {
removeStoredSession(options.mode, options.sessionId);
}
function createRound(prompt: string) {
function createRound(prompt: string, images: ChatImageAttachment[] = []) {
const now = Date.now();
const roundId = createRoundId();
rounds.set(roundId, {
createdAt: now,
images: clone(images),
prompt,
roundId,
selectedVariantIndex: 1,
@@ -738,6 +781,7 @@ export function useAgentTryoutRawRounds(options: {
for (const round of sortedRounds(rounds)) {
ChatTimelineBuilder.appendUserMessage(items, round.prompt, {
id: `user-${round.roundId}`,
images: round.images,
roundId: round.roundId,
});
const variant = selectedVariant(round);
@@ -745,7 +789,12 @@ export function useAgentTryoutRawRounds(options: {
continue;
}
for (const event of variant.runtimeEvents) {
projectEventToTimeline(items, event, round.roundId, variant.variantIndex);
projectEventToTimeline(
items,
event,
round.roundId,
variant.variantIndex,
);
}
if (variant.status === 'completed' || variant.status === 'error') {
ChatTimelineBuilder.finalize(items);
@@ -761,10 +810,7 @@ export function useAgentTryoutRawRounds(options: {
return items;
}
function selectVariant(
roundId: string,
direction: 'next' | 'previous',
) {
function selectVariant(roundId: string, direction: 'next' | 'previous') {
const round = rounds.get(roundId);
if (!round) {
return;

View File

@@ -1,19 +1,24 @@
import type {ServerSentEventMessage} from 'fetch-event-stream';
import type { ServerSentEventMessage } from 'fetch-event-stream';
import type {
ChatImageAttachment,
ChatTimelineItem as ChatTimelineItemType,
ChatTimelineMessageItem,
} from '@easyflow/common-ui';
import {ChatTimelineBuilder} from '@easyflow/common-ui';
import { ChatTimelineBuilder } from '@easyflow/common-ui';
import type {AgentInfo, AgentKnowledgeBinding, AgentToolBinding,} from '../types';
import type {
AgentInfo,
AgentKnowledgeBinding,
AgentToolBinding,
} from '../types';
import {ref} from 'vue';
import { ref } from 'vue';
import {sseClient} from '#/api/request';
import { sseClient } from '#/api/request';
import {clearAgentDraftSession} from '../api';
import {useAgentTryoutRawRounds} from './useAgentTryoutRawRounds';
import { clearAgentDraftSession } from '../api';
import { useAgentTryoutRawRounds } from './useAgentTryoutRawRounds';
function resolveDraftSessionId(agent: AgentInfo) {
return `agent-draft-${agent.id || agent.localId || 'unsaved'}`;
@@ -99,8 +104,13 @@ export function useAgentTryoutStream() {
timelineItems.value = rawRounds?.buildTimelineItems() || [];
}
function syncDraftContext(payload: DraftRuntimeContext, restore = false) {
const sessionId = resolveDraftSessionId(payload.agent);
function syncDraftContext(
payload: DraftRuntimeContext,
restore = false,
requestedSessionId?: string,
) {
const sessionId =
requestedSessionId || resolveDraftSessionId(payload.agent);
const sessionChanged = activeSessionId !== sessionId;
activeSessionId = sessionId;
if (!rawRounds || sessionChanged) {
@@ -206,26 +216,44 @@ export function useAgentTryoutStream() {
async function runDraft(payload: {
agent: AgentInfo;
images?: ChatImageAttachment[];
imageUploadIds?: string[];
knowledgeBindings: AgentKnowledgeBinding[];
onAccepted?: () => void | Promise<void>;
prompt: string;
sessionId?: string;
toolBindings: AgentToolBinding[];
}) {
syncDraftContext(payload);
syncDraftContext(payload, false, payload.sessionId);
if (!rawRounds) {
return;
}
activeRoundId = rawRounds.createRound(payload.prompt);
activeRoundId = rawRounds.createRound(payload.prompt, payload.images);
rebuildTimeline();
loading.value = true;
userStopped = false;
let accepted = false;
await sseClient.post(
'/api/v1/agent/chat/draft',
{
...payload,
agent: payload.agent,
imageUploadIds: payload.imageUploadIds,
knowledgeBindings: payload.knowledgeBindings,
prompt: payload.prompt,
sessionId: activeSessionId,
toolBindings: payload.toolBindings,
},
{
onMessage: handleMessage,
onMessage: (message) => {
const envelope = resolveEnvelope(parseEventData(message));
const domain = String(envelope.domain || '').toUpperCase();
const type = String(envelope.type || '').toUpperCase();
if (!accepted && domain === 'SYSTEM' && type === 'INPUT_ACCEPTED') {
accepted = true;
void payload.onAccepted?.();
}
handleMessage(message);
},
onError: (error) => {
if (shouldIgnoreStoppedError(error)) {
return;
@@ -257,8 +285,12 @@ export function useAgentTryoutStream() {
async function sendDraft(payload: {
agent: AgentInfo;
images?: ChatImageAttachment[];
imageUploadIds?: string[];
knowledgeBindings: AgentKnowledgeBinding[];
onAccepted?: () => void | Promise<void>;
prompt: string;
sessionId?: string;
toolBindings: AgentToolBinding[];
}) {
await runDraft(payload);

View File

@@ -88,6 +88,11 @@ const formData = reactive<FormData>({
});
const modelAbility = ref<ModelAbilityItem[]>(getDefaultModelAbility());
const visibleModelAbility = computed(() =>
modelAbility.value.filter(
(item) => item.field !== 'supportImageB64Only' || formData.supportImage,
),
);
type SelectableModelType = '' | 'embeddingModel' | 'rerankModel';
const selectedModelType = ref<SelectableModelType>('');
@@ -129,6 +134,13 @@ const handleTagClick = (item: ModelAbilityItem) => {
}
item.selected = !item.selected;
formData[item.field] = item.selected;
if (item.field === 'supportImage' && !item.selected) {
formData.supportImageB64Only = false;
const base64Ability = modelAbility.value.find(
(ability) => ability.field === 'supportImageB64Only',
);
if (base64Ability) base64Ability.selected = false;
}
};
const handleModelTypeChipClick = (
@@ -353,7 +365,7 @@ const save = async () => {
aria-hidden="true"
></span>
<button
v-for="item in modelAbility"
v-for="item in visibleModelAbility"
:key="item.value"
type="button"
class="model-modal__ability-chip"