feat(webui): add data adapter layer for ACP and JSONL protocols

- Implement ACPAdapter to convert ACP protocol messages to unified format
- Implement JSONLAdapter to convert JSONL format messages to unified format
- Define unified message types for consistent component rendering
- Add helper functions for timeline position calculation (isFirst/isLast)
- Enable cross-platform message format compatibility

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
yiliang114 2026-01-20 21:33:35 +08:00
parent 5ad731fb2b
commit 1861557d15
4 changed files with 345 additions and 0 deletions

View file

@ -0,0 +1,109 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Adapter for ACP protocol messages (used by vscode-ide-companion)
*/
import type {
UnifiedMessage,
ACPMessage,
ACPMessageData,
ToolCallData,
} from './types.js';
/**
* Check if a message is a user message (breaks AI sequence)
*/
function isUserMessage(msg: ACPMessage | undefined): boolean {
if (!msg) return true;
if (msg.type !== 'message') return false;
const data = msg.data as ACPMessageData;
return data?.role === 'user';
}
/**
* Adapt ACP messages to unified format
*
* @param messages - Array of ACP messages from vscode-ide-companion
* @returns Array of unified messages with timeline positions calculated
*/
export function adaptACPMessages(messages: ACPMessage[]): UnifiedMessage[] {
return messages.map((item, index, arr) => {
const prev = arr[index - 1];
const next = arr[index + 1];
// Calculate timeline position
const isFirst = isUserMessage(prev);
const isLast = isUserMessage(next);
switch (item.type) {
case 'message': {
const msg = item.data as ACPMessageData;
return {
id: `msg-${index}`,
type:
msg.role === 'user'
? 'user'
: msg.role === 'thinking'
? 'thinking'
: 'assistant',
timestamp: msg.timestamp || Date.now(),
content: msg.content,
fileContext: msg.fileContext,
isFirst,
isLast,
};
}
case 'in-progress-tool-call':
case 'completed-tool-call': {
const toolCall = item.data as ToolCallData;
return {
id: `tool-${toolCall.toolCallId}-${item.type}`,
type: 'tool_call',
timestamp: Date.now(),
toolCall,
isFirst,
isLast,
};
}
default:
// Fallback for unknown types
return {
id: `unknown-${index}`,
type: 'assistant',
timestamp: Date.now(),
content: '',
isFirst,
isLast,
};
}
});
}
/**
* Type guard to check if data is a tool call
*/
export function isToolCallData(data: unknown): data is ToolCallData {
return (
typeof data === 'object' &&
data !== null &&
'toolCallId' in data &&
'kind' in data
);
}
/**
* Type guard to check if data is a message
*/
export function isMessageData(data: unknown): data is ACPMessageData {
return (
typeof data === 'object' &&
data !== null &&
'role' in data &&
'content' in data
);
}

View file

