Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions web-app/src/lib/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
}
import { ulid } from 'ulidx'
import { MCPTool } from '@/types/completion'
import { CompletionMessagesBuilder } from './messages'
import { CompletionMessagesBuilder, ToolResult } from './messages'
import { ChatCompletionMessageToolCall } from 'openai/resources'
import { ExtensionManager } from './extension'
import { useAppState } from '@/hooks/useAppState'
Expand Down Expand Up @@ -66,11 +66,11 @@
const docMetadata = documents
.filter((doc) => doc.id) // Only include processed documents
.map((doc) => ({
id: doc.id!,
name: doc.name,
type: doc.fileType,
size: typeof doc.size === 'number' ? doc.size : undefined,
chunkCount: typeof doc.chunkCount === 'number' ? doc.chunkCount : undefined,

Check warning on line 73 in web-app/src/lib/completion.ts

View workflow job for this annotation

GitHub Actions / coverage-check

69-73 lines are not covered with tests
}))

const textWithFiles =
Expand All @@ -88,15 +88,15 @@

// Add image attachments to content array
images.forEach((img) => {
if (img.base64 && img.mimeType) {
contentParts.push({
type: ContentType.Image,
image_url: {
url: `data:${img.mimeType};base64,${img.base64}`,
detail: 'auto',
},
} as any)
}

Check warning on line 99 in web-app/src/lib/completion.ts

View workflow job for this annotation

GitHub Actions / coverage-check

91-99 lines are not covered with tests
})

return {
Expand Down Expand Up @@ -168,45 +168,45 @@
* @returns
*/
export const sendCompletion = async (
thread: Thread,
provider: ModelProvider,
messages: ChatCompletionMessageParam[],
abortController: AbortController,
tools: MCPTool[] = [],
stream: boolean = true,
params: Record<string, object> = {}
): Promise<ChatCompletionResponse | undefined> => {
if (!thread?.model?.id || !provider) return undefined

Check warning on line 179 in web-app/src/lib/completion.ts

View workflow job for this annotation

GitHub Actions / coverage-check

171-179 lines are not covered with tests

let providerName = provider.provider as unknown as keyof typeof models

Check warning on line 181 in web-app/src/lib/completion.ts

View workflow job for this annotation

GitHub Actions / coverage-check

181 line is not covered with tests

if (!Object.keys(models).some((key) => key === providerName))
providerName = 'openai-compatible'

Check warning on line 184 in web-app/src/lib/completion.ts

View workflow job for this annotation

GitHub Actions / coverage-check

183-184 lines are not covered with tests

const tokenJS = new TokenJS({
apiKey:
provider.api_key ?? (await getServiceHub().core().getAppToken()) ?? '',

Check warning on line 188 in web-app/src/lib/completion.ts

View workflow job for this annotation

GitHub Actions / coverage-check

186-188 lines are not covered with tests
// TODO: Retrieve from extension settings
baseURL: provider.base_url,

Check warning on line 190 in web-app/src/lib/completion.ts

View workflow job for this annotation

GitHub Actions / coverage-check

190 line is not covered with tests
// Use Tauri's fetch to avoid CORS issues only for openai-compatible provider
fetch: IS_DEV ? fetch : getServiceHub().providers().fetch(),

Check warning on line 192 in web-app/src/lib/completion.ts

View workflow job for this annotation

GitHub Actions / coverage-check

192 line is not covered with tests
// OpenRouter identification headers for Jan
// ref: https://openrouter.ai/docs/api-reference/overview#headers
...(provider.provider === 'openrouter' && {
defaultHeaders: {
'HTTP-Referer': 'https://jan.ai',
'X-Title': 'Jan',
},
}),

Check warning on line 200 in web-app/src/lib/completion.ts

View workflow job for this annotation

GitHub Actions / coverage-check

195-200 lines are not covered with tests
// Add Origin header for local providers to avoid CORS issues
...((provider.base_url?.includes('localhost:') ||
provider.base_url?.includes('127.0.0.1:')) && {
fetch: getServiceHub().providers().fetch(),
defaultHeaders: {
Origin: 'tauri://localhost',
},
}),
} as ExtendedConfigOptions)

Check warning on line 209 in web-app/src/lib/completion.ts

View workflow job for this annotation

GitHub Actions / coverage-check

202-209 lines are not covered with tests

if (
thread.model.id &&
Expand Down Expand Up @@ -543,7 +543,7 @@
},
],
}
builder.addToolMessage(result.content[0]?.text ?? '', toolCall.id)
builder.addToolMessage(result as ToolResult, toolCall.id)
// update message metadata
}
return message
Expand Down
94 changes: 88 additions & 6 deletions web-app/src/lib/messages.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ChatCompletionMessageParam } from 'token.js'
import { ChatCompletionMessageToolCall } from 'openai/resources'
import { ThreadMessage, ContentType } from '@janhq/core'
Expand All @@ -6,6 +7,48 @@ import { removeReasoningContent } from '@/utils/reasoning'

