diff --git a/packages/conversation/demo/api/apply-gateway-stream.ts b/packages/conversation/demo/api/apply-gateway-stream.ts index a4e387b74de754eecc6e0089fda7b0fcd1a710f1..efc24446e9a2cbd79fc351fd0f31d4c7d66bf32c 100644 --- a/packages/conversation/demo/api/apply-gateway-stream.ts +++ b/packages/conversation/demo/api/apply-gateway-stream.ts @@ -1,219 +1 @@ -/** - * 将流式 Gateway 事件增量应用到消息列表(同 streamId 合并为一条助手消息)。 - */ -import type { Message } from '../../src/components/conversation/conversation.props'; -import type { GatewayEnvelope, WireStreamContent } from './wire-types'; -import type { MessageContentMarkdown } from '../../src/components/markdown/types'; -import type { MessageContentAgentThinking } from '../../src/components/enterprise-cloud/types'; -import { normalizeWireBodyToMessage } from './normalize-wire'; - -const ASSISTANT_NAME = 'inBuilder'; - -function streamPlaceHolderId(streamId: string, wireType: string): string { - return `stream-${wireType.replace(/\//g, '-')}-${streamId}`; -} - -function ensureThinkingMessage(messages: Message[], streamId: string): number { - const wireType = 'agent/thinking'; - const id = streamPlaceHolderId(streamId, wireType); - let idx = messages.findIndex((m) => m.id === id); - if (idx >= 0) return idx; - const msg: Message = { - id, - name: ASSISTANT_NAME, - role: 'assistant', - timestamp: Date.now(), - agentId: '', - content: { - type: 'AgentThinking', - standardType: 'agent/thinking', - streamStatus: 'continue', - text: '', - sources: undefined - } - }; - messages.push(msg); - return messages.length - 1; -} - -function ensureMarkdownMessage(messages: Message[], streamId: string): number { - const wireType = 'agent/dynamic-markdown'; - const id = streamPlaceHolderId(streamId, wireType); - let idx = messages.findIndex((m) => m.id === id); - if (idx >= 0) return idx; - const msg: Message = { - id, - name: ASSISTANT_NAME, - role: 'assistant', - timestamp: Date.now(), - agentId: '', - content: { - type: 'markdown', - message: '正式回答(流式输出)', - content: '' - } as MessageContentMarkdown - }; - messages.push(msg); - return messages.length - 1; -} - -function patchThinking(messages: Message[], streamId: string, content: WireStreamContent): void { - const idx = ensureThinkingMessage(messages, streamId); - const m = messages[idx]; - const cur = m.content as MessageContentAgentThinking; - const piece = content.chunk ?? ''; - const nextText = piece ? cur.text + piece : cur.text; - const nextStatus = - content.streamStatus === 'end' ? 'end' : content.streamStatus === 'start' ? 'continue' : 'continue'; - messages[idx] = { - ...m, - content: { - ...cur, - text: nextText, - streamStatus: content.streamStatus === 'end' ? 'end' : nextStatus, - sources: content.sources ?? cur.sources - } - }; -} - -function patchMarkdown(messages: Message[], streamId: string, content: WireStreamContent): void { - const idx = ensureMarkdownMessage(messages, streamId); - const m = messages[idx]; - const cur = m.content as MessageContentMarkdown; - const piece = content.chunk ?? ''; - const nextBody = piece ? cur.content + piece : cur.content; - messages[idx] = { - ...m, - content: { - ...cur, - content: nextBody - } - }; -} - -function applyCompletion(messages: Message[], body: GatewayEnvelope['body']): void { - const c = body.content as WireStreamContent; - const streamId = c?.streamId; - if (!streamId) return; - const idThink = streamPlaceHolderId(streamId, 'agent/thinking'); - const idMd = streamPlaceHolderId(streamId, 'agent/dynamic-markdown'); - - if (body.type === 'agent/thinking-completion') { - const idx = messages.findIndex((m) => m.id === idThink); - const text = c?.text ?? ''; - if (idx >= 0) { - const m = messages[idx]; - const cur = m.content as MessageContentAgentThinking; - messages[idx] = { - ...m, - content: { ...cur, text: text || cur.text, streamStatus: 'end' } - }; - } else if (text) { - messages.push({ - id: body.id || idThink, - name: ASSISTANT_NAME, - role: 'assistant', - timestamp: Date.now(), - agentId: '', - content: { - type: 'AgentThinking', - streamStatus: 'end', - text, - sources: c.sources - } - }); - } - } - - if (body.type === 'agent/dynamic-markdown-completion' || body.type === 'agent/markdown-completion') { - const idx = messages.findIndex((m) => m.id === idMd); - const text = c?.text ?? ''; - if (idx >= 0) { - const m = messages[idx]; - const cur = m.content as MessageContentMarkdown; - messages[idx] = { - ...m, - content: { ...cur, content: text || cur.content } - }; - } else if (text) { - messages.push({ - id: body.id || idMd, - name: ASSISTANT_NAME, - role: 'assistant', - timestamp: Date.now(), - agentId: '', - content: { type: 'markdown', message: '正式回答', content: text } - }); - } - } -} - -/** @returns 新 messages 数组(immutable) */ -export function applyGatewayEnvelope(messages: Message[], env: GatewayEnvelope): Message[] { - const next = [...messages]; - const { body } = env; - const t = body.type; - const content = body.content as WireStreamContent | undefined; - - if (t === 'agent/thinking' && content?.streamId) { - patchThinking(next, content.streamId, content); - return next; - } - if (t === 'agent/dynamic-markdown' && content?.streamId) { - patchMarkdown(next, content.streamId, content); - return next; - } - if ( - t === 'agent/thinking-completion' || - t === 'agent/dynamic-markdown-completion' || - t === 'agent/markdown-completion' - ) { - applyCompletion(next, body); - return next; - } - - /** - * 代码修改:同一 body.id 多帧 = 原地更新同一条 Coding 消息(全量替换 content,与 task-plan 模式一致)。 - */ - if ((t === 'agent/code-change' || t === 'agent/coding') && body.id) { - const msg = normalizeWireBodyToMessage(body); - if (!msg) return next; - const idx = next.findIndex((m) => m.id === body.id); - if (idx >= 0) { - next[idx] = { - ...next[idx], - content: msg.content, - timestamp: Date.now() - }; - } else { - next.push(msg); - } - return next; - } - - /** - * 任务规划:同一 body.id 多次到达 = 全量快照更新同一条助手消息(分步执行演示)。 - * 不使用 chunk 流,而是多帧 SSE,每帧一棵完整 taskList。 - */ - if (t === 'agent/task-plan' && body.id) { - const msg = normalizeWireBodyToMessage(body); - if (!msg) return next; - const idx = next.findIndex((m) => m.id === body.id); - if (idx >= 0) { - next[idx] = { - ...next[idx], - content: msg.content, - timestamp: Date.now() - }; - } else { - next.push(msg); - } - return next; - } - - const single = normalizeWireBodyToMessage(body); - if (single) { - next.push(single); - } - return next; -} +export * from '../../src/gateway/apply-gateway-stream'; diff --git a/packages/conversation/demo/api/chat-api-config.ts b/packages/conversation/demo/api/chat-api-config.ts new file mode 100644 index 0000000000000000000000000000000000000000..637a487c2683aeaf1ab5126a480299297ea3a342 --- /dev/null +++ b/packages/conversation/demo/api/chat-api-config.ts @@ -0,0 +1 @@ +export * from '../../src/gateway/chat-api-config'; diff --git a/packages/conversation/demo/api/fetch-mock-chat.ts b/packages/conversation/demo/api/fetch-mock-chat.ts index 052125011df9f5d801a5aea7dba34880eb0f337b..4bd47718077478edc44266a61e0b111e2d78092e 100644 --- a/packages/conversation/demo/api/fetch-mock-chat.ts +++ b/packages/conversation/demo/api/fetch-mock-chat.ts @@ -1,237 +1 @@ -/** - * 调用本地 mock:HTTP(同步 JSON + SSE)或 WebSocket(见 VITE_MOCK_CHAT_WS)。 - * 两种传输均复用同一套 getStreamScenario / getSyncEnvelopes,场景一致。 - */ -import type { GatewayEnvelope } from './wire-types'; -import type { Message } from '../../src/components/conversation/conversation.props'; -import { applyGatewayEnvelope } from './apply-gateway-stream'; - -export interface ChatStreamContext { - text: string; - parentMessageId: string; - sessionId: string; - roundId: string; - channelId?: string; -} - -function mockChatWsUrl(): string { - const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - return `${proto}//${window.location.host}/api/mock/chat/ws`; -} - -/** 在 `.env.development` 中设置 `VITE_MOCK_CHAT_WS=true` 启用 WebSocket mock */ -export function isMockChatWebSocketEnabled(): boolean { - const v = import.meta.env.VITE_MOCK_CHAT_WS; - return v === 'true' || v === '1'; -} - -function parseSseDataLines(chunk: string, carry: string): { lines: string[]; rest: string } { - const buf = carry + chunk; - const parts = buf.split('\n'); - const rest = parts.pop() ?? ''; - return { lines: parts, rest }; -} - -/** 避免单次 TCP 包内多行 `data:` 在同一同步栈内连续 `onMessages` → Vue/Monaco 重绘挤爆主线程(「流式全景」易触发) */ -function yieldToPaint(): Promise { - return new Promise((resolve) => { - requestAnimationFrame(() => resolve()); - }); -} - -async function fetchMockChatStreamWs( - ctx: ChatStreamContext, - onMessages: (messages: Message[]) => void, - getBaseMessages: () => Message[] -): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(mockChatWsUrl()); - let messages = [...getBaseMessages()]; - - ws.onopen = () => { - ws.send( - JSON.stringify({ - mode: 'stream', - text: ctx.text, - parentMessageId: ctx.parentMessageId, - sessionId: ctx.sessionId, - roundId: ctx.roundId, - channelId: ctx.channelId ?? 'demo-channel' - }) - ); - }; - - ws.onmessage = (ev) => { - try { - const j = JSON.parse(ev.data as string) as { - kind?: string; - envelope?: GatewayEnvelope; - message?: string; - }; - if (j.kind === 'done') { - ws.close(); - resolve(); - return; - } - if (j.kind === 'error') { - ws.close(); - reject(new Error(j.message ?? 'ws_error')); - return; - } - if (j.kind === 'envelope' && j.envelope) { - messages = applyGatewayEnvelope(messages, j.envelope); - onMessages(messages); - } - } catch (e) { - ws.close(); - reject(e instanceof Error ? e : new Error(String(e))); - } - }; - - ws.onerror = () => { - reject(new Error('websocket_error')); - }; - }); -} - -export async function fetchMockChatStream( - ctx: ChatStreamContext, - onMessages: (messages: Message[]) => void, - getBaseMessages: () => Message[] -): Promise { - if (isMockChatWebSocketEnabled()) { - return fetchMockChatStreamWs(ctx, onMessages, getBaseMessages); - } - - const res = await fetch('/api/mock/chat/stream', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - text: ctx.text, - parentMessageId: ctx.parentMessageId, - sessionId: ctx.sessionId, - roundId: ctx.roundId, - channelId: ctx.channelId ?? 'demo-channel' - }) - }); - - if (!res.ok) { - throw new Error(`stream http ${res.status}`); - } - - const reader = res.body?.getReader(); - if (!reader) { - throw new Error('no response body'); - } - - const decoder = new TextDecoder(); - let sseCarry = ''; - let messages = [...getBaseMessages()]; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const { lines, rest } = parseSseDataLines(decoder.decode(value, { stream: true }), sseCarry); - sseCarry = rest; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith('data:')) continue; - const jsonStr = trimmed.slice(5).trim(); - if (jsonStr === '[DONE]') continue; - try { - const env = JSON.parse(jsonStr) as GatewayEnvelope; - messages = applyGatewayEnvelope(messages, env); - onMessages(messages); - await yieldToPaint(); - } catch (e) { - // eslint-disable-next-line no-console - console.warn('SSE parse skip', jsonStr, e); - } - } - } -} - -export interface SyncResponse { - envelopes: GatewayEnvelope[]; -} - -async function fetchMockChatSyncWs(ctx: ChatStreamContext): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(mockChatWsUrl()); - let syncEnvelopes: GatewayEnvelope[] | undefined; - - ws.onopen = () => { - ws.send( - JSON.stringify({ - mode: 'sync', - text: ctx.text, - parentMessageId: ctx.parentMessageId, - sessionId: ctx.sessionId, - roundId: ctx.roundId, - channelId: ctx.channelId ?? 'demo-channel' - }) - ); - }; - - ws.onmessage = (ev) => { - try { - const j = JSON.parse(ev.data as string) as { - kind?: string; - envelopes?: GatewayEnvelope[]; - message?: string; - }; - if (j.kind === 'sync' && Array.isArray(j.envelopes)) { - syncEnvelopes = j.envelopes; - return; - } - if (j.kind === 'done') { - ws.close(); - resolve(syncEnvelopes ?? []); - return; - } - if (j.kind === 'error') { - ws.close(); - reject(new Error(j.message ?? 'ws_error')); - } - } catch (e) { - ws.close(); - reject(e instanceof Error ? e : new Error(String(e))); - } - }; - - ws.onerror = () => reject(new Error('websocket_error')); - }); -} - -export async function fetchMockChatSync(ctx: ChatStreamContext): Promise { - if (isMockChatWebSocketEnabled()) { - return fetchMockChatSyncWs(ctx); - } - - const res = await fetch('/api/mock/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - text: ctx.text, - parentMessageId: ctx.parentMessageId, - sessionId: ctx.sessionId, - roundId: ctx.roundId, - channelId: ctx.channelId ?? 'demo-channel' - }) - }); - if (!res.ok) { - throw new Error(`sync http ${res.status}`); - } - const data = (await res.json()) as SyncResponse; - return data.envelopes ?? []; -} - -/** 同步:解析全部 envelope 合并到消息列表 */ -export function applyEnvelopes(messages: Message[], envelopes: GatewayEnvelope[]): Message[] { - let next = [...messages]; - for (const env of envelopes) { - next = applyGatewayEnvelope(next, env); - } - return next; -} +export * from '../../src/gateway/fetch-gateway-chat'; diff --git a/packages/conversation/demo/api/gateway-standard.ts b/packages/conversation/demo/api/gateway-standard.ts index 83865d4f9d95d1e5164a40a8b4e62a56e0b930bb..9aa574c5bcbd603aaa2b67fb2f96012737839380 100644 --- a/packages/conversation/demo/api/gateway-standard.ts +++ b/packages/conversation/demo/api/gateway-standard.ts @@ -1,131 +1 @@ -/** - * 《智能消息 1.0》Gateway → Client 下行约定(与企业云 20260318 设计篇智能体内容字段一致)。 - * Mock 与 normalize 均按此补齐信封,避免散落的魔法常量。 - * - * 依据:`智能消息1.0.md` §4.3、§5.3 流式 / completion / 用户授权等;设计篇 `企业云消息平台20260318版.md` 智能体增强类 content。 - */ - -import type { GatewayEnvelope, WireMessageBody } from './wire-types'; - -export const GATEWAY_PROTOCOL_VERSION = '3.0'; - -export const DEMO_ENTERPRISE = '9991'; -/** 文档示例中的终端用户 */ -export const DEMO_CLIENT_USER = '1234'; -/** 文档示例中的智能体侧用户 id */ -export const DEMO_AGENT_USER = '6666'; - -export function standardToRecipients(): Array<{ user: string; enterprise: string }> { - return [{ user: DEMO_CLIENT_USER, enterprise: DEMO_ENTERPRISE }]; -} - -export function standardFromAgent(): { user: string; enterprise: string; role: 'agent' } { - return { user: DEMO_AGENT_USER, enterprise: DEMO_ENTERPRISE, role: 'agent' }; -} - -/** 流式分片:action 无 status(与 1.0 示例一致) */ -export function gatewayActionStreamChunk() { - return { method: 'post' as const, path: '/channel/message' as const }; -} - -/** 非流式 ACK / 汇总 / 卡片 */ -export function gatewayActionAck() { - return { method: 'post' as const, path: '/channel/message' as const, status: 200 as const }; -} - -export function isoCreationDate(): string { - return new Date().toISOString(); -} - -export function sessionContextFields(ctx: { - parentMessageId: string; - roundId: string; - sessionId: string; -}): { parent: string; roundId: string; sessionId: string } { - return { - parent: ctx.parentMessageId, - roundId: ctx.roundId, - sessionId: ctx.sessionId - }; -} - -/** 流式分片 body(含协议号、to、from.role=agent) */ -export function streamChunkBody(params: { - ctx: { channelId: string; parentMessageId: string; roundId: string; sessionId: string }; - type: 'agent/thinking' | 'agent/dynamic-markdown'; - streamId: string; - chunkIndex: number; - streamStatus: 'start' | 'continue' | 'end'; - chunk: string; -}): WireMessageBody { - const { ctx, type, streamId, chunkIndex, streamStatus, chunk } = params; - return { - id: 'ignored', - message: GATEWAY_PROTOCOL_VERSION, - type, - channel: ctx.channelId, - to: standardToRecipients(), - from: standardFromAgent(), - content: { - ...sessionContextFields(ctx), - streamId, - chunk, - chunkIndex, - streamStatus - } - }; -} - -/** 流式 completion body */ -export function streamCompletionBody(params: { - ctx: { channelId: string; parentMessageId: string; roundId: string; sessionId: string }; - id: string; - type: 'agent/thinking-completion' | 'agent/dynamic-markdown-completion' | 'agent/markdown-completion'; - streamId: string; - text: string; - sources?: string[]; -}): WireMessageBody { - const { ctx, id, type, streamId, text, sources } = params; - return { - id, - message: GATEWAY_PROTOCOL_VERSION, - creationDate: isoCreationDate(), - type, - channel: ctx.channelId, - to: standardToRecipients(), - from: standardFromAgent(), - content: { - ...sessionContextFields(ctx), - streamId, - text, - ...(sources != null && sources.length > 0 ? { sources } : {}) - } - }; -} - -/** 非流式卡片公用外壳(content 由调用方传入,可再合并 sessionContext) */ -export function cardEnvelope( - tracer: string, - id: string, - type: string, - ctx: { channelId: string; parentMessageId: string; roundId: string; sessionId: string }, - content: Record -): GatewayEnvelope { - return { - headers: { enterprise: DEMO_ENTERPRISE, tracer }, - action: gatewayActionAck(), - body: { - id, - message: GATEWAY_PROTOCOL_VERSION, - creationDate: isoCreationDate(), - type, - channel: ctx.channelId, - to: standardToRecipients(), - from: standardFromAgent(), - content: { - ...sessionContextFields(ctx), - ...content - } - } - }; -} +export * from '../../src/gateway/gateway-standard'; diff --git a/packages/conversation/demo/api/normalize-wire.ts b/packages/conversation/demo/api/normalize-wire.ts index 9c0689ff81050a3e5370114d00fe6d3a4606c637..0af8c46de017341209eefeb5c3ab8ef3b9369b83 100644 --- a/packages/conversation/demo/api/normalize-wire.ts +++ b/packages/conversation/demo/api/normalize-wire.ts @@ -1,341 +1 @@ -/** - * 将网关非流式单条 body 转为 farris Message(解析层)。 - */ -import type { Message } from '../../src/components/conversation/conversation.props'; -import { assetUrl } from '../asset-url'; -import type { WireMessageBody } from './wire-types'; -import type { - MessageContentLinkCard, - MessageContentUserAuth, - MessageContentReferenceSources, - MessageContentErrorReminder, - ReferenceSourceItem -} from '../../src/components/enterprise-cloud/types'; -import type { MessageContentMarkdown } from '../../src/components/markdown/types'; -import type { TodoItemStatus, TodoWorkItem } from '../../src/components/todo/compopsition/type'; -import type { MessageContentFileOperation, FileOperationItem } from '../../src/components/file-operation/types'; -import type { MessageContentAttachmentFile } from '../../src/components/enterprise-cloud/types'; -import type { MessageContentCoding } from '../../src/components/coding/types'; -import { enterpriseWireShouldStubOnly } from './wire-standard-registry'; - -const ASSISTANT_NAME = 'inBuilder'; - -function assistantShell(id: string, content: Message['content']): Message { - return { - id, - name: ASSISTANT_NAME, - role: 'assistant', - content, - timestamp: Date.now(), - agentId: '' - }; -} - -/** 标准已登记、暂无专项 UI:归一为 UnknownEnterprise,保留 wireType 供后续接组件 */ -function stubUnknownEnterprise(body: WireMessageBody, wireType: string): Message { - return assistantShell(body.id || `msg-${Date.now()}`, { - type: 'UnknownEnterprise', - standardType: 'unknown', - wireType, - hint: `标准 wire 类型「${wireType}」已在归一化层登记(阶段 C);专项 UI 未实现时可在此接入。` - }); -} - -function lineCount(s: string): number { - if (!s) return 0; - return s.split('\n').length; -} - -/** completion / 单条卡片等非增量类 */ -export function normalizeWireBodyToMessage(body: WireMessageBody): Message | null { - const t = body.type; - const c = body.content as Record | undefined; - - if (t === 'agent/thinking-completion' && c?.text != null) { - return assistantShell(body.id || `msg-${Date.now()}`, { - type: 'AgentThinking', - standardType: 'agent/thinking', - streamStatus: 'end', - text: String(c.text), - sources: Array.isArray(c.sources) ? (c.sources as string[]) : undefined - }); - } - - if ((t === 'agent/dynamic-markdown-completion' || t === 'agent/markdown-completion') && c?.text != null) { - const mc: MessageContentMarkdown = { - type: 'markdown', - message: '正式回答(流结束汇总)', - content: String(c.text) - }; - return assistantShell(body.id || `msg-${Date.now()}`, mc); - } - - if (t === 'agent/input-recommend' || t === 'agent/suggestions') { - const title = (c?.title as string) || '输入推荐'; - const rawList = c?.suggestionsList ?? c?.suggestions; - const suggestions = Array.isArray(rawList) ? (rawList as string[]) : []; - if (suggestions.length === 0) return null; - return assistantShell(body.id || `msg-${Date.now()}`, { - type: 'InputRecommend', - title, - suggestions - }); - } - - if (t === 'agent/request-run' && c?.description != null) { - const opts = (c.options as Array<{ option_id?: string; id?: string; name: string; message?: string }>) || []; - const requestId = String(c.requestId ?? body.id ?? `req-${Date.now()}`); - const content: MessageContentUserAuth = { - type: 'UserAuth', - standardType: 'agent/request-run', - requestId, - description: String(c.description), - options: opts.map((o) => ({ - optionId: o.option_id || o.id || `opt-${o.name}`, - name: o.name, - message: o.message ?? o.name - })) - }; - return assistantShell(body.id || `msg-${Date.now()}`, content); - } - - if (t === 'agent/reference' || t === 'agent/reference-sources') { - const rawItems = c?.items ?? c?.references; - if (!Array.isArray(rawItems) || rawItems.length === 0) return null; - const items: ReferenceSourceItem[] = []; - for (const it of rawItems) { - const o = it as Record; - const title = String(o.title ?? o.name ?? '').trim(); - const url = String(o.url ?? o.href ?? '#'); - if (title) items.push({ title, url }); - } - if (items.length === 0) return null; - const content: MessageContentReferenceSources = { - type: 'ReferenceSources', - standardType: 'agent/reference', - items - }; - return assistantShell(body.id || `msg-${Date.now()}`, content); - } - - if (t === 'agent/error' || t === 'agent/error-reminder') { - const errorText = String(c?.errorText ?? c?.message ?? '').trim(); - if (!errorText) return null; - const rawLv = Number(c?.errorLevel ?? 1); - const errorLevel = (rawLv === 0 || rawLv === 1 || rawLv === 2 ? rawLv : 1) as MessageContentErrorReminder['errorLevel']; - const err: MessageContentErrorReminder = { - type: 'ErrorReminder', - standardType: 'agent/error', - errorLevel, - errorText, - errorLink: c?.errorLink != null ? String(c.errorLink) : undefined - }; - return assistantShell(body.id || `msg-${Date.now()}`, err); - } - - if (t === 'card/link' || t === 'card/url' || t === 'link_card') { - const card: MessageContentLinkCard = { - type: 'LinkCard', - standardType: t === 'card/url' ? 'card/url' : undefined, - title: String(c?.title ?? ''), - subtitle: c?.subtitle != null ? String(c.subtitle) : undefined, - url: String(c?.url ?? '#'), - poster: c?.poster != null ? String(c.poster) : undefined, - relatedLinks: Array.isArray(c?.relatedLinks) ? (c.relatedLinks as MessageContentLinkCard['relatedLinks']) : undefined - }; - return assistantShell(body.id || `msg-${Date.now()}`, card); - } - - if (t === 'text/plain' && c?.text != null) { - return assistantShell(body.id || `msg-${Date.now()}`, { type: 'text', text: String(c.text) }); - } - - /** 文本评论:20260318 `comment/text-plain`,content.message 为被评论消息 id */ - if (t === 'comment/text-plain' && c?.text != null) { - const replyTo = c.message != null ? String(c.message) : ''; - const text = String(c.text); - const display = replyTo ? `[评论 · 原消息 ${replyTo}] ${text}` : text; - return assistantShell(body.id || `msg-${Date.now()}`, { type: 'text', text: display }); - } - - /** 富文本(设计篇标题 + 正文)→ markdown */ - if ((t === 'text/rich' || t === 'text/rich-text' || t === 'rich/text') && c?.text != null) { - const title = c.title != null ? String(c.title) : ''; - const text = String(c.text); - const md = title ? `## ${title}\n\n${text}` : text; - const mc: MessageContentMarkdown = { type: 'markdown', message: '富文本', content: md }; - return assistantShell(body.id || `msg-${Date.now()}`, mc); - } - - /** - * 资源组(extended/multi-media):文本 + mediaList 摘要为 markdown;无实质内容则占位 - */ - if (t === 'extended/multi-media') { - const cx = (c ?? {}) as Record; - const parts: string[] = []; - if (cx.title != null) parts.push(`## ${String(cx.title)}\n`); - if (cx.text != null) parts.push(String(cx.text)); - if (Array.isArray(cx.mediaList)) { - if (parts.length) parts.push('\n\n'); - parts.push('**资源组**\n'); - for (const m of cx.mediaList as unknown[]) { - const o = m as Record; - parts.push( - `\n- ${String(o.category ?? '?')} ${String(o.name ?? '')} → ${String(o.media ?? '')}` - ); - } - } - if (parts.length === 0) { - return stubUnknownEnterprise(body, t); - } - const mc: MessageContentMarkdown = { type: 'markdown', message: '资源组消息', content: parts.join('') }; - return assistantShell(body.id || `msg-${Date.now()}`, mc); - } - - /** - * 代码修改 / Diff(Monaco)→ farris `Coding` - * wire:`agent/code-change`(推荐)或别名 `agent/coding` - */ - if (t === 'agent/code-change' || t === 'agent/coding') { - const codeBlock = c?.code as Record | undefined; - if (!c || !codeBlock || codeBlock.value == null) return null; - const value = String(codeBlock.value ?? ''); - const originalValue = - codeBlock.originalValue != null && String(codeBlock.originalValue).length > 0 - ? String(codeBlock.originalValue) - : undefined; - const language = codeBlock.language != null ? String(codeBlock.language) : 'plaintext'; - let addedLines = Number(codeBlock.addedLines); - let deletedLines = Number(codeBlock.deletedLines); - if (!Number.isFinite(addedLines) || addedLines < 0) { - addedLines = lineCount(value); - } - if (!Number.isFinite(deletedLines) || deletedLines < 0) { - deletedLines = originalValue ? lineCount(originalValue) : 0; - } - const coding: MessageContentCoding = { - type: 'Coding', - standardType: t, - message: String(c.message ?? c.summary ?? '代码变更'), - fileIcon: String(c.fileIcon ?? c.icon ?? assetUrl('assets/icon/react.png')), - fileName: String(c.fileName ?? c.path ?? 'snippet.txt'), - code: { value, originalValue, language, addedLines, deletedLines } - }; - return assistantShell(body.id || `msg-${Date.now()}`, coding); - } - - /** - * Agent 工具/文件轨迹(Read / Grep / Search …)→ farris FileOperation - * wire.operations 项支持 { type, message } 或 { op, target };details 结构与 FileOperation 一致 - */ - if ((t === 'agent/tool-trace' || t === 'agent/file-trace') && c && Array.isArray(c.operations)) { - type WireOp = { - type?: string; - op?: string; - message?: string; - target?: string; - details?: FileOperationItem['details']; - }; - const raw = c.operations as WireOp[]; - const operations: FileOperationItem[] = raw.map((o) => ({ - type: String(o.type ?? o.op ?? 'Read'), - message: String(o.message ?? o.target ?? ''), - details: o.details - })); - const sum = c.summary as { explored?: number; searched?: number } | undefined; - const content: MessageContentFileOperation = { - type: 'FileOperation', - ...(sum != null - ? { - summary: { - explored: Number(sum.explored ?? 0), - searched: Number(sum.searched ?? 0) - } - } - : {}), - operations - }; - return assistantShell(body.id || `msg-${Date.now()}`, content); - } - - /** 企业云:资源「文件」附件 → AttachmentFile 卡片 */ - if ((t === 'resource/file' || t === 'media/file') && c?.name != null && c?.media != null) { - const att: MessageContentAttachmentFile = { - type: 'AttachmentFile', - standardType: 'resource/file', - category: String(c.category ?? 'document'), - name: String(c.name), - size: Number(c.size ?? 0), - media: String(c.media) - }; - return assistantShell(body.id || `msg-${Date.now()}`, att); - } - - /** 图片类资源:与文件相同字段时使用 AttachmentFile */ - if ( - (t === 'resource/image' || t === 'media/image' || t === 'multi-media/image') && - c?.media != null - ) { - const att: MessageContentAttachmentFile = { - type: 'AttachmentFile', - standardType: t, - category: String(c.category ?? 'image'), - name: String(c.name ?? 'image'), - size: Number(c.size ?? 0), - media: String(c.media) - }; - return assistantShell(body.id || `msg-${Date.now()}`, att); - } - - /** 企业云:任务规划/执行 → Todo 列表(支持嵌套 todoList + revision 为元数据,仅存于 wire,可不展示) */ - if (t === 'agent/task-plan' && c && Array.isArray(c.taskList)) { - const mapStatus = (raw?: string): TodoItemStatus => { - switch (raw) { - case 'success': - return 'Done'; - case 'fail': - return 'NotStart'; - case 'current': - return 'Working'; - case 'undo': - default: - return 'NotStart'; - } - }; - - type WireTaskRow = { - taskContent?: string; - task?: string; - taskStatus?: string; - todoList?: WireTaskRow[]; - }; - - function mapWireTaskRow(row: WireTaskRow): TodoWorkItem { - const item: TodoWorkItem = { - task: String(row.taskContent ?? row.task ?? ''), - status: mapStatus(row.taskStatus) - }; - if (Array.isArray(row.todoList) && row.todoList.length > 0) { - item.todoList = row.todoList.map(mapWireTaskRow); - item.detailViewMode = 'expand'; - item.initExpanded = true; - } - return item; - } - - const taskList = c.taskList as WireTaskRow[]; - const items = taskList.map(mapWireTaskRow); - const title = (c.title as string) || '任务规划'; - return assistantShell(body.id || `msg-${Date.now()}`, { - type: 'Todo', - message: title, - items - }); - } - - if (enterpriseWireShouldStubOnly(t)) { - return stubUnknownEnterprise(body, t); - } - - return null; -} +export * from '../../src/gateway/normalize-wire'; diff --git a/packages/conversation/demo/api/wire-standard-registry.ts b/packages/conversation/demo/api/wire-standard-registry.ts index 7fd8361e5e2218268ca8fdd96da20a27dcfc8bb8..cdcf37cae5171b386d406f06c9d33ced2c490280 100644 --- a/packages/conversation/demo/api/wire-standard-registry.ts +++ b/packages/conversation/demo/api/wire-standard-registry.ts @@ -1,78 +1 @@ -/** - * 《企业云消息平台 20260318》× 智能消息 wire `body.type` 登记(阶段 C)。 - * - 与 normalize-wire 中「已有专项 UI」的集合对账; - * - `STUB_TYPES`:无专项组件时归一为 `UnknownEnterprise`,仅留 `wireType` 口子; - * - `STUB_PREFIXES`:同类前缀宽登记(若更早在 normalize 中已处理则不会走到 stub)。 - */ - -/** normalize-wire 中已返回非 null 的类型(勿重复 stub) */ -export const WIRE_TYPES_HANDLED_IN_NORMALIZE = new Set([ - 'agent/thinking-completion', - 'agent/dynamic-markdown-completion', - 'agent/markdown-completion', - 'agent/input-recommend', - 'agent/suggestions', - 'agent/request-run', - 'agent/reference', - 'agent/reference-sources', - 'agent/error', - 'agent/error-reminder', - 'card/link', - 'card/url', - 'link_card', - 'text/plain', - 'agent/code-change', - 'agent/coding', - 'agent/tool-trace', - 'agent/file-trace', - 'resource/file', - 'media/file', - 'agent/task-plan', - 'comment/text-plain', - 'text/rich', - 'text/rich-text', - 'rich/text', - 'extended/multi-media', - 'resource/image', - 'media/image', - 'multi-media/image' -]); - -/** - * 标准中有定义、演示层暂无独立组件:归一化末尾 → UnknownEnterprise(wireType 填原始 type) - */ -export const ENTERPRISE_WIRE_STUB_TYPES = new Set([ - 'batch/messages', - 'message/batch', - 'agent/dynamic-code', - 'agent/dynamic-code-completion', - 'agent/navigator', - 'agent/form', - 'card/vcard', - 'card/location', - 'card/chart', - 'card/table', - 'card/approval', - 'card/custom', - 'multi-media/video', - 'multi-media/voice', - 'multi-media/short-video', - 'resource/video', - 'resource/audio', - 'resource/sticker', - 'message/reply', - 'sticker/resource' -]); - -const STUB_PREFIXES = ['batch/'] as const; - -export function enterpriseWireShouldStubOnly(t: string): boolean { - if (WIRE_TYPES_HANDLED_IN_NORMALIZE.has(t)) return false; - if (ENTERPRISE_WIRE_STUB_TYPES.has(t)) return true; - for (const p of STUB_PREFIXES) { - if (t.startsWith(p)) return true; - } - /** 其余 A2UI 卡片(card/link、card/url 等已在 HANDLED) */ - if (t.startsWith('card/')) return true; - return false; -} +export * from '../../src/gateway/wire-standard-registry'; diff --git a/packages/conversation/demo/api/wire-types.ts b/packages/conversation/demo/api/wire-types.ts index b3858b915d986a44a9552ae5f95e05af4e8f59db..64fc71b618c2a5e2f004ea3f2e2dafb27e5c0f0c 100644 --- a/packages/conversation/demo/api/wire-types.ts +++ b/packages/conversation/demo/api/wire-types.ts @@ -1,59 +1 @@ -/** - * 《智能消息 1.0》Gateway → Client 载荷(与企业云 20260318 设计篇智能体 content 对齐)。 - * - 流式分片:`agent/thinking` | `agent/dynamic-markdown`,`content.chunk` / `streamStatus` / `streamId` 等见 `gateway-standard.ts` - * - 流式汇总:`agent/thinking-completion` | `agent/dynamic-markdown-completion`(亦接受别名 `agent/markdown-completion`) - * - 非流式卡片:`body.message`=`3.0`、`to`、`from.role`=`agent`、`creationDate` 等由 `cardEnvelope` 统一补齐 - */ - -export interface WireHeaders { - enterprise: string; - tracer: string; -} - -export interface WireAction { - method: string; - path: string; - status?: number; -} - -export interface WireFrom { - user: string; - enterprise: string; - /** 《智能消息1.0》Gateway→Client:智能体消息建议 `agent` */ - role?: 'agent' | 'human'; -} - -/** Gateway→Client 的 `to`:对象数组(推荐)或文档示例中的用户 id 字符串数组 */ -export type WireToRecipient = { user: string; enterprise: string }; - -/** 流式片段 / completion 共用的 content 结构(节选) */ -export interface WireStreamContent { - parent: string; - roundId: string; - sessionId: string; - streamId: string; - chunk?: string; - chunkIndex?: number; - streamStatus?: 'start' | 'continue' | 'end'; - /** completion 时的完整文本 */ - text?: string; - sources?: string[]; -} - -export interface WireMessageBody { - /** 智能消息协议版本,如 `3.0`(《智能消息1.0》§4.3 body) */ - message?: string; - id: string; - type: string; - channel?: string; - creationDate?: string; - from?: WireFrom; - to?: WireToRecipient[] | string[]; - content?: WireStreamContent | Record; -} - -export interface GatewayEnvelope { - headers: WireHeaders; - action: WireAction; - body: WireMessageBody; -} +export * from '../../src/gateway/wire-types'; diff --git a/packages/conversation/demo/asset-url.ts b/packages/conversation/demo/asset-url.ts index 9c8e25c2a411c07200ebf43b00a4922f34723cdd..3e51ca8ff930bb27fd68b30e0d92e4f360a66896 100644 --- a/packages/conversation/demo/asset-url.ts +++ b/packages/conversation/demo/asset-url.ts @@ -2,9 +2,22 @@ * 与 Vite `base` 对齐的 public 资源路径(`base: './'` 时形如 `./assets/...`), * 便于整包复制到 Web 服务器任意子目录。 */ +function viteBaseUrl(): string { + try { + const env = (import.meta as ImportMeta & { env?: { BASE_URL?: string } }).env; + const b = env?.BASE_URL; + if (b !== undefined && b !== '') { + return b; + } + } catch { + /* Vite 配置在 Node 侧打包执行时可能无 import.meta.env */ + } + return './'; +} + export function assetUrl(path: string): string { const p = path.replace(/^\//, ''); - const base = import.meta.env.BASE_URL; + const base = viteBaseUrl(); if (base === './') { return `./${p}`; } diff --git a/packages/conversation/demo/mock-api/scenarios.ts b/packages/conversation/demo/mock-api/scenarios.ts index e1840fd295d4f6f690d6502cb97c699738aaad93..5252957c4bdc84e2e3721a05b61f31fc85c9aea3 100644 --- a/packages/conversation/demo/mock-api/scenarios.ts +++ b/packages/conversation/demo/mock-api/scenarios.ts @@ -1,6 +1,6 @@ /** * Mock 服务端场景:按「相邻事件间隔」输出 Gateway 信封(流式 chunk / completion)。 - * 信封字段对齐《智能消息1.0》Gateway→Client + 企业云设计篇 content。 + * 信封字段对齐《智能消息2.0》Gateway→Client;流式汇总富文本用 `agent/markdown-completion`。 */ import type { GatewayEnvelope } from '../api/wire-types'; import { assetUrl } from '../asset-url'; @@ -51,7 +51,10 @@ function chunk( } function completion( - type: 'agent/thinking-completion' | 'agent/dynamic-markdown-completion', + type: + | 'agent/thinking-completion' + | 'agent/dynamic-markdown-completion' + | 'agent/markdown-completion', ctx: ScenarioContext, streamId: string, fullText: string, @@ -110,13 +113,13 @@ function markdownStream( }); out.push({ delayMs: d.completionAfterChunkMs, - envelope: completion('agent/dynamic-markdown-completion', ctx, streamId, full) + envelope: completion('agent/markdown-completion', ctx, streamId, full) }); return out; } function todoEnvelope(ctx: ScenarioContext): GatewayEnvelope { - return cardEnvelope(tr(), `msg-todo-${Date.now()}`, 'agent/task-plan', ctx, { + return cardEnvelope(tr(), `msg-todo-${Date.now()}`, 'agent/todo-list', ctx, { title: '任务规划 / 执行(企业云标准)', taskList: [ { taskContent: '解析网关信封与 streamId', taskStatus: 'success' }, @@ -136,7 +139,7 @@ function scenarioDefault(ctx: ScenarioContext, d: StreamDelays): TimedEnvelope[] '当前为 **`agent/dynamic-markdown`** 分帧,`chunk` 会 ', '拼接为完整 Markdown。\n\n', '- 上一段为 **`agent/thinking`** 流式\n', - '- 随后为 **`agent/task-plan`** 待办规划\n\n', + '- 随后为 **`agent/todo-list`** 待办规划\n\n', '> 延迟可在 `demo/mock-api/stream-config.ts` 调大。' ]; @@ -167,7 +170,7 @@ function scenarioMarkdownOnly(ctx: ScenarioContext, d: StreamDelays): TimedEnvel return markdownStream(ctx, `md-only-${ctx.roundId}`, ['# 仅正文\n\n', '无 `agent/thinking` 分帧。'], d.singleStreamFirstMs, d); } -/** 单条 SSE:任务规划(agent/task-plan → Todo) */ +/** 单条 SSE:任务规划(agent/todo-list → Todo) */ function scenarioTodoOnly(ctx: ScenarioContext, d: StreamDelays): TimedEnvelope[] { return [{ delayMs: d.singleStreamFirstMs, envelope: todoEnvelope(ctx) }]; } @@ -180,7 +183,7 @@ function scenarioProgressiveTodo(ctx: ScenarioContext, d: StreamDelays): TimedEn const gap = Math.max(500, Math.floor(d.beforeTodoMs)); const title = '分步待办(含子任务)'; - const step1 = cardEnvelope(tr(), stableId, 'agent/task-plan', ctx, { + const step1 = cardEnvelope(tr(), stableId, 'agent/todo-list', ctx, { title, revision: 1, phase: 'planning', @@ -202,7 +205,7 @@ function scenarioProgressiveTodo(ctx: ScenarioContext, d: StreamDelays): TimedEn ] }); - const step2 = cardEnvelope(tr(), stableId, 'agent/task-plan', ctx, { + const step2 = cardEnvelope(tr(), stableId, 'agent/todo-list', ctx, { title, revision: 2, phase: 'executing', @@ -224,7 +227,7 @@ function scenarioProgressiveTodo(ctx: ScenarioContext, d: StreamDelays): TimedEn ] }); - const step3 = cardEnvelope(tr(), stableId, 'agent/task-plan', ctx, { + const step3 = cardEnvelope(tr(), stableId, 'agent/todo-list', ctx, { title, revision: 3, phase: 'executing', @@ -246,7 +249,7 @@ function scenarioProgressiveTodo(ctx: ScenarioContext, d: StreamDelays): TimedEn ] }); - const step4 = cardEnvelope(tr(), stableId, 'agent/task-plan', ctx, { + const step4 = cardEnvelope(tr(), stableId, 'agent/todo-list', ctx, { title, revision: 4, phase: 'completed', @@ -367,7 +370,7 @@ function scenarioFullStreamShowcase(ctx: ScenarioContext, d: StreamDelays): Time const sMd = `md-full-${rid.slice(-8)}`; const thinkLines = [ '【全景演示】\n', - '随后将经 SSE 推送:Markdown、引用、工具/附件、代码修改(同 id 两帧)、分步待办、授权、链接、文本、错误与推荐。\n' + '随后将经 SSE 推送:Markdown、引用、工具/附件、分步待办、授权、链接、文本、错误与推荐。(本演示略过代码修改,避免重型编辑器卡住主线程。)\n' ]; const mdPieces = [ '## 流式全景(mock)\n\n', @@ -375,8 +378,7 @@ function scenarioFullStreamShowcase(ctx: ScenarioContext, d: StreamDelays): Time '| 片段 | wire `body.type`(节选) |\n|------|--------------------------|\n', '| 流式 | `agent/thinking` · `agent/dynamic-markdown` |\n', '| 卡片 | `agent/reference` · `agent/tool-trace` · `resource/file` |\n', - '| 代码 | `agent/code-change`(同一 `id` 全量替换,`revision` 可选) |\n', - '| 任务 | `agent/task-plan`(同一 `id`,`revision` 递增) |\n', + '| 任务 | `agent/todo-list`(同一 `id`,`revision` 递增) |\n', '| 其它 | `agent/request-run` · `card/link` · `text/plain` · `agent/error` |\n\n', '> 发送口令 **【流式】全景** 可重复本序列。' ]; @@ -384,7 +386,7 @@ function scenarioFullStreamShowcase(ctx: ScenarioContext, d: StreamDelays): Time const planId = `msg-plan-full-${rid}`; const todoTitle = '全景:分步待办(含子任务)'; - const step1 = cardEnvelope(tr(), planId, 'agent/task-plan', ctx, { + const step1 = cardEnvelope(tr(), planId, 'agent/todo-list', ctx, { title: todoTitle, revision: 1, phase: 'planning', @@ -406,7 +408,7 @@ function scenarioFullStreamShowcase(ctx: ScenarioContext, d: StreamDelays): Time ] }); - const step2 = cardEnvelope(tr(), planId, 'agent/task-plan', ctx, { + const step2 = cardEnvelope(tr(), planId, 'agent/todo-list', ctx, { title: todoTitle, revision: 2, phase: 'executing', @@ -428,7 +430,7 @@ function scenarioFullStreamShowcase(ctx: ScenarioContext, d: StreamDelays): Time ] }); - const step3 = cardEnvelope(tr(), planId, 'agent/task-plan', ctx, { + const step3 = cardEnvelope(tr(), planId, 'agent/todo-list', ctx, { title: todoTitle, revision: 3, phase: 'completed', @@ -484,34 +486,6 @@ function scenarioFullStreamShowcase(ctx: ScenarioContext, d: StreamDelays): Time media: '/document/demo/openapi.yaml' }); - const codeFullId = `msg-code-full-${rid}`; - const codeFullStep1 = cardEnvelope(tr(), codeFullId, 'agent/code-change', ctx, { - revision: 1, - message: '【全景】代码修改第 1 帧(仅新稿,无 diff)', - fileName: 'demo-feature.ts', - fileIcon: assetUrl('assets/icon/react.png'), - code: { - language: 'typescript', - value: 'export const FEATURE = true;\n', - addedLines: 1, - deletedLines: 0 - } - }); - - const codeFullStep2 = cardEnvelope(tr(), codeFullId, 'agent/code-change', ctx, { - revision: 2, - message: '【全景】代码修改第 2 帧:同 id 替换为 diff 视图', - fileName: 'demo-feature.ts', - fileIcon: assetUrl('assets/icon/react.png'), - code: { - language: 'typescript', - value: 'export const FEATURE = true;\nexport const READY = true;\n', - originalValue: 'export const FEATURE = false;\n', - addedLines: 2, - deletedLines: 1 - } - }); - const authEnv = cardEnvelope(tr(), `msg-auth-full-${rid}`, 'agent/request-run', ctx, { requestId: tr(), description: '【全景】是否将上述待办结果同步到演示会话摘要?', @@ -550,8 +524,6 @@ function scenarioFullStreamShowcase(ctx: ScenarioContext, d: StreamDelays): Time { delayMs: gap, envelope: toolEnv }, { delayMs: gap, envelope: file1 }, { delayMs: gap, envelope: file2 }, - { delayMs: gap, envelope: codeFullStep1 }, - { delayMs: planGap, envelope: codeFullStep2 }, { delayMs: gap, envelope: step1 }, { delayMs: planGap, envelope: step2 }, { delayMs: planGap, envelope: step3 }, diff --git a/packages/conversation/demo/vite-env.d.ts b/packages/conversation/demo/vite-env.d.ts index 1e96c43c19db8614262447fe6fffaa4640c0134f..185a4a00bebc1a6d729c01ef08634608e44ea59a 100644 --- a/packages/conversation/demo/vite-env.d.ts +++ b/packages/conversation/demo/vite-env.d.ts @@ -1,11 +1,3 @@ /// /// - -interface ImportMetaEnv { - /** 设为 `true` 时,演示层用 WebSocket 调 mock(与 HTTP/SSE 场景数据一致) */ - readonly VITE_MOCK_CHAT_WS?: string; -} - -interface ImportMeta { - readonly env: ImportMetaEnv; -} +/// diff --git a/packages/conversation/src/components/conversation/conversation.component.tsx b/packages/conversation/src/components/conversation/conversation.component.tsx index e457c6b618561de8c7a5c03d418b885127066bbd..7c73b4955296f5b40be108377e89a4ab2455e757 100644 --- a/packages/conversation/src/components/conversation/conversation.component.tsx +++ b/packages/conversation/src/components/conversation/conversation.component.tsx @@ -234,10 +234,29 @@ export default defineComponent({ nextTick(() => editorRef.value?.focus()); } + function scrollMessageListToBottom() { + nextTick(() => { + requestAnimationFrame(() => { + const el = messageScrollRef.value; + if (!el) return; + el.scrollTop = el.scrollHeight; + }); + }); + } + watch(() => displayedMessages.value.length, (newLength, oldLength) => { if (newLength > 0 && oldLength === 0) focusInputEditor(); }); + /** 新消息或流式更新同条消息内容时,列表滚到底(deep 覆盖 merge 进同一 assistant 气泡) */ + watch( + () => displayedMessages.value, + () => { + scrollMessageListToBottom(); + }, + { deep: true, flush: 'post' } + ); + // chatContentPane 位置固定为 center,预览面板移入内容区内部的嵌套 FLayout watch(showPreview, (shouldShow) => { diff --git a/packages/conversation/src/gateway/apply-gateway-stream.ts b/packages/conversation/src/gateway/apply-gateway-stream.ts new file mode 100644 index 0000000000000000000000000000000000000000..057612a1e7dab038b8cb2d1e1f75adb9d81c7280 --- /dev/null +++ b/packages/conversation/src/gateway/apply-gateway-stream.ts @@ -0,0 +1,219 @@ +/** + * 将流式 Gateway 事件增量应用到消息列表(同 streamId 合并为一条助手消息)。 + */ +import type { Message } from '../components/conversation/conversation.props'; +import type { GatewayEnvelope, WireStreamContent } from './wire-types'; +import type { MessageContentMarkdown } from '../components/markdown/types'; +import type { MessageContentAgentThinking } from '../components/enterprise-cloud/types'; +import { normalizeWireBodyToMessage } from './normalize-wire'; + +const ASSISTANT_NAME = 'inBuilder'; + +function streamPlaceHolderId(streamId: string, wireType: string): string { + return `stream-${wireType.replace(/\//g, '-')}-${streamId}`; +} + +function ensureThinkingMessage(messages: Message[], streamId: string): number { + const wireType = 'agent/thinking'; + const id = streamPlaceHolderId(streamId, wireType); + let idx = messages.findIndex((m) => m.id === id); + if (idx >= 0) return idx; + const msg: Message = { + id, + name: ASSISTANT_NAME, + role: 'assistant', + timestamp: Date.now(), + agentId: '', + content: { + type: 'AgentThinking', + standardType: 'agent/thinking', + streamStatus: 'continue', + text: '', + sources: undefined + } + }; + messages.push(msg); + return messages.length - 1; +} + +function ensureMarkdownMessage(messages: Message[], streamId: string): number { + const wireType = 'agent/dynamic-markdown'; + const id = streamPlaceHolderId(streamId, wireType); + let idx = messages.findIndex((m) => m.id === id); + if (idx >= 0) return idx; + const msg: Message = { + id, + name: ASSISTANT_NAME, + role: 'assistant', + timestamp: Date.now(), + agentId: '', + content: { + type: 'markdown', + message: '正式回答(流式输出)', + content: '' + } as MessageContentMarkdown + }; + messages.push(msg); + return messages.length - 1; +} + +function patchThinking(messages: Message[], streamId: string, content: WireStreamContent): void { + const idx = ensureThinkingMessage(messages, streamId); + const m = messages[idx]; + const cur = m.content as MessageContentAgentThinking; + const piece = content.chunk ?? ''; + const nextText = piece ? cur.text + piece : cur.text; + const nextStatus = + content.streamStatus === 'end' ? 'end' : content.streamStatus === 'start' ? 'continue' : 'continue'; + messages[idx] = { + ...m, + content: { + ...cur, + text: nextText, + streamStatus: content.streamStatus === 'end' ? 'end' : nextStatus, + sources: content.sources ?? cur.sources + } + }; +} + +function patchMarkdown(messages: Message[], streamId: string, content: WireStreamContent): void { + const idx = ensureMarkdownMessage(messages, streamId); + const m = messages[idx]; + const cur = m.content as MessageContentMarkdown; + const piece = content.chunk ?? ''; + const nextBody = piece ? cur.content + piece : cur.content; + messages[idx] = { + ...m, + content: { + ...cur, + content: nextBody + } + }; +} + +function applyCompletion(messages: Message[], body: GatewayEnvelope['body']): void { + const c = body.content as WireStreamContent; + const streamId = c?.streamId; + if (!streamId) return; + const idThink = streamPlaceHolderId(streamId, 'agent/thinking'); + const idMd = streamPlaceHolderId(streamId, 'agent/dynamic-markdown'); + + if (body.type === 'agent/thinking-completion') { + const idx = messages.findIndex((m) => m.id === idThink); + const text = c?.text ?? ''; + if (idx >= 0) { + const m = messages[idx]; + const cur = m.content as MessageContentAgentThinking; + messages[idx] = { + ...m, + content: { ...cur, text: text || cur.text, streamStatus: 'end' } + }; + } else if (text) { + messages.push({ + id: body.id || idThink, + name: ASSISTANT_NAME, + role: 'assistant', + timestamp: Date.now(), + agentId: '', + content: { + type: 'AgentThinking', + streamStatus: 'end', + text, + sources: c.sources + } + }); + } + } + + if (body.type === 'agent/dynamic-markdown-completion' || body.type === 'agent/markdown-completion') { + const idx = messages.findIndex((m) => m.id === idMd); + const text = c?.text ?? ''; + if (idx >= 0) { + const m = messages[idx]; + const cur = m.content as MessageContentMarkdown; + messages[idx] = { + ...m, + content: { ...cur, content: text || cur.content } + }; + } else if (text) { + messages.push({ + id: body.id || idMd, + name: ASSISTANT_NAME, + role: 'assistant', + timestamp: Date.now(), + agentId: '', + content: { type: 'markdown', message: '正式回答', content: text } + }); + } + } +} + +/** @returns 新 messages 数组(immutable) */ +export function applyGatewayEnvelope(messages: Message[], env: GatewayEnvelope): Message[] { + const next = [...messages]; + const { body } = env; + const t = body.type; + const content = body.content as WireStreamContent | undefined; + + if (t === 'agent/thinking' && content?.streamId) { + patchThinking(next, content.streamId, content); + return next; + } + if (t === 'agent/dynamic-markdown' && content?.streamId) { + patchMarkdown(next, content.streamId, content); + return next; + } + if ( + t === 'agent/thinking-completion' || + t === 'agent/dynamic-markdown-completion' || + t === 'agent/markdown-completion' + ) { + applyCompletion(next, body); + return next; + } + + /** + * 代码修改:同一 body.id 多帧 = 原地更新同一条 Coding 消息(全量替换 content,与 task-plan 模式一致)。 + */ + if ((t === 'agent/code-change' || t === 'agent/coding') && body.id) { + const msg = normalizeWireBodyToMessage(body); + if (!msg) return next; + const idx = next.findIndex((m) => m.id === body.id); + if (idx >= 0) { + next[idx] = { + ...next[idx], + content: msg.content, + timestamp: Date.now() + }; + } else { + next.push(msg); + } + return next; + } + + /** + * 任务规划:同一 body.id 多次到达 = 全量快照更新同一条助手消息(分步执行演示)。 + * 不使用 chunk 流,而是多帧 SSE,每帧一棵完整 taskList。 + */ + if ((t === 'agent/task-plan' || t === 'agent/todo-list') && body.id) { + const msg = normalizeWireBodyToMessage(body); + if (!msg) return next; + const idx = next.findIndex((m) => m.id === body.id); + if (idx >= 0) { + next[idx] = { + ...next[idx], + content: msg.content, + timestamp: Date.now() + }; + } else { + next.push(msg); + } + return next; + } + + const single = normalizeWireBodyToMessage(body); + if (single) { + next.push(single); + } + return next; +} diff --git a/packages/conversation/src/gateway/asset-url.ts b/packages/conversation/src/gateway/asset-url.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2d1942e6889cf51a4e97d6780a1788e1c070245 --- /dev/null +++ b/packages/conversation/src/gateway/asset-url.ts @@ -0,0 +1,27 @@ +/** + * 与 Vite `base` 对齐的 public 资源路径(`base: './'` 时形如 `./assets/...`)。 + */ +function viteBaseUrl(): string { + try { + const env = (import.meta as ImportMeta & { env?: { BASE_URL?: string } }).env; + const b = env?.BASE_URL; + if (b !== undefined && b !== '') { + return b; + } + } catch { + /* Node / 非 Vite 环境 */ + } + return './'; +} + +export function gatewayAssetUrl(path: string): string { + const p = path.replace(/^\//, ''); + const base = viteBaseUrl(); + if (base === './') { + return `./${p}`; + } + if (base === '/' || base === '') { + return `/${p}`; + } + return base.endsWith('/') ? `${base}${p}` : `${base}/${p}`; +} diff --git a/packages/conversation/src/gateway/chat-api-config.ts b/packages/conversation/src/gateway/chat-api-config.ts new file mode 100644 index 0000000000000000000000000000000000000000..edd0d13bb1c320351f01fb08845015707ea9eb1e --- /dev/null +++ b/packages/conversation/src/gateway/chat-api-config.ts @@ -0,0 +1,78 @@ +/** + * 通信端点配置(随源码交付,供阶段 mock / 联调验收): + * - 默认根地址、`streamPath` / `syncPath` / `wsPath` 见同目录 {@link chat-api.json}(与参考 mock 服务 mock-chat-spring-boot 默认端口 18080 对齐)。 + * - 不改 json、仅在本机换端口或走网关时,可用 `VITE_CHAT_API_*` 覆盖(见 `src/vite-env.d.ts`);未设置环境变量时以 json 为准。 + */ +import raw from './chat-api.json'; + +export interface ChatApiConfig { + /** 空字符串表示与当前页面同源(相对 path);非空则为绝对根,如 http://127.0.0.1:18080 */ + baseUrl: string; + streamPath: string; + syncPath: string; + wsPath: string; +} + +function trimEndSlash(s: string): string { + return s.replace(/\/+$/, ''); +} + +function ensureLeadingSlash(p: string): string { + const t = p.trim(); + return t.startsWith('/') ? t : `/${t}`; +} + +function envStr(key: keyof ImportMetaEnv): string | undefined { + const v = import.meta.env[key]; + if (v === undefined || v === null) return undefined; + const s = String(v).trim(); + return s === '' ? undefined : s; +} + +let cached: ChatApiConfig | null = null; + +export function getChatApiConfig(): ChatApiConfig { + if (cached) return cached; + const base = + envStr('VITE_CHAT_API_BASE_URL') ?? + (typeof raw.baseUrl === 'string' ? raw.baseUrl.trim() : ''); + const streamPath = + envStr('VITE_CHAT_API_STREAM_PATH') ?? + ensureLeadingSlash(String(raw.streamPath ?? '/api/mock/chat/stream')); + const syncPath = + envStr('VITE_CHAT_API_SYNC_PATH') ?? ensureLeadingSlash(String(raw.syncPath ?? '/api/mock/chat')); + const wsPath = + envStr('VITE_CHAT_API_WS_PATH') ?? ensureLeadingSlash(String(raw.wsPath ?? '/api/mock/chat/ws')); + cached = { + baseUrl: trimEndSlash(base), + streamPath, + syncPath, + wsPath + }; + return cached; +} + +/** HTTP(S) 完整地址;baseUrl 为空时返回 pathname+search 形式的 path(供 fetch 同源使用) */ +export function chatApiHttpUrl(path: string): string { + const { baseUrl } = getChatApiConfig(); + const p = ensureLeadingSlash(path); + if (!baseUrl) return p; + return `${baseUrl}${p}`; +} + +/** WebSocket 完整地址:有 baseUrl 时与其 host/protocol 对齐,否则与 window.location 同源 */ +export function getChatApiWebSocketUrl(): string { + const { baseUrl, wsPath } = getChatApiConfig(); + const p = ensureLeadingSlash(wsPath); + if (baseUrl) { + try { + const u = new URL(baseUrl.startsWith('http') ? baseUrl : `http://${baseUrl}`); + const wsProto = u.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${wsProto}//${u.host}${p}`; + } catch { + /* ignore */ + } + } + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${window.location.host}${p}`; +} diff --git a/packages/conversation/src/gateway/chat-api.json b/packages/conversation/src/gateway/chat-api.json new file mode 100644 index 0000000000000000000000000000000000000000..3a8fed11e1154f8f0476fea652a48a7e4f302018 --- /dev/null +++ b/packages/conversation/src/gateway/chat-api.json @@ -0,0 +1,6 @@ +{ + "baseUrl": "http://127.0.0.1:18080", + "streamPath": "/api/mock/chat/stream", + "syncPath": "/api/mock/chat", + "wsPath": "/api/mock/chat/ws" +} diff --git a/packages/conversation/src/gateway/fetch-gateway-chat.ts b/packages/conversation/src/gateway/fetch-gateway-chat.ts new file mode 100644 index 0000000000000000000000000000000000000000..88b38a860eeb9546e8359a2f293463f8aeef0381 --- /dev/null +++ b/packages/conversation/src/gateway/fetch-gateway-chat.ts @@ -0,0 +1,243 @@ +/** + * 调用网关 / 本地 mock:HTTP(同步 JSON + SSE)或 WebSocket(见 VITE_MOCK_CHAT_WS)。 + */ +import type { GatewayEnvelope } from './wire-types'; +import type { Message } from '../components/conversation/conversation.props'; +import { applyGatewayEnvelope } from './apply-gateway-stream'; +import { chatApiHttpUrl, getChatApiConfig, getChatApiWebSocketUrl } from './chat-api-config'; + +export interface GatewayChatRequestContext { + text: string; + parentMessageId: string; + sessionId: string; + roundId: string; + channelId?: string; +} + +/** @deprecated 请使用 {@link GatewayChatRequestContext} */ +export type ChatStreamContext = GatewayChatRequestContext; + +function mockChatWsUrl(): string { + return getChatApiWebSocketUrl(); +} + +/** 在 `.env.development` 中设置 `VITE_MOCK_CHAT_WS=true` 启用 WebSocket mock */ +export function isMockChatWebSocketEnabled(): boolean { + const v = import.meta.env.VITE_MOCK_CHAT_WS; + return v === 'true' || v === '1'; +} + +function parseSseDataLines(chunk: string, carry: string): { lines: string[]; rest: string } { + const buf = carry + chunk; + const parts = buf.split('\n'); + const rest = parts.pop() ?? ''; + return { lines: parts, rest }; +} + +function yieldToPaint(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); +} + +async function fetchGatewayChatStreamWs( + ctx: GatewayChatRequestContext, + onMessages: (messages: Message[]) => void, + getBaseMessages: () => Message[] +): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(mockChatWsUrl()); + let messages = [...getBaseMessages()]; + + ws.onopen = () => { + ws.send( + JSON.stringify({ + mode: 'stream', + text: ctx.text, + parentMessageId: ctx.parentMessageId, + sessionId: ctx.sessionId, + roundId: ctx.roundId, + channelId: ctx.channelId ?? 'demo-channel' + }) + ); + }; + + ws.onmessage = (ev) => { + try { + const j = JSON.parse(ev.data as string) as { + kind?: string; + envelope?: GatewayEnvelope; + message?: string; + }; + if (j.kind === 'done') { + ws.close(); + resolve(); + return; + } + if (j.kind === 'error') { + ws.close(); + reject(new Error(j.message ?? 'ws_error')); + return; + } + if (j.kind === 'envelope' && j.envelope) { + messages = applyGatewayEnvelope(messages, j.envelope); + onMessages(messages); + } + } catch (e) { + ws.close(); + reject(e instanceof Error ? e : new Error(String(e))); + } + }; + + ws.onerror = () => { + reject(new Error('websocket_error')); + }; + }); +} + +export async function fetchGatewayChatStream( + ctx: GatewayChatRequestContext, + onMessages: (messages: Message[]) => void, + getBaseMessages: () => Message[] +): Promise { + if (isMockChatWebSocketEnabled()) { + return fetchGatewayChatStreamWs(ctx, onMessages, getBaseMessages); + } + + const { streamPath } = getChatApiConfig(); + const res = await fetch(chatApiHttpUrl(streamPath), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: ctx.text, + parentMessageId: ctx.parentMessageId, + sessionId: ctx.sessionId, + roundId: ctx.roundId, + channelId: ctx.channelId ?? 'demo-channel' + }) + }); + + if (!res.ok) { + throw new Error(`stream http ${res.status}`); + } + + const reader = res.body?.getReader(); + if (!reader) { + throw new Error('no response body'); + } + + const decoder = new TextDecoder(); + let sseCarry = ''; + let messages = [...getBaseMessages()]; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const { lines, rest } = parseSseDataLines(decoder.decode(value, { stream: true }), sseCarry); + sseCarry = rest; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('data:')) continue; + const jsonStr = trimmed.slice(5).trim(); + if (jsonStr === '[DONE]') continue; + try { + const env = JSON.parse(jsonStr) as GatewayEnvelope; + messages = applyGatewayEnvelope(messages, env); + onMessages(messages); + await yieldToPaint(); + } catch (e) { + console.warn('SSE parse skip', jsonStr, e); + } + } + } +} + +export interface SyncResponse { + envelopes: GatewayEnvelope[]; +} + +async function fetchGatewayChatSyncWs(ctx: GatewayChatRequestContext): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(mockChatWsUrl()); + let syncEnvelopes: GatewayEnvelope[] | undefined; + + ws.onopen = () => { + ws.send( + JSON.stringify({ + mode: 'sync', + text: ctx.text, + parentMessageId: ctx.parentMessageId, + sessionId: ctx.sessionId, + roundId: ctx.roundId, + channelId: ctx.channelId ?? 'demo-channel' + }) + ); + }; + + ws.onmessage = (ev) => { + try { + const j = JSON.parse(ev.data as string) as { + kind?: string; + envelopes?: GatewayEnvelope[]; + message?: string; + }; + if (j.kind === 'sync' && Array.isArray(j.envelopes)) { + syncEnvelopes = j.envelopes; + return; + } + if (j.kind === 'done') { + ws.close(); + resolve(syncEnvelopes ?? []); + return; + } + if (j.kind === 'error') { + ws.close(); + reject(new Error(j.message ?? 'ws_error')); + } + } catch (e) { + ws.close(); + reject(e instanceof Error ? e : new Error(String(e))); + } + }; + + ws.onerror = () => reject(new Error('websocket_error')); + }); +} + +export async function fetchGatewayChatSync(ctx: GatewayChatRequestContext): Promise { + if (isMockChatWebSocketEnabled()) { + return fetchGatewayChatSyncWs(ctx); + } + + const { syncPath } = getChatApiConfig(); + const res = await fetch(chatApiHttpUrl(syncPath), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: ctx.text, + parentMessageId: ctx.parentMessageId, + sessionId: ctx.sessionId, + roundId: ctx.roundId, + channelId: ctx.channelId ?? 'demo-channel' + }) + }); + if (!res.ok) { + throw new Error(`sync http ${res.status}`); + } + const data = (await res.json()) as SyncResponse; + return data.envelopes ?? []; +} + +/** 同步:解析全部 envelope 合并到消息列表 */ +export function applyEnvelopes(messages: Message[], envelopes: GatewayEnvelope[]): Message[] { + let next = [...messages]; + for (const env of envelopes) { + next = applyGatewayEnvelope(next, env); + } + return next; +} + +/** 与历史命名兼容 */ +export const fetchMockChatStream = fetchGatewayChatStream; +export const fetchMockChatSync = fetchGatewayChatSync; diff --git a/packages/conversation/src/gateway/gateway-standard.ts b/packages/conversation/src/gateway/gateway-standard.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f65784b0db5f90eaa20a46d0a25d8b1a1070e7f --- /dev/null +++ b/packages/conversation/src/gateway/gateway-standard.ts @@ -0,0 +1,121 @@ +/** + * Gateway → Client 下行约定(对齐《智能消息2.0》§4 / §5;兼容 1.0 与 Socket.IO 协议文档 20260331 流式形态)。 + */ + +import type { GatewayEnvelope, WireMessageBody } from './wire-types'; + +export const GATEWAY_PROTOCOL_VERSION = '3.0'; + +export const DEMO_ENTERPRISE = '9991'; +export const DEMO_CLIENT_USER = '1234'; +export const DEMO_AGENT_USER = '6666'; + +export function standardToRecipients(): Array<{ user: string; enterprise: string }> { + return [{ user: DEMO_CLIENT_USER, enterprise: DEMO_ENTERPRISE }]; +} + +export function standardFromAgent(): { user: string; enterprise: string; role: 'agent' } { + return { user: DEMO_AGENT_USER, enterprise: DEMO_ENTERPRISE, role: 'agent' }; +} + +export function gatewayActionStreamChunk() { + return { method: 'post' as const, path: '/channel/message' as const }; +} + +export function gatewayActionAck() { + return { method: 'post' as const, path: '/channel/message' as const, status: 200 as const }; +} + +export function isoCreationDate(): string { + return new Date().toISOString(); +} + +export function sessionContextFields(ctx: { + parentMessageId: string; + roundId: string; + sessionId: string; +}): { parent: string; roundId: string; sessionId: string } { + return { + parent: ctx.parentMessageId, + roundId: ctx.roundId, + sessionId: ctx.sessionId + }; +} + +export function streamChunkBody(params: { + ctx: { channelId: string; parentMessageId: string; roundId: string; sessionId: string }; + type: 'agent/thinking' | 'agent/dynamic-markdown'; + streamId: string; + chunkIndex: number; + streamStatus: 'start' | 'continue' | 'end'; + chunk: string; +}): WireMessageBody { + const { ctx, type, streamId, chunkIndex, streamStatus, chunk } = params; + return { + id: 'ignored', + message: GATEWAY_PROTOCOL_VERSION, + type, + channel: ctx.channelId, + to: standardToRecipients(), + from: standardFromAgent(), + content: { + ...sessionContextFields(ctx), + streamId, + chunk, + chunkIndex, + streamStatus + } + }; +} + +export function streamCompletionBody(params: { + ctx: { channelId: string; parentMessageId: string; roundId: string; sessionId: string }; + id: string; + type: 'agent/thinking-completion' | 'agent/dynamic-markdown-completion' | 'agent/markdown-completion'; + streamId: string; + text: string; + sources?: string[]; +}): WireMessageBody { + const { ctx, id, type, streamId, text, sources } = params; + return { + id, + message: GATEWAY_PROTOCOL_VERSION, + creationDate: isoCreationDate(), + type, + channel: ctx.channelId, + to: standardToRecipients(), + from: standardFromAgent(), + content: { + ...sessionContextFields(ctx), + streamId, + text, + ...(sources != null && sources.length > 0 ? { sources } : {}) + } + }; +} + +export function cardEnvelope( + tracer: string, + id: string, + type: string, + ctx: { channelId: string; parentMessageId: string; roundId: string; sessionId: string }, + content: Record +): GatewayEnvelope { + return { + headers: { enterprise: DEMO_ENTERPRISE, tracer }, + action: gatewayActionAck(), + body: { + id, + message: GATEWAY_PROTOCOL_VERSION, + creationDate: isoCreationDate(), + type, + channel: ctx.channelId, + to: standardToRecipients(), + from: standardFromAgent(), + content: { + ...sessionContextFields(ctx), + ...content + } + } + }; +} diff --git a/packages/conversation/src/gateway/index.ts b/packages/conversation/src/gateway/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5dc1d75950c959bc0d70fce648633d88ab89896 --- /dev/null +++ b/packages/conversation/src/gateway/index.ts @@ -0,0 +1,8 @@ +export { gatewayAssetUrl } from './asset-url'; +export * from './wire-types'; +export * from './wire-standard-registry'; +export * from './gateway-standard'; +export * from './normalize-wire'; +export * from './apply-gateway-stream'; +export * from './chat-api-config'; +export * from './fetch-gateway-chat'; diff --git a/packages/conversation/src/gateway/normalize-wire.ts b/packages/conversation/src/gateway/normalize-wire.ts new file mode 100644 index 0000000000000000000000000000000000000000..2514e0851afb5c5d457330ae659f09734facff14 --- /dev/null +++ b/packages/conversation/src/gateway/normalize-wire.ts @@ -0,0 +1,345 @@ +/** + * 将网关非流式单条 body 转为 farris Message(解析层)。 + */ +import type { Message } from '../components/conversation/conversation.props'; +import { gatewayAssetUrl as assetUrl } from './asset-url'; +import type { WireMessageBody } from './wire-types'; +import type { + MessageContentLinkCard, + MessageContentUserAuth, + MessageContentReferenceSources, + MessageContentErrorReminder, + ReferenceSourceItem +} from '../components/enterprise-cloud/types'; +import type { MessageContentMarkdown } from '../components/markdown/types'; +import type { TodoItemStatus, TodoWorkItem } from '../components/todo/compopsition/type'; +import type { MessageContentFileOperation, FileOperationItem } from '../components/file-operation/types'; +import type { MessageContentAttachmentFile } from '../components/enterprise-cloud/types'; +import type { MessageContentCoding } from '../components/coding/types'; +import { enterpriseWireShouldStubOnly } from './wire-standard-registry'; + +const ASSISTANT_NAME = 'inBuilder'; + +function assistantShell(id: string, content: Message['content']): Message { + return { + id, + name: ASSISTANT_NAME, + role: 'assistant', + content, + timestamp: Date.now(), + agentId: '' + }; +} + +/** 标准已登记、暂无专项 UI:归一为 UnknownEnterprise,保留 wireType 供后续接组件 */ +function stubUnknownEnterprise(body: WireMessageBody, wireType: string): Message { + return assistantShell(body.id || `msg-${Date.now()}`, { + type: 'UnknownEnterprise', + standardType: 'unknown', + wireType, + hint: `标准 wire 类型「${wireType}」已在归一化层登记(阶段 C);专项 UI 未实现时可在此接入。` + }); +} + +function lineCount(s: string): number { + if (!s) return 0; + return s.split('\n').length; +} + +/** completion / 单条卡片等非增量类 */ +export function normalizeWireBodyToMessage(body: WireMessageBody): Message | null { + const t = body.type; + const c = body.content as Record | undefined; + + if (t === 'agent/thinking-completion' && c?.text != null) { + return assistantShell(body.id || `msg-${Date.now()}`, { + type: 'AgentThinking', + standardType: 'agent/thinking', + streamStatus: 'end', + text: String(c.text), + sources: Array.isArray(c.sources) ? (c.sources as string[]) : undefined + }); + } + + if ((t === 'agent/dynamic-markdown-completion' || t === 'agent/markdown-completion') && c?.text != null) { + const mc: MessageContentMarkdown = { + type: 'markdown', + message: '正式回答(流结束汇总)', + content: String(c.text) + }; + return assistantShell(body.id || `msg-${Date.now()}`, mc); + } + + if (t === 'agent/input-recommend' || t === 'agent/suggestions') { + const title = (c?.title as string) || '输入推荐'; + const rawList = c?.suggestionsList ?? c?.suggestions; + const suggestions = Array.isArray(rawList) ? (rawList as string[]) : []; + if (suggestions.length === 0) return null; + return assistantShell(body.id || `msg-${Date.now()}`, { + type: 'InputRecommend', + title, + suggestions + }); + } + + if (t === 'agent/request-run' && c?.description != null) { + const opts = (c.options as Array<{ option_id?: string; id?: string; name: string; message?: string }>) || []; + const requestId = String(c.requestId ?? body.id ?? `req-${Date.now()}`); + const content: MessageContentUserAuth = { + type: 'UserAuth', + standardType: 'agent/request-run', + requestId, + description: String(c.description), + options: opts.map((o) => ({ + optionId: o.option_id || o.id || `opt-${o.name}`, + name: o.name, + message: o.message ?? o.name + })) + }; + return assistantShell(body.id || `msg-${Date.now()}`, content); + } + + if (t === 'agent/reference' || t === 'agent/reference-sources') { + const rawItems = c?.items ?? c?.references; + if (!Array.isArray(rawItems) || rawItems.length === 0) return null; + const items: ReferenceSourceItem[] = []; + for (const it of rawItems) { + const o = it as Record; + const title = String(o.title ?? o.name ?? '').trim(); + const url = String(o.url ?? o.href ?? '#'); + if (title) items.push({ title, url }); + } + if (items.length === 0) return null; + const content: MessageContentReferenceSources = { + type: 'ReferenceSources', + standardType: 'agent/reference', + items + }; + return assistantShell(body.id || `msg-${Date.now()}`, content); + } + + if (t === 'agent/error' || t === 'agent/error-reminder') { + const errorText = String(c?.errorText ?? c?.message ?? '').trim(); + if (!errorText) return null; + const rawLv = Number(c?.errorLevel ?? 1); + const errorLevel = (rawLv === 0 || rawLv === 1 || rawLv === 2 ? rawLv : 1) as MessageContentErrorReminder['errorLevel']; + const err: MessageContentErrorReminder = { + type: 'ErrorReminder', + standardType: 'agent/error', + errorLevel, + errorText, + errorLink: c?.errorLink != null ? String(c.errorLink) : undefined + }; + return assistantShell(body.id || `msg-${Date.now()}`, err); + } + + if (t === 'card/link' || t === 'card/url' || t === 'link_card') { + const card: MessageContentLinkCard = { + type: 'LinkCard', + standardType: t === 'card/url' ? 'card/url' : undefined, + title: String(c?.title ?? ''), + subtitle: c?.subtitle != null ? String(c.subtitle) : undefined, + url: String(c?.url ?? '#'), + poster: c?.poster != null ? String(c.poster) : undefined, + relatedLinks: Array.isArray(c?.relatedLinks) ? (c.relatedLinks as MessageContentLinkCard['relatedLinks']) : undefined + }; + return assistantShell(body.id || `msg-${Date.now()}`, card); + } + + if (t === 'text/plain' && c?.text != null) { + return assistantShell(body.id || `msg-${Date.now()}`, { type: 'text', text: String(c.text) }); + } + + /** 文本评论:20260318 `comment/text-plain`,content.message 为被评论消息 id */ + if (t === 'comment/text-plain' && c?.text != null) { + const replyTo = c.message != null ? String(c.message) : ''; + const text = String(c.text); + const display = replyTo ? `[评论 · 原消息 ${replyTo}] ${text}` : text; + return assistantShell(body.id || `msg-${Date.now()}`, { type: 'text', text: display }); + } + + /** 富文本(设计篇标题 + 正文)→ markdown */ + if ((t === 'text/rich' || t === 'text/rich-text' || t === 'rich/text') && c?.text != null) { + const title = c.title != null ? String(c.title) : ''; + const text = String(c.text); + const md = title ? `## ${title}\n\n${text}` : text; + const mc: MessageContentMarkdown = { type: 'markdown', message: '富文本', content: md }; + return assistantShell(body.id || `msg-${Date.now()}`, mc); + } + + /** + * 资源组(extended/multi-media):文本 + mediaList 摘要为 markdown;无实质内容则占位 + */ + if (t === 'extended/multi-media') { + const cx = (c ?? {}) as Record; + const parts: string[] = []; + if (cx.title != null) parts.push(`## ${String(cx.title)}\n`); + if (cx.text != null) parts.push(String(cx.text)); + if (Array.isArray(cx.mediaList)) { + if (parts.length) parts.push('\n\n'); + parts.push('**资源组**\n'); + for (const m of cx.mediaList as unknown[]) { + const o = m as Record; + parts.push( + `\n- ${String(o.category ?? '?')} ${String(o.name ?? '')} → ${String(o.media ?? '')}` + ); + } + } + if (parts.length === 0) { + return stubUnknownEnterprise(body, t); + } + const mc: MessageContentMarkdown = { type: 'markdown', message: '资源组消息', content: parts.join('') }; + return assistantShell(body.id || `msg-${Date.now()}`, mc); + } + + /** + * 代码修改 / Diff(Monaco)→ farris `Coding` + * wire:`agent/code-change`(推荐)或别名 `agent/coding` + */ + if (t === 'agent/code-change' || t === 'agent/coding') { + const codeBlock = c?.code as Record | undefined; + if (!c || !codeBlock || codeBlock.value == null) return null; + const value = String(codeBlock.value ?? ''); + const originalValue = + codeBlock.originalValue != null && String(codeBlock.originalValue).length > 0 + ? String(codeBlock.originalValue) + : undefined; + const language = codeBlock.language != null ? String(codeBlock.language) : 'plaintext'; + let addedLines = Number(codeBlock.addedLines); + let deletedLines = Number(codeBlock.deletedLines); + if (!Number.isFinite(addedLines) || addedLines < 0) { + addedLines = lineCount(value); + } + if (!Number.isFinite(deletedLines) || deletedLines < 0) { + deletedLines = originalValue ? lineCount(originalValue) : 0; + } + const coding: MessageContentCoding = { + type: 'Coding', + standardType: t, + message: String(c.message ?? c.summary ?? '代码变更'), + fileIcon: String(c.fileIcon ?? c.icon ?? assetUrl('assets/icon/react.png')), + fileName: String(c.fileName ?? c.path ?? 'snippet.txt'), + code: { value, originalValue, language, addedLines, deletedLines } + }; + return assistantShell(body.id || `msg-${Date.now()}`, coding); + } + + /** + * Agent 工具/文件轨迹(Read / Grep / Search …)→ farris FileOperation + * wire.operations 项支持 { type, message } 或 { op, target };details 结构与 FileOperation 一致 + */ + if ( + (t === 'agent/tool-trace' || t === 'agent/file-trace' || t === 'agent/tool-use') && + c && + Array.isArray(c.operations) + ) { + type WireOp = { + type?: string; + op?: string; + message?: string; + target?: string; + details?: FileOperationItem['details']; + }; + const raw = c.operations as WireOp[]; + const operations: FileOperationItem[] = raw.map((o) => ({ + type: String(o.type ?? o.op ?? 'Read'), + message: String(o.message ?? o.target ?? ''), + details: o.details + })); + const sum = c.summary as { explored?: number; searched?: number } | undefined; + const content: MessageContentFileOperation = { + type: 'FileOperation', + ...(sum != null + ? { + summary: { + explored: Number(sum.explored ?? 0), + searched: Number(sum.searched ?? 0) + } + } + : {}), + operations + }; + return assistantShell(body.id || `msg-${Date.now()}`, content); + } + + /** 企业云:资源「文件」附件 → AttachmentFile 卡片 */ + if ((t === 'resource/file' || t === 'media/file') && c?.name != null && c?.media != null) { + const att: MessageContentAttachmentFile = { + type: 'AttachmentFile', + standardType: 'resource/file', + category: String(c.category ?? 'document'), + name: String(c.name), + size: Number(c.size ?? 0), + media: String(c.media) + }; + return assistantShell(body.id || `msg-${Date.now()}`, att); + } + + /** 图片类资源:与文件相同字段时使用 AttachmentFile */ + if ( + (t === 'resource/image' || t === 'media/image' || t === 'multi-media/image') && + c?.media != null + ) { + const att: MessageContentAttachmentFile = { + type: 'AttachmentFile', + standardType: t, + category: String(c.category ?? 'image'), + name: String(c.name ?? 'image'), + size: Number(c.size ?? 0), + media: String(c.media) + }; + return assistantShell(body.id || `msg-${Date.now()}`, att); + } + + /** 企业云:任务规划/执行 → Todo 列表(支持嵌套 todoList + revision 为元数据,仅存于 wire,可不展示) */ + if ((t === 'agent/task-plan' || t === 'agent/todo-list') && c && Array.isArray(c.taskList)) { + const mapStatus = (raw?: string): TodoItemStatus => { + switch (raw) { + case 'success': + return 'Done'; + case 'fail': + return 'NotStart'; + case 'current': + return 'Working'; + case 'undo': + default: + return 'NotStart'; + } + }; + + type WireTaskRow = { + taskContent?: string; + task?: string; + taskStatus?: string; + todoList?: WireTaskRow[]; + }; + + function mapWireTaskRow(row: WireTaskRow): TodoWorkItem { + const item: TodoWorkItem = { + task: String(row.taskContent ?? row.task ?? ''), + status: mapStatus(row.taskStatus) + }; + if (Array.isArray(row.todoList) && row.todoList.length > 0) { + item.todoList = row.todoList.map(mapWireTaskRow); + item.detailViewMode = 'expand'; + item.initExpanded = true; + } + return item; + } + + const taskList = c.taskList as WireTaskRow[]; + const items = taskList.map(mapWireTaskRow); + const title = (c.title as string) || '任务规划'; + return assistantShell(body.id || `msg-${Date.now()}`, { + type: 'Todo', + message: title, + items + }); + } + + if (enterpriseWireShouldStubOnly(t)) { + return stubUnknownEnterprise(body, t); + } + + return null; +} diff --git a/packages/conversation/src/gateway/wire-standard-registry.ts b/packages/conversation/src/gateway/wire-standard-registry.ts new file mode 100644 index 0000000000000000000000000000000000000000..a911e0671a616593fe184bb36b5b3beb26e81302 --- /dev/null +++ b/packages/conversation/src/gateway/wire-standard-registry.ts @@ -0,0 +1,72 @@ +/** + * 《企业云消息平台 20260318》× 《智能消息2.0》wire `body.type` 登记。 + */ + +export const WIRE_TYPES_HANDLED_IN_NORMALIZE = new Set([ + 'agent/thinking-completion', + 'agent/dynamic-markdown-completion', + 'agent/markdown-completion', + 'agent/input-recommend', + 'agent/suggestions', + 'agent/request-run', + 'agent/reference', + 'agent/reference-sources', + 'agent/error', + 'agent/error-reminder', + 'card/link', + 'card/url', + 'link_card', + 'text/plain', + 'agent/code-change', + 'agent/coding', + 'agent/tool-trace', + 'agent/file-trace', + 'resource/file', + 'media/file', + 'agent/task-plan', + 'agent/todo-list', + 'agent/tool-use', + 'comment/text-plain', + 'text/rich', + 'text/rich-text', + 'rich/text', + 'extended/multi-media', + 'resource/image', + 'media/image', + 'multi-media/image' +]); + +export const ENTERPRISE_WIRE_STUB_TYPES = new Set([ + 'batch/messages', + 'message/batch', + 'agent/dynamic-code', + 'agent/dynamic-code-completion', + 'agent/navigator', + 'agent/form', + 'card/vcard', + 'card/location', + 'card/chart', + 'card/table', + 'card/approval', + 'card/custom', + 'multi-media/video', + 'multi-media/voice', + 'multi-media/short-video', + 'resource/video', + 'resource/audio', + 'resource/sticker', + 'message/reply', + 'sticker/resource' +]); + +const STUB_PREFIXES = ['batch/'] as const; + +export function enterpriseWireShouldStubOnly(t: string): boolean { + if (WIRE_TYPES_HANDLED_IN_NORMALIZE.has(t)) return false; + if (ENTERPRISE_WIRE_STUB_TYPES.has(t)) return true; + for (const p of STUB_PREFIXES) { + if (t.startsWith(p)) return true; + } + if (t.startsWith('card/')) return true; + return false; +} diff --git a/packages/conversation/src/gateway/wire-types.ts b/packages/conversation/src/gateway/wire-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..0662e409b7d5ca63baffd309cb32528aca4dacd3 --- /dev/null +++ b/packages/conversation/src/gateway/wire-types.ts @@ -0,0 +1,55 @@ +/** + * 《智能消息2.0》Gateway → Client 载荷(与企业云设计篇智能体 content 对齐)。 + * - 流式分片:`agent/thinking` | `agent/dynamic-markdown` + * - 流式汇总:`agent/thinking-completion` | **`agent/markdown-completion`**(富文本);兼容旧名 `agent/dynamic-markdown-completion` + * - 待办:`agent/todo-list`(兼容旧名 `agent/task-plan`,content 仍为 taskList 等) + * - 非流式卡片:`body.message`=`3.0`、`to`、`from.role`=`agent`、`creationDate` 等由 `cardEnvelope` 统一补齐 + */ + +export interface WireHeaders { + enterprise: string; + tracer: string; +} + +export interface WireAction { + method: string; + path: string; + status?: number; +} + +export interface WireFrom { + user: string; + enterprise: string; + role?: 'agent' | 'human'; +} + +export type WireToRecipient = { user: string; enterprise: string }; + +export interface WireStreamContent { + parent: string; + roundId: string; + sessionId: string; + streamId: string; + chunk?: string; + chunkIndex?: number; + streamStatus?: 'start' | 'continue' | 'end'; + text?: string; + sources?: string[]; +} + +export interface WireMessageBody { + message?: string; + id: string; + type: string; + channel?: string; + creationDate?: string; + from?: WireFrom; + to?: WireToRecipient[] | string[]; + content?: WireStreamContent | Record; +} + +export interface GatewayEnvelope { + headers: WireHeaders; + action: WireAction; + body: WireMessageBody; +} diff --git a/packages/conversation/src/index.ts b/packages/conversation/src/index.ts index 97ce4c20732c9ee06b8ee0bb4c170649630eb099..1341558bc2468fb8149fe7847eab2208d692231e 100644 --- a/packages/conversation/src/index.ts +++ b/packages/conversation/src/index.ts @@ -58,3 +58,6 @@ export { default as LinkCardMessage } from './components/enterprise-cloud/link-c export { default as ReferenceSourcesMessage } from './components/enterprise-cloud/reference-sources.component'; export { default as UnknownEnterpriseMessage } from './components/enterprise-cloud/unknown-enterprise.component'; export { default as UserAuthMessage } from './components/enterprise-cloud/user-auth.component'; + +/** 智能消息网关:wire 类型、归一化、HTTP/SSE/WebSocket 客户端(交付契约层) */ +export * from './gateway'; diff --git a/packages/conversation/src/vite-env.d.ts b/packages/conversation/src/vite-env.d.ts index 11f02fe2a0061d6e6e1f271b21da95423b448b32..e34b516d0ba49039950a62d16b26ab98621c17df 100644 --- a/packages/conversation/src/vite-env.d.ts +++ b/packages/conversation/src/vite-env.d.ts @@ -1 +1,15 @@ /// + +interface ImportMetaEnv { + /** 设为 `true` 时可用 WebSocket 调 mock(与 HTTP/SSE 场景数据一致) */ + readonly VITE_MOCK_CHAT_WS?: string; + /** 覆盖 `src/gateway/chat-api.json` 的 baseUrl,如 `http://127.0.0.1:18080` */ + readonly VITE_CHAT_API_BASE_URL?: string; + readonly VITE_CHAT_API_STREAM_PATH?: string; + readonly VITE_CHAT_API_SYNC_PATH?: string; + readonly VITE_CHAT_API_WS_PATH?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/packages/workbench/.env.development.example b/packages/workbench/.env.development.example new file mode 100644 index 0000000000000000000000000000000000000000..5bc55b4112fafc44b6532bee851af20df22c9317 --- /dev/null +++ b/packages/workbench/.env.development.example @@ -0,0 +1,7 @@ +# 复制为 .env.development.local(已被 .gitignore 忽略)后按需修改。 +# 指向本地 Spring Boot mock(默认端口见 mock-chat-spring-boot/application.properties)。 +VITE_CHAT_API_BASE_URL=http://127.0.0.1:18080 + +# 未配置 baseUrl 时走 Vite 同源 / Vite 插件 mock。 +# Spring 侧未提供 WS 时不要开启: +# VITE_MOCK_CHAT_WS=true diff --git a/packages/workbench/src/components/workbench.component.tsx b/packages/workbench/src/components/workbench.component.tsx index 119b47e694bc57d0e01b31d8a3eb14fedf74324f..cb70ee12e2e069cf02b4918c92f7ba9419cf2497 100644 --- a/packages/workbench/src/components/workbench.component.tsx +++ b/packages/workbench/src/components/workbench.component.tsx @@ -16,6 +16,7 @@ import AgentHub from './agent-hub/agent-hub.component'; import FunctionBoard from './function-board/function-board.component'; import WorkbenchContent from './workbench-content/workbench-content.component'; import type { WorkbenchContentItem } from './workbench-content/workbench-content.props'; +import { applyEnvelopes, fetchMockChatStream, fetchMockChatSync } from '@farris/x-conversation'; import { SkillMarketPanel } from './skill-market/index'; const NAV_ACTIONS: NavActionItem[] = [ @@ -342,51 +343,89 @@ export default defineComponent({ return; } const newConv = await loadConversationFromJson(NEW_TASK_CONVERSATION_URL); - const userMsg: Message = { - id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - name: 'Sagi', - role: 'user', - content: { type: 'text', text: content }, - timestamp: Date.now(), - agentId: '' - }; - newConv.pendingMessages = [userMsg]; + /** 不在 pending 里塞用户句,避免与下方「追加用户 + mock 流」重复一条 */ + newConv.pendingMessages = []; conversations.value = [...conversations.value, newConv]; activeConversationId.value = newConv.id; idx = conversations.value.length - 1; conv = newConv; } - const pending = [...conv.pendingMessages]; + let pending = [...conv.pendingMessages]; const displayed = [...conv.messages]; if (pending.length === 0 && !content.trim()) { return; } - if (pending.length > 0) { - const [next, ...rest] = pending; - const updated = [...conversations.value]; - updated[idx] = { - ...conv, - messages: [...displayed, next], - pendingMessages: rest, + const trimmed = content.trim(); + + /** 有输入:展示用户气泡 → 清空预置队列 → 服务端流式 SSE 或同步 JSON(地址见 demo/api/chat-api.json + VITE_CHAT_API_*) */ + if (trimmed) { + const userLine: Message = { + id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + name: 'Sagi', + role: 'user', + content: { type: 'text', text: trimmed }, + timestamp: Date.now(), + agentId: '' + }; + const nextMessages = [...displayed, userLine]; + while (pending.length > 0 && pending[0].role === 'user') { + pending = pending.slice(1); + } + pending = []; + const convId = conv.id; + const j0 = conversations.value.findIndex((c) => c.id === convId); + if (j0 < 0) { + return; + } + const row0 = [...conversations.value]; + row0[j0] = { ...row0[j0], messages: nextMessages, pendingMessages: pending }; + conversations.value = row0; + + const chatCtx = { + text: trimmed, + parentMessageId: userLine.id, + sessionId: sessionId.value, + roundId: `round-${Date.now()}`, + channelId: 'demo-channel' }; - conversations.value = updated; + + try { + if (trimmed.startsWith('【同步】')) { + const envelopes = await fetchMockChatSync(chatCtx); + const merged = applyEnvelopes(nextMessages, envelopes); + const j = conversations.value.findIndex((c) => c.id === convId); + if (j >= 0) { + const row = [...conversations.value]; + row[j] = { ...row[j], messages: merged }; + conversations.value = row; + } + } else { + await fetchMockChatStream( + chatCtx, + (msgs) => { + const j = conversations.value.findIndex((c) => c.id === convId); + if (j < 0) { + return; + } + const row = [...conversations.value]; + row[j] = { ...row[j], messages: msgs }; + conversations.value = row; + }, + () => { + const j = conversations.value.findIndex((c) => c.id === convId); + return j >= 0 ? [...conversations.value[j].messages] : []; + } + ); + } + } catch (e) { + // eslint-disable-next-line no-console + console.error('[mock chat]', e); + } return; } - - const newMsg: Message = { - id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - name: 'Sagi', - role: 'user', - content: { type: 'text', text: content }, - timestamp: Date.now(), - agentId: '' - }; - const updated = [...conversations.value]; - updated[idx] = { ...conv, messages: [...displayed, newMsg], pendingMessages: pending }; - conversations.value = updated; } const assistiveTools: AssistiveTool[] = [ diff --git a/packages/workbench/tsconfig.json b/packages/workbench/tsconfig.json index 03ae6f26f734c248039b8f64bbc84b4da1ab5240..ef8cf5c8e7ac7a2a7761c7a1ce261b2cde1e2788 100644 --- a/packages/workbench/tsconfig.json +++ b/packages/workbench/tsconfig.json @@ -20,7 +20,8 @@ "paths": { "@/*": ["./src/*"], "@farris/x-conversation": ["../conversation/src/index.ts"], - "@farris/x-conversation/*": ["../conversation/src/*"] + "@farris/x-conversation/*": ["../conversation/src/*"], + "@conversation-demo/*": ["../conversation/demo/*"] }, "declaration": true, "declarationDir": "./dist" diff --git a/packages/workbench/vite.config.ts b/packages/workbench/vite.config.ts index 05016ac666351260d021aa32c42721af6cb4753b..5cbe8a3b2dbb87c80c436fc82c37b2d462b96239 100644 --- a/packages/workbench/vite.config.ts +++ b/packages/workbench/vite.config.ts @@ -2,14 +2,20 @@ import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import vueJsx from '@vitejs/plugin-vue-jsx'; import { resolve } from 'path'; +import { mockEnterpriseApiPlugin } from '../conversation/demo/mock-api/vite-plugin-mock-enterprise'; +// Mock:`POST /api/mock/chat/stream` 等,与 `packages/conversation` demo 共用 `getStreamScenario`(如「【流式】全景」) export default defineConfig({ base: './', - plugins: [vue(), vueJsx()], + plugins: [vue(), vueJsx(), mockEnterpriseApiPlugin()], resolve: { alias: [ { find: '@farris/x-conversation/src', replacement: resolve(__dirname, '../conversation/src') }, { find: '@farris/x-conversation', replacement: resolve(__dirname, '../conversation/src/index.ts') }, + { + find: '@conversation-demo', + replacement: resolve(__dirname, '../conversation/demo'), + }, { find: '@', replacement: resolve(__dirname, 'src') }, ], },