@ -0,0 +1,126 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Adapter for JSONL format messages (used by ChatViewer)
*/
import type {
UnifiedMessage,
JSONLMessage,
UnifiedMessageType,
} from './types.js';
/**
* Extract text content from different message formats
*/
function extractContent(message?: {
parts?: Array<{ text: string }>;
content?: string | unknown[];
}): string {
if (!message) return '';
// Qwen format: parts array
if (message.parts?.length) {
return message.parts.map((p) => p.text).join('');
}
// Claude format: string content
if (typeof message.content === 'string') {
return message.content;
}
// Claude format: content array
if (Array.isArray(message.content)) {
return message.content
.filter(
(item): item is { type: 'text'; text: string } =>
typeof item === 'object' &&
item !== null &&
'type' in item &&
item.type === 'text',
)
.map((item) => item.text)
.join('');
}
return '';
}
/**
* Parse timestamp string to milliseconds
*/
function parseTimestamp(timestamp: string): number {
const parsed = Date.parse(timestamp);
return isNaN(parsed) ? Date.now() : parsed;
}
/**
* Determine the unified message type from JSONL message
*/
function getMessageType(msg: JSONLMessage): UnifiedMessageType {
if (msg.type === 'tool_call') {
return 'tool_call';
}
if (msg.type === 'user') {
return 'user';
}
if (msg.message?.role === 'thinking') {
return 'thinking';
}
return 'assistant';
}
/**
* Check if a message is a user type (breaks AI sequence)
*/
function isUserType(msg: JSONLMessage | undefined): boolean {
return !msg || msg.type === 'user';
}
/**
* Adapt JSONL messages to unified format
*
* @param messages - Array of JSONL messages
* @returns Array of unified messages with timeline positions calculated
*/
export function adaptJSONLMessages(messages: JSONLMessage[]): UnifiedMessage[] {
// Sort by timestamp
const sorted = [...messages].sort(
(a, b) => parseTimestamp(a.timestamp) - parseTimestamp(b.timestamp),
);
return sorted.map((msg, index, arr) => {
const prev = arr[index - 1];
const next = arr[index + 1];
// Calculate timeline position
const isFirst = isUserType(prev);
const isLast = isUserType(next);
const type = getMessageType(msg);
return {
id: msg.uuid,
type,
timestamp: parseTimestamp(msg.timestamp),
content: type !== 'tool_call' ? extractContent(msg.message) : undefined,
toolCall: msg.toolCall,
isFirst,
isLast,
};
});
}
/**
* Filter out empty messages (except tool calls)
*/
export function filterEmptyMessages(
messages: UnifiedMessage[],
): UnifiedMessage[] {
return messages.filter((msg) => {
if (msg.type === 'tool_call') return true;
return msg.content && msg.content.trim().length > 0;
});
}

View file

@ -0,0 +1,28 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Adapter layer for normalizing different data formats to unified message format
*/
// Type exports
export type {
UnifiedMessage,
UnifiedMessageType,
JSONLMessage,
ACPMessage,
ACPMessageData,
ToolCallData,
FileContext,
} from './types.js';
// JSONL Adapter (for ChatViewer)
export { adaptJSONLMessages, filterEmptyMessages } from './JSONLAdapter.js';
// ACP Adapter (for vscode-ide-companion)
export {
adaptACPMessages,
isToolCallData,
isMessageData,
} from './ACPAdapter.js';

View file

@ -0,0 +1,82 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Unified message types for adapter layer
*/
import type { ToolCallData } from '../components/toolcalls/shared/types.js';
import type { FileContext } from '../components/messages/UserMessage.js';
/**
* Unified message type used by all webui components
*/
export type UnifiedMessageType =
| 'user'
| 'assistant'
| 'tool_call'
| 'thinking';
/**
* Unified message format - normalized from ACP or JSONL sources
*/
export interface UnifiedMessage {
/** Unique identifier */
id: string;
/** Message type */
type: UnifiedMessageType;
/** Timestamp in milliseconds */
timestamp: number;
/** Text content (for user/assistant/thinking messages) */
content?: string;
/** Tool call data (for tool_call type) */
toolCall?: ToolCallData;
/** Whether this is the first item in an AI response sequence */
isFirst: boolean;
/** Whether this is the last item in an AI response sequence */
isLast: boolean;
/** File context for user messages */
fileContext?: FileContext[];
}
// Re-export FileContext for convenience
export type { FileContext };
/**
* JSONL chat message format (ChatViewer input)
*/
export interface JSONLMessage {
uuid: string;
parentUuid?: string | null;
sessionId?: string;
timestamp: string; // ISO timestamp string
type: 'user' | 'assistant' | 'system' | 'tool_call';
message?: {
role?: string;
parts?: Array<{ text: string }>; // Qwen format
content?: string | unknown[]; // Claude format
};
model?: string;
toolCall?: ToolCallData;
}
/**
* ACP message format (vscode-ide-companion input)
*/
export interface ACPMessage {
type: 'message' | 'in-progress-tool-call' | 'completed-tool-call';
data: ACPMessageData | ToolCallData;
}
/**
* ACP text message data
*/
export interface ACPMessageData {
role: 'user' | 'assistant' | 'thinking';
content: string;
timestamp?: number;
fileContext?: FileContext[];
}
export type { ToolCallData };