type ThreadContent = NonNullable<ThreadMessage['content']>[number]

// Define a temporary type for the expected tool result shape (ToolResult as before)
export type ToolResult = {
content: Array<{
type?: string
text?: string
data?: string
image_url?: { url: string; detail?: string }
}>
error?: string
}

// Helper function to convert the tool's output part into an API content part
const convertToolPartToApiContentPart = (part: ToolResult['content'][0]) => {
if (part.text) {
return { type: 'text', text: part.text }
}

// Handle base64 image data
if (part.data) {
// Assume default image type, though a proper tool should return the mime type
const mimeType =
part.type === 'image' ? 'image/png' : part.type || 'image/png'
const dataUrl = `data:${mimeType};base64,${part.data}`

return {
type: 'image_url',
image_url: {
url: dataUrl,
detail: 'auto',
},
}
}

// Handle pre-formatted image URL
if (part.image_url) {
return { type: 'image_url', image_url: part.image_url }
}

// Fallback to text stringification for structured but unhandled data
return { type: 'text', text: JSON.stringify(part) }
}

/**
* @fileoverview Helper functions for creating chat completion request.
* These functions are used to create chat completion request objects
Expand All @@ -26,7 +69,11 @@ export class CompletionMessagesBuilder {
.map<ChatCompletionMessageParam>((msg) => {
const param = this.toCompletionParamFromThread(msg)
// In constructor context, normalize empty user text to a placeholder
if (param.role === 'user' && typeof param.content === 'string' && param.content === '') {
if (
param.role === 'user' &&
typeof param.content === 'string' &&
param.content === ''
) {
return { ...param, content: '.' }
}
return param
Expand All @@ -35,7 +82,9 @@ export class CompletionMessagesBuilder {
}

// Normalize a ThreadMessage into a ChatCompletionMessageParam for Token.js
private toCompletionParamFromThread(msg: ThreadMessage): ChatCompletionMessageParam {
private toCompletionParamFromThread(
msg: ThreadMessage
): ChatCompletionMessageParam {
if (msg.role === 'assistant') {
return {
role: 'assistant',
Expand All @@ -60,7 +109,10 @@ export class CompletionMessagesBuilder {
if (part.type === ContentType.Image) {
return {
type: 'image_url' as const,
image_url: { url: part.image_url?.url || '', detail: part.image_url?.detail || 'auto' },
image_url: {
url: part.image_url?.url || '',
detail: part.image_url?.detail || 'auto',
},
}
}
// Fallback for unknown content types
Expand Down Expand Up @@ -110,13 +162,43 @@ export class CompletionMessagesBuilder {

/**
* Add a tool message to the messages array.
* @param content - The content of the tool message.
* @param content - The content of the tool message (string or ToolResult object).
* @param toolCallId - The ID of the tool call associated with the message.
*/
addToolMessage(content: string, toolCallId: string) {
addToolMessage(result: string | ToolResult, toolCallId: string) {
let content: string | any[] = ''

// Handle simple string case
if (typeof result === 'string') {
content = result
} else {
// Check for multimodal content (more than just a simple text string)
const hasMultimodalContent = result.content?.some(
(p) => p.data || p.image_url
)

if (hasMultimodalContent) {
// Build the structured content array
content = result.content.map(convertToolPartToApiContentPart)
} else if (result.content?.[0]?.text) {
// Standard text case
content = result.content[0].text
} else if (result.error) {
// Error case
content = `Tool execution failed: ${result.error}`
} else {
// Fallback: serialize the whole result structure if content is unexpected
try {
content = JSON.stringify(result)
} catch {
content = 'Tool call completed, unexpected output format.'
}
}
}
this.messages.push({
role: 'tool',
content: content,
// for role 'tool', need to use 'as ChatCompletionMessageParam'
content: content as any,
tool_call_id: toolCallId,
})
}
Expand Down
Loading