fix: conversation items (#6815)

This commit is contained in:
Dinh Long Nguyen 2025-10-24 09:01:31 +07:00 committed by GitHub
parent e46200868e
commit f07e43cfe0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 274 additions and 127 deletions

View File

@ -12,7 +12,7 @@ jobs:
build-and-preview:
runs-on: [ubuntu-24-04-docker]
env:
JAN_API_BASE: "https://api-dev.menlo.ai/v1"
MENLO_PLATFORM_BASE_URL: "https://api-dev.menlo.ai/v1"
permissions:
pull-requests: write
contents: write
@ -52,7 +52,7 @@ jobs:
- name: Build docker image
run: |
docker build --build-arg JAN_API_BASE=${{ env.JAN_API_BASE }} -t ${{ steps.vars.outputs.FULL_IMAGE }} .
docker build --build-arg MENLO_PLATFORM_BASE_URL=${{ env.MENLO_PLATFORM_BASE_URL }} -t ${{ steps.vars.outputs.FULL_IMAGE }} .
- name: Push docker image
if: github.event_name == 'push'

View File

@ -13,7 +13,7 @@ jobs:
deployments: write
pull-requests: write
env:
JAN_API_BASE: "https://api.menlo.ai/v1"
MENLO_PLATFORM_BASE_URL: "https://api.menlo.ai/v1"
GA_MEASUREMENT_ID: "G-YK53MX8M8M"
CLOUDFLARE_PROJECT_NAME: "jan-server-web"
steps:
@ -43,7 +43,7 @@ jobs:
- name: Install dependencies
run: make config-yarn && yarn install && yarn build:core && make build-web-app
env:
JAN_API_BASE: ${{ env.JAN_API_BASE }}
MENLO_PLATFORM_BASE_URL: ${{ env.MENLO_PLATFORM_BASE_URL }}
GA_MEASUREMENT_ID: ${{ env.GA_MEASUREMENT_ID }}
- name: Publish to Cloudflare Pages Production

View File

@ -12,7 +12,7 @@ jobs:
build-and-preview:
runs-on: [ubuntu-24-04-docker]
env:
JAN_API_BASE: "https://api-stag.menlo.ai/v1"
MENLO_PLATFORM_BASE_URL: "https://api-stag.menlo.ai/v1"
permissions:
pull-requests: write
contents: write
@ -52,7 +52,7 @@ jobs:
- name: Build docker image
run: |
docker build --build-arg JAN_API_BASE=${{ env.JAN_API_BASE }} -t ${{ steps.vars.outputs.FULL_IMAGE }} .
docker build --build-arg MENLO_PLATFORM_BASE_URL=${{ env.MENLO_PLATFORM_BASE_URL }} -t ${{ steps.vars.outputs.FULL_IMAGE }} .
- name: Push docker image
if: github.event_name == 'push'

View File

@ -1,8 +1,8 @@
# Stage 1: Build stage with Node.js and Yarn v4
FROM node:20-alpine AS builder
ARG JAN_API_BASE=https://api-dev.jan.ai/v1
ENV JAN_API_BASE=$JAN_API_BASE
ARG MENLO_PLATFORM_BASE_URL=https://api-dev.menlo.ai/v1
ENV MENLO_PLATFORM_BASE_URL=$MENLO_PLATFORM_BASE_URL
# Install build dependencies
RUN apk add --no-cache \

View File

@ -16,7 +16,7 @@ import {
ListConversationItemsResponse
} from './types'
declare const JAN_API_BASE: string
declare const MENLO_PLATFORM_BASE_URL: string
export class RemoteApi {
private authService: JanAuthService
@ -28,7 +28,7 @@ export class RemoteApi {
async createConversation(
data: Conversation
): Promise<ConversationResponse> {
const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATIONS}`
const url = `${MENLO_PLATFORM_BASE_URL}${CONVERSATION_API_ROUTES.CONVERSATIONS}`
return this.authService.makeAuthenticatedRequest<ConversationResponse>(
url,
@ -43,7 +43,7 @@ export class RemoteApi {
conversationId: string,
data: Conversation
): Promise<ConversationResponse> {
const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATION_BY_ID(conversationId)}`
const url = `${MENLO_PLATFORM_BASE_URL}${CONVERSATION_API_ROUTES.CONVERSATION_BY_ID(conversationId)}`
return this.authService.makeAuthenticatedRequest<ConversationResponse>(
url,
@ -70,7 +70,7 @@ export class RemoteApi {
}
const queryString = queryParams.toString()
const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATIONS}${queryString ? `?${queryString}` : ''}`
const url = `${MENLO_PLATFORM_BASE_URL}${CONVERSATION_API_ROUTES.CONVERSATIONS}${queryString ? `?${queryString}` : ''}`
return this.authService.makeAuthenticatedRequest<ListConversationsResponse>(
url,
@ -114,7 +114,7 @@ export class RemoteApi {
}
async deleteConversation(conversationId: string): Promise<void> {
const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATION_BY_ID(conversationId)}`
const url = `${MENLO_PLATFORM_BASE_URL}${CONVERSATION_API_ROUTES.CONVERSATION_BY_ID(conversationId)}`
await this.authService.makeAuthenticatedRequest(
url,
@ -141,7 +141,7 @@ export class RemoteApi {
}
const queryString = queryParams.toString()
const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATION_ITEMS(conversationId)}${queryString ? `?${queryString}` : ''}`
const url = `${MENLO_PLATFORM_BASE_URL}${CONVERSATION_API_ROUTES.CONVERSATION_ITEMS(conversationId)}${queryString ? `?${queryString}` : ''}`
return this.authService.makeAuthenticatedRequest<ListConversationItemsResponse>(
url,

View File

@ -31,7 +31,7 @@ export interface ConversationResponse {
id: string
object: 'conversation'
title?: string
created_at: number
created_at: number | string
metadata: ConversationMetadata
}
@ -50,6 +50,7 @@ export interface ConversationItemAnnotation {
}
export interface ConversationItemContent {
type?: string
file?: {
file_id?: string
mime_type?: string
@ -62,23 +63,50 @@ export interface ConversationItemContent {
file_id?: string
url?: string
}
image_file?: {
file_id?: string
mime_type?: string
}
input_text?: string
output_text?: {
annotations?: ConversationItemAnnotation[]
text?: string
}
reasoning_content?: string
text?: {
value?: string
text?: string
}
type?: string
reasoning_content?: string
tool_calls?: Array<{
id?: string
type?: string
function?: {
name?: string
arguments?: string
}
}>
tool_call_id?: string
tool_result?: {
content?: Array<{
type?: string
text?: string
output_text?: {
text?: string
}
}>
output_text?: {
text?: string
}
}
text_result?: string
}
export interface ConversationItem {
content?: ConversationItemContent[]
created_at: number
created_at: number | string
id: string
object: string
metadata?: Record<string, unknown>
role: string
status?: string
type?: string

View File

@ -1,5 +1,5 @@
import { Thread, ThreadAssistantInfo, ThreadMessage, ContentType } from '@janhq/core'
import { Conversation, ConversationResponse, ConversationItem } from './types'
import { Conversation, ConversationResponse, ConversationItem, ConversationItemContent, ConversationMetadata } from './types'
import { DEFAULT_ASSISTANT } from './const'
export class ObjectParser {
@ -7,7 +7,7 @@ export class ObjectParser {
const modelName = thread.assistants?.[0]?.model?.id || undefined
const modelProvider = thread.assistants?.[0]?.model?.engine || undefined
const isFavorite = thread.metadata?.is_favorite?.toString() || 'false'
let metadata = {}
let metadata: ConversationMetadata = {}
if (modelName && modelProvider) {
metadata = {
model_id: modelName,
@ -23,15 +23,14 @@ export class ObjectParser {
static conversationToThread(conversation: ConversationResponse): Thread {
const assistants: ThreadAssistantInfo[] = []
if (
conversation.metadata?.model_id &&
conversation.metadata?.model_provider
) {
const metadata: ConversationMetadata = conversation.metadata || {}
if (metadata.model_id && metadata.model_provider) {
assistants.push({
...DEFAULT_ASSISTANT,
model: {
id: conversation.metadata.model_id,
engine: conversation.metadata.model_provider,
id: metadata.model_id,
engine: metadata.model_provider,
},
})
} else {
@ -44,16 +43,18 @@ export class ObjectParser {
})
}
const isFavorite = conversation.metadata?.is_favorite === 'true'
const isFavorite = metadata.is_favorite === 'true'
const createdAtMs = parseTimestamp(conversation.created_at)
return {
id: conversation.id,
title: conversation.title || '',
assistants,
created: conversation.created_at,
updated: conversation.created_at,
created: createdAtMs,
updated: createdAtMs,
model: {
id: conversation.metadata.model_id,
provider: conversation.metadata.model_provider,
id: metadata.model_id,
provider: metadata.model_provider,
},
isFavorite,
metadata: { is_favorite: isFavorite },
@ -65,74 +66,70 @@ export class ObjectParser {
threadId: string
): ThreadMessage {
// Extract text content and metadata from the item
let textContent = ''
let reasoningContent = ''
const textSegments: string[] = []
const reasoningSegments: string[] = []
const imageUrls: string[] = []
let toolCalls: any[] = []
let finishReason = ''
if (item.content && item.content.length > 0) {
for (const content of item.content) {
// Handle text content
if (content.text?.value) {
textContent = content.text.value
}
// Handle output_text for assistant messages
if (content.output_text?.text) {
textContent = content.output_text.text
}
// Handle reasoning content
if (content.reasoning_content) {
reasoningContent = content.reasoning_content
}
// Handle image content
if (content.image?.url) {
imageUrls.push(content.image.url)
}
// Extract finish_reason
if (content.finish_reason) {
finishReason = content.finish_reason
}
}
}
// Handle tool calls parsing for assistant messages
if (item.role === 'assistant' && finishReason === 'tool_calls') {
try {
// Tool calls are embedded as JSON string in textContent
const toolCallMatch = textContent.match(/\[.*\]/)
if (toolCallMatch) {
const toolCallsData = JSON.parse(toolCallMatch[0])
toolCalls = toolCallsData.map((toolCall: any) => ({
tool: {
id: toolCall.id || 'unknown',
function: {
name: toolCall.function?.name || 'unknown',
arguments: toolCall.function?.arguments || '{}'
},
type: toolCall.type || 'function'
},
response: {
error: '',
content: []
},
state: 'ready'
}))
// Remove tool calls JSON from text content, keep only reasoning
textContent = ''
}
} catch (error) {
console.error('Failed to parse tool calls:', error)
extractContentByType(content, {
onText: (value) => {
if (value) {
textSegments.push(value)
}
},
onReasoning: (value) => {
if (value) {
reasoningSegments.push(value)
}
},
onImage: (url) => {
if (url) {
imageUrls.push(url)
}
},
onToolCalls: (calls) => {
toolCalls = calls.map((toolCall) => {
const callId = toolCall.id || 'unknown'
const rawArgs = toolCall.function?.arguments
const normalizedArgs =
typeof rawArgs === 'string'
? rawArgs
: JSON.stringify(rawArgs ?? {})
return {
id: callId,
tool_call_id: callId,
tool: {
id: callId,
function: {
name: toolCall.function?.name || 'unknown',
arguments: normalizedArgs,
},
type: toolCall.type || 'function',
},
response: {
error: '',
content: [],
},
state: 'pending',
}
})
},
})
}
}
// Format final content with reasoning if present
let finalTextValue = ''
if (reasoningContent) {
finalTextValue = `<think>${reasoningContent}</think>`
if (reasoningSegments.length > 0) {
finalTextValue += `<think>${reasoningSegments.join('\n')}</think>`
}
if (textContent) {
finalTextValue += textContent
if (textSegments.length > 0) {
if (finalTextValue) {
finalTextValue += '\n'
}
finalTextValue += textSegments.join('\n')
}
// Build content array for ThreadMessage
@ -157,22 +154,26 @@ export class ObjectParser {
}
// Build metadata
const metadata: any = {}
const metadata: any = { ...(item.metadata || {}) }
if (toolCalls.length > 0) {
metadata.tool_calls = toolCalls
}
const createdAtMs = parseTimestamp(item.created_at)
// Map status from server format to frontend format
const mappedStatus = item.status === 'completed' ? 'ready' : item.status || 'ready'
const role = item.role === 'user' || item.role === 'assistant' ? item.role : 'assistant'
return {
type: 'text',
id: item.id,
object: 'thread.message',
thread_id: threadId,
role: item.role as 'user' | 'assistant',
role,
content: messageContent,
created_at: item.created_at * 1000, // Convert to milliseconds
created_at: createdAtMs,
completed_at: 0,
status: mappedStatus,
metadata,
@ -201,25 +202,46 @@ export const combineConversationItemsToMessages = (
): ThreadMessage[] => {
const messages: ThreadMessage[] = []
const toolResponseMap = new Map<string, any>()
const sortedItems = [...items].sort(
(a, b) => parseTimestamp(a.created_at) - parseTimestamp(b.created_at)
)
// First pass: collect tool responses
for (const item of items) {
for (const item of sortedItems) {
if (item.role === 'tool') {
const toolContent = item.content?.[0]?.text?.value || ''
toolResponseMap.set(item.id, {
error: '',
content: [
{
type: 'text',
text: toolContent
}
]
})
for (const content of item.content ?? []) {
const toolCallId = content.tool_call_id || item.id
const toolResultText =
content.tool_result?.output_text?.text ||
(Array.isArray(content.tool_result?.content)
? content.tool_result?.content
?.map((entry) => entry.text || entry.output_text?.text)
.filter((text): text is string => Boolean(text))
.join('\n')
: undefined)
const toolContent =
content.text?.text ||
content.text?.value ||
content.output_text?.text ||
content.input_text ||
content.text_result ||
toolResultText ||
''
toolResponseMap.set(toolCallId, {
error: '',
content: [
{
type: 'text',
text: toolContent,
},
],
})
}
}
}
// Second pass: build messages and merge tool responses
for (const item of items) {
for (const item of sortedItems) {
// Skip tool messages as they will be merged into assistant messages
if (item.role === 'tool') {
continue
@ -228,14 +250,35 @@ export const combineConversationItemsToMessages = (
const message = ObjectParser.conversationItemToThreadMessage(item, threadId)
// If this is an assistant message with tool calls, merge tool responses
if (message.role === 'assistant' && message.metadata?.tool_calls && Array.isArray(message.metadata.tool_calls)) {
if (
message.role === 'assistant' &&
message.metadata?.tool_calls &&
Array.isArray(message.metadata.tool_calls)
) {
const toolCalls = message.metadata.tool_calls as any[]
let toolResponseIndex = 0
for (const [responseId, responseData] of toolResponseMap.entries()) {
if (toolResponseIndex < toolCalls.length) {
toolCalls[toolResponseIndex].response = responseData
toolResponseIndex++
for (const toolCall of toolCalls) {
const callId = toolCall.tool_call_id || toolCall.id || toolCall.tool?.id
let responseKey: string | undefined
let response: any = null
if (callId && toolResponseMap.has(callId)) {
responseKey = callId
response = toolResponseMap.get(callId)
} else {
const iterator = toolResponseMap.entries().next()
if (!iterator.done) {
responseKey = iterator.value[0]
response = iterator.value[1]
}
}
if (response) {
toolCall.response = response
toolCall.state = 'succeeded'
if (responseKey) {
toolResponseMap.delete(responseKey)
}
}
}
}
@ -245,3 +288,79 @@ export const combineConversationItemsToMessages = (
return messages
}
const parseTimestamp = (value: number | string | undefined): number => {
if (typeof value === 'number') {
// Distinguish between seconds and milliseconds
return value > 1e12 ? value : value * 1000
}
if (typeof value === 'string') {
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? Date.now() : parsed
}
return Date.now()
}
const extractContentByType = (
content: ConversationItemContent,
handlers: {
onText: (value: string) => void
onReasoning: (value: string) => void
onImage: (url: string) => void
onToolCalls: (calls: NonNullable<ConversationItemContent['tool_calls']>) => void
}
) => {
const type = content.type || ''
switch (type) {
case 'input_text':
handlers.onText(content.input_text || '')
break
case 'text':
handlers.onText(content.text?.text || content.text?.value || '')
break
case 'output_text':
handlers.onText(content.output_text?.text || '')
break
case 'reasoning_content':
handlers.onReasoning(content.reasoning_content || '')
break
case 'image':
case 'image_url':
if (content.image?.url) {
handlers.onImage(content.image.url)
}
break
case 'tool_calls':
if (content.tool_calls && Array.isArray(content.tool_calls)) {
handlers.onToolCalls(content.tool_calls)
}
break
case 'tool_result':
if (content.tool_result?.output_text?.text) {
handlers.onText(content.tool_result.output_text.text)
}
break
default:
// Fallback for legacy fields without explicit type
if (content.text?.value || content.text?.text) {
handlers.onText(content.text.value || content.text.text || '')
}
if (content.text_result) {
handlers.onText(content.text_result)
}
if (content.output_text?.text) {
handlers.onText(content.output_text.text)
}
if (content.reasoning_content) {
handlers.onReasoning(content.reasoning_content)
}
if (content.image?.url) {
handlers.onImage(content.image.url)
}
if (content.tool_calls && Array.isArray(content.tool_calls)) {
handlers.onToolCalls(content.tool_calls)
}
break
}
}

View File

@ -8,7 +8,7 @@ import { ApiError } from '../shared/types/errors'
import { JAN_API_ROUTES } from './const'
import { JanModel, janProviderStore } from './store'
// JAN_API_BASE is defined in vite.config.ts
// MENLO_PLATFORM_BASE_URL is defined in vite.config.ts
// Constants
const TEMPORARY_CHAT_ID = 'temporary-chat'
@ -20,7 +20,7 @@ const TEMPORARY_CHAT_ID = 'temporary-chat'
*/
function getChatCompletionConfig(request: JanChatCompletionRequest, stream: boolean = false) {
const isTemporaryChat = request.conversation_id === TEMPORARY_CHAT_ID
const endpoint = `${JAN_API_BASE}${JAN_API_ROUTES.CHAT_COMPLETIONS}`
const endpoint = `${MENLO_PLATFORM_BASE_URL}${JAN_API_ROUTES.CHAT_COMPLETIONS}`
const payload = {
...request,
@ -162,7 +162,7 @@ export class JanApiClient {
this.modelsFetchPromise = (async () => {
const response = await this.authService.makeAuthenticatedRequest<JanModelsResponse>(
`${JAN_API_BASE}${JAN_API_ROUTES.MODELS}`
`${MENLO_PLATFORM_BASE_URL}${JAN_API_ROUTES.MODELS}`
)
const summaries = response.data || []
@ -327,7 +327,7 @@ export class JanApiClient {
private async fetchSupportedParameters(modelId: string): Promise<string[]> {
try {
const endpoint = `${JAN_API_BASE}${JAN_API_ROUTES.MODEL_CATALOGS}/${this.encodeModelIdForCatalog(modelId)}`
const endpoint = `${MENLO_PLATFORM_BASE_URL}${JAN_API_ROUTES.MODEL_CATALOGS}/${this.encodeModelIdForCatalog(modelId)}`
const catalog = await this.authService.makeAuthenticatedRequest<JanModelCatalogResponse>(endpoint)
return this.extractSupportedParameters(catalog)
} catch (error) {

View File

@ -12,8 +12,8 @@ import { JanMCPOAuthProvider } from './oauth-provider'
import { WebSearchButton } from './components'
import type { ComponentType } from 'react'
// JAN_API_BASE is defined in vite.config.ts (defaults to 'https://api-dev.jan.ai/jan/v1')
declare const JAN_API_BASE: string
// MENLO_PLATFORM_BASE_URL is defined in vite.config.ts (defaults to 'https://api-dev.menlo.ai/jan/v1')
declare const MENLO_PLATFORM_BASE_URL: string
export default class MCPExtensionWeb extends MCPExtension {
private mcpEndpoint = '/mcp'
@ -77,7 +77,7 @@ export default class MCPExtensionWeb extends MCPExtension {
// Create transport with OAuth provider (handles token refresh automatically)
const transport = new StreamableHTTPClientTransport(
new URL(`${JAN_API_BASE}${this.mcpEndpoint}`),
new URL(`${MENLO_PLATFORM_BASE_URL}${this.mcpEndpoint}`),
{
authProvider: this.oauthProvider
// No sessionId needed - server will generate one automatically

View File

@ -6,13 +6,13 @@
import { AuthTokens } from './types'
import { AUTH_ENDPOINTS } from './const'
declare const JAN_API_BASE: string
declare const MENLO_PLATFORM_BASE_URL: string
/**
* Logout user on server
*/
export async function logoutUser(): Promise<void> {
const response = await fetch(`${JAN_API_BASE}${AUTH_ENDPOINTS.LOGOUT}`, {
const response = await fetch(`${MENLO_PLATFORM_BASE_URL}${AUTH_ENDPOINTS.LOGOUT}`, {
method: 'GET',
credentials: 'include',
headers: {
@ -29,7 +29,7 @@ export async function logoutUser(): Promise<void> {
* Guest login
*/
export async function guestLogin(): Promise<AuthTokens> {
const response = await fetch(`${JAN_API_BASE}${AUTH_ENDPOINTS.GUEST_LOGIN}`, {
const response = await fetch(`${MENLO_PLATFORM_BASE_URL}${AUTH_ENDPOINTS.GUEST_LOGIN}`, {
method: 'POST',
credentials: 'include',
headers: {
@ -51,7 +51,7 @@ export async function guestLogin(): Promise<AuthTokens> {
*/
export async function refreshToken(): Promise<AuthTokens> {
const response = await fetch(
`${JAN_API_BASE}${AUTH_ENDPOINTS.REFRESH_TOKEN}`,
`${MENLO_PLATFORM_BASE_URL}${AUTH_ENDPOINTS.REFRESH_TOKEN}`,
{
method: 'GET',
credentials: 'include',

View File

@ -5,10 +5,10 @@
import { AuthTokens, LoginUrlResponse } from './types'
declare const JAN_API_BASE: string
declare const MENLO_PLATFORM_BASE_URL: string
export async function getLoginUrl(endpoint: string): Promise<LoginUrlResponse> {
const response: Response = await fetch(`${JAN_API_BASE}${endpoint}`, {
const response: Response = await fetch(`${MENLO_PLATFORM_BASE_URL}${endpoint}`, {
method: 'GET',
credentials: 'include',
headers: {
@ -30,7 +30,7 @@ export async function handleOAuthCallback(
code: string,
state?: string
): Promise<AuthTokens> {
const response: Response = await fetch(`${JAN_API_BASE}${endpoint}`, {
const response: Response = await fetch(`${MENLO_PLATFORM_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@ -3,7 +3,7 @@
* Handles authentication flows for any OAuth provider
*/
declare const JAN_API_BASE: string
declare const MENLO_PLATFORM_BASE_URL: string
import { User, AuthState, AuthBroadcastMessage, AuthTokens } from './types'
import {
@ -429,7 +429,7 @@ export class JanAuthService {
private async fetchUserProfile(): Promise<User | null> {
try {
return await this.makeAuthenticatedRequest<User>(
`${JAN_API_BASE}${AUTH_ENDPOINTS.ME}`
`${MENLO_PLATFORM_BASE_URL}${AUTH_ENDPOINTS.ME}`
)
} catch (error) {
console.error('Failed to fetch user profile:', error)

View File

@ -1,5 +1,5 @@
export {}
declare global {
declare const JAN_API_BASE: string
declare const MENLO_PLATFORM_BASE_URL: string
}

View File

@ -14,6 +14,6 @@ export default defineConfig({
emptyOutDir: false // Don't clean the output directory
},
define: {
JAN_API_BASE: JSON.stringify(process.env.JAN_API_BASE || 'https://api-dev.jan.ai/v1'),
MENLO_PLATFORM_BASE_URL: JSON.stringify(process.env.MENLO_PLATFORM_BASE_URL || 'https://api-dev.menlo.ai/v1'),
}
})