mirror of
https://github.com/ONLYOFFICE/desktop-sdk.git
synced 2026-02-10 18:15:05 +08:00
Merge branch 'release/v9.3.0' into feature/ai-agent-langs_9.3
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -17,6 +17,7 @@
|
||||
"@codemirror/view": "^6.38.2",
|
||||
"@google/genai": "^1.30.0",
|
||||
"@lmstudio/sdk": "^1.5.0",
|
||||
"@mistralai/mistralai": "^1.11.0",
|
||||
"@openrouter/ai-sdk-provider": "^1.2.3",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
@ -1420,6 +1421,15 @@
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mistralai/mistralai": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.11.0.tgz",
|
||||
"integrity": "sha512-6/BVj2mcaggYbpMzNSxtqtM2Tv/Jb5845XFd2CMYFO+O5VBkX70iLjtkBBTI4JFhh1l9vTCIMYXBVOjLoBVHGQ==",
|
||||
"dependencies": {
|
||||
"zod": "^3.20.0",
|
||||
"zod-to-json-schema": "^3.24.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@openrouter/ai-sdk-provider": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-1.5.4.tgz",
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
"@codemirror/view": "^6.38.2",
|
||||
"@google/genai": "^1.30.0",
|
||||
"@lmstudio/sdk": "^1.5.0",
|
||||
"@mistralai/mistralai": "^1.11.0",
|
||||
"@openrouter/ai-sdk-provider": "^1.2.3",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
||||
@ -22,7 +22,8 @@ export type ProviderType =
|
||||
| "genai"
|
||||
| "deepseek"
|
||||
| "xai"
|
||||
| "lm-studio";
|
||||
| "lm-studio"
|
||||
| "mistral";
|
||||
|
||||
export type Model = {
|
||||
id: string;
|
||||
|
||||
@ -24,8 +24,6 @@ const AvailableTools = () => {
|
||||
|
||||
React.useEffect(() => {
|
||||
setAllToolsCount(webSearchEnabled ? tools.length - 2 : tools.length);
|
||||
|
||||
console.log(tools.length);
|
||||
}, [tools.length, webSearchEnabled]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||
import type {
|
||||
CompletionEvent,
|
||||
DeltaMessage,
|
||||
} from "@mistralai/mistralai/models/components";
|
||||
|
||||
export const handleToolCall = (
|
||||
delta: DeltaMessage,
|
||||
responseMessage: ThreadMessageLike
|
||||
): ThreadMessageLike => {
|
||||
if (!delta.toolCalls?.length) return responseMessage;
|
||||
|
||||
const toolCall = delta.toolCalls[0];
|
||||
const toolCallId = toolCall.id || "";
|
||||
const toolCallArgs = toolCall.function?.arguments || "{}";
|
||||
const toolCallContent: ThreadMessageLike["content"] = [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId,
|
||||
toolName: toolCall.function?.name || "",
|
||||
args:
|
||||
typeof toolCallArgs === "string"
|
||||
? JSON.parse(toolCallArgs)
|
||||
: toolCallArgs,
|
||||
argsText:
|
||||
typeof toolCallArgs === "string"
|
||||
? toolCallArgs
|
||||
: JSON.stringify(toolCallArgs),
|
||||
result: "",
|
||||
parentId: toolCallId,
|
||||
},
|
||||
];
|
||||
|
||||
if (typeof responseMessage.content !== "string") {
|
||||
return {
|
||||
...responseMessage,
|
||||
content: [...responseMessage.content, ...toolCallContent],
|
||||
};
|
||||
}
|
||||
|
||||
return responseMessage;
|
||||
};
|
||||
|
||||
export const handleTextContent = (
|
||||
delta: DeltaMessage,
|
||||
responseMessage: ThreadMessageLike
|
||||
): ThreadMessageLike => {
|
||||
if (
|
||||
typeof delta?.content !== "string" ||
|
||||
typeof responseMessage.content === "string"
|
||||
) {
|
||||
return responseMessage;
|
||||
}
|
||||
|
||||
const content = delta.content;
|
||||
const lastContent = responseMessage.content.at(-1);
|
||||
|
||||
if (lastContent && lastContent.type === "text") {
|
||||
return {
|
||||
...responseMessage,
|
||||
content: [
|
||||
...responseMessage.content.slice(0, -1),
|
||||
{ ...lastContent, text: lastContent.text + content },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...responseMessage,
|
||||
content: [...responseMessage.content, { type: "text", text: content }],
|
||||
};
|
||||
};
|
||||
|
||||
export const getChoiceFromEvent = (
|
||||
event: CompletionEvent
|
||||
): CompletionEvent["data"]["choices"][number] | undefined => {
|
||||
return event.data?.choices?.[0];
|
||||
};
|
||||
@ -0,0 +1,50 @@
|
||||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||
import { Mistral } from "@mistralai/mistralai";
|
||||
import cloneDeep from "lodash.clonedeep";
|
||||
import type { ToolCallPart } from "./types";
|
||||
|
||||
export const createClient = (apiKey?: string, baseUrl?: string): Mistral => {
|
||||
return new Mistral({
|
||||
apiKey: apiKey ?? "",
|
||||
serverURL: baseUrl,
|
||||
});
|
||||
};
|
||||
|
||||
export const createEmptyResponse = (): ThreadMessageLike => ({
|
||||
role: "assistant",
|
||||
content: [],
|
||||
});
|
||||
|
||||
export const createErrorResponse = (error: unknown): ThreadMessageLike => ({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const createResponseShell = (
|
||||
afterToolCall?: boolean,
|
||||
existingMessage?: ThreadMessageLike
|
||||
): ThreadMessageLike => {
|
||||
if (afterToolCall && existingMessage) {
|
||||
return cloneDeep(existingMessage);
|
||||
}
|
||||
return createEmptyResponse();
|
||||
};
|
||||
|
||||
export const getLastToolCall = (
|
||||
message: ThreadMessageLike
|
||||
): ToolCallPart | undefined => {
|
||||
if (typeof message.content === "string") return undefined;
|
||||
|
||||
for (let i = message.content.length - 1; i >= 0; i -= 1) {
|
||||
const part = message.content[i];
|
||||
if (part.type === "tool-call") {
|
||||
return part as ToolCallPart;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@ -0,0 +1,252 @@
|
||||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||
import type { Mistral } from "@mistralai/mistralai";
|
||||
import type { Messages, Tool } from "@mistralai/mistralai/models/components";
|
||||
import type { Model, TMCPItem, TProvider } from "@/lib/types";
|
||||
import { AbstractBaseProvider, type TData, type TErrorData } from "../base";
|
||||
import { extractErrorMessage, getErrorStatus, ProviderErrors } from "../errors";
|
||||
import { CREATE_TITLE_SYSTEM_PROMPT } from "../prompts";
|
||||
import {
|
||||
getChoiceFromEvent,
|
||||
handleTextContent,
|
||||
handleToolCall,
|
||||
} from "./handlers";
|
||||
import {
|
||||
createClient,
|
||||
createErrorResponse,
|
||||
createResponseShell,
|
||||
getLastToolCall,
|
||||
} from "./helpers";
|
||||
import { mistralInfo } from "./info";
|
||||
import type { StreamResult } from "./types";
|
||||
import {
|
||||
convertMessagesToModelFormat,
|
||||
convertToolsToModelFormat,
|
||||
} from "./utils";
|
||||
|
||||
class MistralProvider extends AbstractBaseProvider<Tool, Messages, Mistral> {
|
||||
setProvider = (provider: TProvider): void => {
|
||||
this.provider = provider;
|
||||
this.client = createClient(provider.key, provider.baseUrl);
|
||||
|
||||
if (provider.key) this.setApiKey(provider.key);
|
||||
if (provider.baseUrl) this.setUrl(provider.baseUrl);
|
||||
};
|
||||
|
||||
setPrevMessages = (prevMessages: ThreadMessageLike[]): void => {
|
||||
this.prevMessages = convertMessagesToModelFormat(prevMessages);
|
||||
};
|
||||
|
||||
setTools = (tools: TMCPItem[]): void => {
|
||||
this.tools = convertToolsToModelFormat(tools);
|
||||
};
|
||||
|
||||
private pushToHistory = (message: ThreadMessageLike): void => {
|
||||
const converted = convertMessagesToModelFormat([message]);
|
||||
this.prevMessages.push(...converted);
|
||||
};
|
||||
|
||||
private pushToHistorySliced = (
|
||||
responseMessage: ThreadMessageLike,
|
||||
originalMessage: ThreadMessageLike
|
||||
): void => {
|
||||
if (typeof responseMessage.content === "string") return;
|
||||
if (typeof originalMessage.content === "string") return;
|
||||
|
||||
const newContent = responseMessage.content.slice(
|
||||
originalMessage.content.length
|
||||
);
|
||||
this.pushToHistory({ ...responseMessage, content: newContent });
|
||||
};
|
||||
|
||||
async createChatName(message: string): Promise<string> {
|
||||
if (!this.client) return "";
|
||||
|
||||
try {
|
||||
const response = await this.client.chat.complete({
|
||||
model: this.modelKey,
|
||||
messages: [
|
||||
{ role: "system", content: CREATE_TITLE_SYSTEM_PROMPT },
|
||||
{ role: "user", content: message },
|
||||
],
|
||||
});
|
||||
|
||||
const content = response.choices?.[0]?.message?.content;
|
||||
if (typeof content === "string") {
|
||||
return content.substring(0, 25);
|
||||
}
|
||||
|
||||
return message.substring(0, 25);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private getModelKey = (withThinking?: boolean): string => {
|
||||
if (!withThinking) return this.modelKey;
|
||||
|
||||
const reasoningModel = mistralInfo.reasoningModels.find(
|
||||
([level, _modelKeyy]) => this.modelKey.includes(level)
|
||||
);
|
||||
|
||||
return reasoningModel ? reasoningModel[1] : this.modelKey;
|
||||
};
|
||||
|
||||
async *sendMessage(
|
||||
messages: ThreadMessageLike[],
|
||||
afterToolCall?: boolean,
|
||||
previousMessage?: ThreadMessageLike,
|
||||
withThinking?: boolean
|
||||
): AsyncGenerator<StreamResult> {
|
||||
if (!this.client) return;
|
||||
|
||||
try {
|
||||
const convertedMessages = convertMessagesToModelFormat(messages);
|
||||
this.prevMessages.push(...convertedMessages);
|
||||
|
||||
const model = this.getModelKey(withThinking);
|
||||
|
||||
const allMessages: Messages[] = [
|
||||
{ role: "system", content: this.systemPrompt },
|
||||
...this.prevMessages,
|
||||
];
|
||||
|
||||
const stream = await this.client.chat.stream({
|
||||
model,
|
||||
messages: allMessages,
|
||||
tools: this.tools.length > 0 ? this.tools : undefined,
|
||||
});
|
||||
|
||||
let responseMessage = createResponseShell(afterToolCall, previousMessage);
|
||||
|
||||
for await (const event of stream) {
|
||||
// Handle stop flag
|
||||
if (this.stopFlag) {
|
||||
this.stopFlag = false;
|
||||
this.pushToHistory(responseMessage);
|
||||
yield { isEnd: true, responseMessage };
|
||||
return;
|
||||
}
|
||||
|
||||
const choice = getChoiceFromEvent(event);
|
||||
if (!choice) continue;
|
||||
|
||||
const delta = choice.delta;
|
||||
|
||||
// Handle tool call
|
||||
responseMessage = handleToolCall(delta, responseMessage);
|
||||
|
||||
// Handle text content
|
||||
responseMessage = handleTextContent(delta, responseMessage);
|
||||
|
||||
// Handle finish
|
||||
if (choice.finishReason) {
|
||||
if (afterToolCall && previousMessage) {
|
||||
this.pushToHistorySliced(responseMessage, previousMessage);
|
||||
} else {
|
||||
this.pushToHistory(responseMessage);
|
||||
}
|
||||
yield { isEnd: true, responseMessage };
|
||||
return;
|
||||
}
|
||||
|
||||
yield responseMessage;
|
||||
}
|
||||
} catch (_error) {
|
||||
console.error("Mistral sendMessage error:", _error);
|
||||
yield { isEnd: true, responseMessage: createErrorResponse(_error) };
|
||||
}
|
||||
}
|
||||
|
||||
async *sendMessageAfterToolCall(
|
||||
message: ThreadMessageLike,
|
||||
withThinking?: boolean
|
||||
): AsyncGenerator<StreamResult> {
|
||||
if (typeof message.content === "string") return message;
|
||||
|
||||
const lastToolCall = getLastToolCall(message);
|
||||
if (!lastToolCall) return message;
|
||||
|
||||
const toolResult = {
|
||||
role: "tool" as const,
|
||||
content:
|
||||
typeof lastToolCall.result === "string"
|
||||
? lastToolCall.result
|
||||
: JSON.stringify(lastToolCall.result),
|
||||
toolCallId: lastToolCall.toolCallId ?? "",
|
||||
name: lastToolCall.toolName,
|
||||
};
|
||||
|
||||
this.prevMessages.push(toolResult as Messages);
|
||||
yield* this.sendMessage([], true, message, withThinking);
|
||||
}
|
||||
|
||||
getBaseUrl = (): string => mistralInfo.baseUrl;
|
||||
|
||||
getName = (): string => mistralInfo.name;
|
||||
|
||||
checkProvider = async (data: TData): Promise<boolean | TErrorData> => {
|
||||
const client = createClient(data.apiKey, data.url);
|
||||
|
||||
try {
|
||||
await client.models.list();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const status = getErrorStatus(error);
|
||||
|
||||
if (
|
||||
status === 0 ||
|
||||
(error && typeof error === "object" && "cause" in error)
|
||||
) {
|
||||
return ProviderErrors.invalidUrl();
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
return ProviderErrors.invalidKey(extractErrorMessage(error));
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
return ProviderErrors.invalidUrl();
|
||||
}
|
||||
|
||||
return data.apiKey
|
||||
? ProviderErrors.invalidKey()
|
||||
: ProviderErrors.emptyKey();
|
||||
}
|
||||
};
|
||||
|
||||
getProviderModels = async (data: TData): Promise<Model[]> => {
|
||||
const client = createClient(data.apiKey, data.url);
|
||||
|
||||
try {
|
||||
const response = await client.models.list();
|
||||
const models = response.data ?? [];
|
||||
|
||||
const result: Model[] = [];
|
||||
|
||||
for (const model of models) {
|
||||
const matchesFilter =
|
||||
mistralInfo.modelFilters.length === 0 ||
|
||||
mistralInfo.modelFilters.some((f) => model.id?.includes(f));
|
||||
|
||||
if (!matchesFilter) continue;
|
||||
|
||||
const displayName =
|
||||
mistralInfo.modelNames[model.id ?? ""] ?? model.id ?? "Unknown";
|
||||
|
||||
result.push({
|
||||
id: model.id ?? "",
|
||||
name: displayName,
|
||||
provider: "mistral" as const,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const mistralProvider = new MistralProvider();
|
||||
|
||||
export { MistralProvider, mistralProvider };
|
||||
@ -0,0 +1,19 @@
|
||||
export const mistralInfo = {
|
||||
name: "Mistral",
|
||||
baseUrl: "https://api.mistral.ai",
|
||||
modelFilters: [
|
||||
"mistral-large-latest",
|
||||
"mistral-medium-latest",
|
||||
"mistral-small-latest",
|
||||
] as string[],
|
||||
modelNames: {
|
||||
"mistral-large-latest": "Mistral Large",
|
||||
"mistral-medium-latest": "Mistral Medium",
|
||||
"mistral-small-latest": "Mistral Small",
|
||||
} as Record<string, string>,
|
||||
reasoningModels: [
|
||||
["small", "magistral-small-latest"],
|
||||
["medium", "magistral-medium-latest"],
|
||||
["large", "magistral-medium-latest"],
|
||||
] as [string, string][],
|
||||
};
|
||||
@ -0,0 +1,399 @@
|
||||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||
import type {
|
||||
CompletionEvent,
|
||||
DeltaMessage,
|
||||
} from "@mistralai/mistralai/models/components";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getChoiceFromEvent,
|
||||
handleTextContent,
|
||||
handleToolCall,
|
||||
} from "../handlers";
|
||||
|
||||
// Helper to create partial event objects for testing
|
||||
const createEvent = <T>(partial: Partial<T>) => partial as T;
|
||||
|
||||
describe("mistral handlers", () => {
|
||||
// ==========================================================================
|
||||
// handleToolCall
|
||||
// ==========================================================================
|
||||
|
||||
describe("handleToolCall", () => {
|
||||
it("should return unchanged message when no tool calls", () => {
|
||||
const delta = createEvent<DeltaMessage>({
|
||||
toolCalls: undefined,
|
||||
});
|
||||
|
||||
const prevMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
};
|
||||
|
||||
const result = handleToolCall(delta, prevMessage);
|
||||
|
||||
expect(result).toEqual(prevMessage);
|
||||
});
|
||||
|
||||
it("should return unchanged message when tool calls array is empty", () => {
|
||||
const delta = createEvent<DeltaMessage>({
|
||||
toolCalls: [],
|
||||
});
|
||||
|
||||
const prevMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
};
|
||||
|
||||
const result = handleToolCall(delta, prevMessage);
|
||||
|
||||
expect(result).toEqual(prevMessage);
|
||||
});
|
||||
|
||||
it("should add tool call to message content", () => {
|
||||
const delta = createEvent<DeltaMessage>({
|
||||
toolCalls: [
|
||||
{
|
||||
id: "tool_123",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"city": "NYC"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const prevMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
};
|
||||
|
||||
const result = handleToolCall(delta, prevMessage);
|
||||
|
||||
expect(result.content).toHaveLength(1);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tool_123",
|
||||
toolName: "get_weather",
|
||||
args: { city: "NYC" },
|
||||
argsText: '{"city": "NYC"}',
|
||||
result: "",
|
||||
parentId: "tool_123",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle tool call with empty arguments", () => {
|
||||
const delta = createEvent<DeltaMessage>({
|
||||
toolCalls: [
|
||||
{
|
||||
id: "tool_456",
|
||||
function: {
|
||||
name: "no_args_tool",
|
||||
arguments: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const prevMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
};
|
||||
|
||||
const result = handleToolCall(delta, prevMessage);
|
||||
|
||||
expect(result.content).toHaveLength(1);
|
||||
expect(
|
||||
(result.content as unknown as Array<{ args: unknown }>)[0].args
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("should handle tool call with undefined id", () => {
|
||||
const delta = createEvent<DeltaMessage>({
|
||||
toolCalls: [
|
||||
{
|
||||
id: undefined,
|
||||
function: {
|
||||
name: "test_tool",
|
||||
arguments: "{}",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const prevMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
};
|
||||
|
||||
const result = handleToolCall(delta, prevMessage);
|
||||
|
||||
expect(
|
||||
(result.content as unknown as Array<{ toolCallId: string }>)[0]
|
||||
.toolCallId
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("should handle tool call with undefined function name", () => {
|
||||
const delta = createEvent<DeltaMessage>({
|
||||
toolCalls: [
|
||||
{
|
||||
id: "tool_789",
|
||||
function: {
|
||||
name: undefined as unknown as string,
|
||||
arguments: "{}",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const prevMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
};
|
||||
|
||||
const result = handleToolCall(delta, prevMessage);
|
||||
|
||||
expect(
|
||||
(result.content as unknown as Array<{ toolName: string }>)[0].toolName
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("should append tool call to existing content", () => {
|
||||
const delta = createEvent<DeltaMessage>({
|
||||
toolCalls: [
|
||||
{
|
||||
id: "tool_new",
|
||||
function: {
|
||||
name: "new_tool",
|
||||
arguments: "{}",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const prevMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Let me check that" }],
|
||||
};
|
||||
|
||||
const result = handleToolCall(delta, prevMessage);
|
||||
|
||||
expect(result.content).toHaveLength(2);
|
||||
expect(
|
||||
(result.content as unknown as Array<{ type: string }>)[0].type
|
||||
).toBe("text");
|
||||
expect(
|
||||
(result.content as unknown as Array<{ type: string }>)[1].type
|
||||
).toBe("tool-call");
|
||||
});
|
||||
|
||||
it("should return unchanged message when content is string", () => {
|
||||
const delta = createEvent<DeltaMessage>({
|
||||
toolCalls: [
|
||||
{
|
||||
id: "tool_123",
|
||||
function: {
|
||||
name: "test_tool",
|
||||
arguments: "{}",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const prevMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: "string content",
|
||||
};
|
||||
|
||||
const result = handleToolCall(delta, prevMessage);
|
||||
|
||||
expect(result).toEqual(prevMessage);
|
||||
});
|
||||
|
||||
it("should handle object arguments", () => {
|
||||
const delta = createEvent<DeltaMessage>({
|
||||
toolCalls: [
|
||||
{
|
||||
id: "tool_obj",
|
||||
function: {
|
||||
name: "obj_args_tool",
|
||||
arguments: { key: "value" } as unknown as string,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const prevMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
};
|
||||
|
||||
const result = handleToolCall(delta, prevMessage);
|
||||
|
||||
expect(
|
||||
(result.content as unknown as Array<{ args: unknown }>)[0].args
|
||||
).toEqual({
|
||||
key: "value",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// handleTextContent
|
||||
// ==========================================================================
|
||||
|
||||
describe("handleTextContent", () => {
|
||||
it("should return unchanged message when delta content is not string", () => {
|
||||
const delta = createEvent<DeltaMessage>({
|
||||
content: undefined,
|
||||
});
|
||||
|
||||
const prevMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
};
|
||||
|
||||
const result = handleTextContent(delta, prevMessage);
|
||||
|
||||
expect(result).toEqual(prevMessage);
|
||||
});
|
||||
|
||||
it("should return unchanged message when response content is string", () => {
|
||||
const delta = createEvent<DeltaMessage>({
|
||||
content: "new text",
|
||||
});
|
||||
|
||||
const prevMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: "string content",
|
||||
};
|
||||
|
||||
const result = handleTextContent(delta, prevMessage);
|
||||
|
||||
expect(result).toEqual(prevMessage);
|
||||
});
|
||||
|
||||
it("should add new text part when content is empty", () => {
|
||||
const delta = createEvent<DeltaMessage>({
|
||||
content: "Hello",
|
||||
});
|
||||
|
||||
const prevMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
};
|
||||
|
||||
const result = handleTextContent(delta, prevMessage);
|
||||
|
||||
expect(result.content).toEqual([{ type: "text", text: "Hello" }]);
|
||||
});
|
||||
|
||||
it("should append to existing text part", () => {
|
||||
const delta = createEvent<DeltaMessage>({
|
||||
content: " world",
|
||||
});
|
||||
|
||||
const prevMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
};
|
||||
|
||||
const result = handleTextContent(delta, prevMessage);
|
||||
|
||||
expect(result.content).toEqual([{ type: "text", text: "Hello world" }]);
|
||||
});
|
||||
|
||||
it("should add new text part when last content is not text", () => {
|
||||
const delta = createEvent<DeltaMessage>({
|
||||
content: "New text",
|
||||
});
|
||||
|
||||
const prevMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tool_123",
|
||||
toolName: "test",
|
||||
args: {},
|
||||
argsText: "{}",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = handleTextContent(delta, prevMessage);
|
||||
|
||||
expect(result.content).toHaveLength(2);
|
||||
expect(
|
||||
(result.content as unknown as Array<{ type: string }>)[1].type
|
||||
).toBe("text");
|
||||
expect(
|
||||
(result.content as unknown as Array<{ text?: string }>)[1].text
|
||||
).toBe("New text");
|
||||
});
|
||||
|
||||
it("should not mutate original message", () => {
|
||||
const delta = createEvent<DeltaMessage>({
|
||||
content: " world",
|
||||
});
|
||||
|
||||
const prevMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
};
|
||||
|
||||
handleTextContent(delta, prevMessage);
|
||||
|
||||
expect(prevMessage.content).toEqual([{ type: "text", text: "Hello" }]);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// getChoiceFromEvent
|
||||
// ==========================================================================
|
||||
|
||||
describe("getChoiceFromEvent", () => {
|
||||
it("should return first choice from event", () => {
|
||||
const event = createEvent<CompletionEvent>({
|
||||
data: {
|
||||
id: "test-id",
|
||||
model: "mistral-small",
|
||||
choices: [
|
||||
{ index: 0, delta: { content: "Hello" }, finishReason: null },
|
||||
{ index: 1, delta: { content: "World" }, finishReason: null },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = getChoiceFromEvent(event);
|
||||
|
||||
expect(result?.delta.content).toBe("Hello");
|
||||
});
|
||||
|
||||
it("should return undefined when no choices", () => {
|
||||
const event = createEvent<CompletionEvent>({
|
||||
data: {
|
||||
id: "test-id",
|
||||
model: "mistral-small",
|
||||
choices: [],
|
||||
},
|
||||
});
|
||||
|
||||
const result = getChoiceFromEvent(event);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined when data is undefined", () => {
|
||||
const event = createEvent<CompletionEvent>({
|
||||
data: undefined,
|
||||
});
|
||||
|
||||
const result = getChoiceFromEvent(event);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,277 @@
|
||||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createClient,
|
||||
createEmptyResponse,
|
||||
createErrorResponse,
|
||||
createResponseShell,
|
||||
getLastToolCall,
|
||||
} from "../helpers";
|
||||
|
||||
// Mock the Mistral SDK
|
||||
vi.mock("@mistralai/mistralai", () => {
|
||||
return {
|
||||
Mistral: class MockMistral {
|
||||
apiKey: string;
|
||||
serverURL: string | undefined;
|
||||
constructor(config: { apiKey: string; serverURL?: string }) {
|
||||
this.apiKey = config.apiKey;
|
||||
this.serverURL = config.serverURL;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("mistral helpers", () => {
|
||||
// ==========================================================================
|
||||
// createClient
|
||||
// ==========================================================================
|
||||
|
||||
describe("createClient", () => {
|
||||
it("should create client with apiKey and baseUrl", () => {
|
||||
const client = createClient("test-api-key", "https://api.mistral.ai");
|
||||
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create client with empty apiKey when not provided", () => {
|
||||
const client = createClient(undefined, "https://api.mistral.ai");
|
||||
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create client with undefined serverURL", () => {
|
||||
const client = createClient("test-key", undefined);
|
||||
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create client with no arguments", () => {
|
||||
const client = createClient();
|
||||
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// createEmptyResponse
|
||||
// ==========================================================================
|
||||
|
||||
describe("createEmptyResponse", () => {
|
||||
it("should create empty assistant response", () => {
|
||||
const result = createEmptyResponse();
|
||||
|
||||
expect(result).toEqual({
|
||||
role: "assistant",
|
||||
content: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// createErrorResponse
|
||||
// ==========================================================================
|
||||
|
||||
describe("createErrorResponse", () => {
|
||||
it("should create error response from Error instance", () => {
|
||||
const error = new Error("Something went wrong");
|
||||
const result = createErrorResponse(error);
|
||||
|
||||
expect(result).toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: Something went wrong",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should create error response from string", () => {
|
||||
const result = createErrorResponse("String error");
|
||||
|
||||
expect(result).toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: String error",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should create error response from object", () => {
|
||||
const result = createErrorResponse({
|
||||
code: 500,
|
||||
message: "Server error",
|
||||
});
|
||||
|
||||
expect(result.content).toHaveLength(1);
|
||||
expect(
|
||||
(result.content as unknown as Array<{ text: string }>)[0].text
|
||||
).toContain("Error:");
|
||||
});
|
||||
|
||||
it("should create error response from null", () => {
|
||||
const result = createErrorResponse(null);
|
||||
|
||||
expect(result).toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: null",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should create error response from undefined", () => {
|
||||
const result = createErrorResponse(undefined);
|
||||
|
||||
expect(result).toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: undefined",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// createResponseShell
|
||||
// ==========================================================================
|
||||
|
||||
describe("createResponseShell", () => {
|
||||
it("should return empty response when afterToolCall is false", () => {
|
||||
const result = createResponseShell(false, undefined);
|
||||
|
||||
expect(result).toEqual({
|
||||
role: "assistant",
|
||||
content: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty response when existingMessage is undefined", () => {
|
||||
const result = createResponseShell(true, undefined);
|
||||
|
||||
expect(result).toEqual({
|
||||
role: "assistant",
|
||||
content: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return cloned existing message when afterToolCall and existingMessage provided", () => {
|
||||
const existingMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
};
|
||||
|
||||
const result = createResponseShell(true, existingMessage);
|
||||
|
||||
expect(result).toEqual(existingMessage);
|
||||
expect(result).not.toBe(existingMessage); // Should be a clone
|
||||
});
|
||||
|
||||
it("should return empty response when called with no arguments", () => {
|
||||
const result = createResponseShell();
|
||||
|
||||
expect(result).toEqual({
|
||||
role: "assistant",
|
||||
content: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// getLastToolCall
|
||||
// ==========================================================================
|
||||
|
||||
describe("getLastToolCall", () => {
|
||||
it("should return undefined for string content", () => {
|
||||
const message: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: "Just text",
|
||||
};
|
||||
|
||||
const result = getLastToolCall(message);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined when no tool calls exist", () => {
|
||||
const message: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
};
|
||||
|
||||
const result = getLastToolCall(message);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return the last tool call", () => {
|
||||
const message: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tool_1",
|
||||
toolName: "first_tool",
|
||||
args: {},
|
||||
argsText: "{}",
|
||||
},
|
||||
{ type: "text", text: "Some text" },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tool_2",
|
||||
toolName: "last_tool",
|
||||
args: { key: "value" },
|
||||
argsText: '{"key":"value"}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getLastToolCall(message);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.toolCallId).toBe("tool_2");
|
||||
expect(result?.toolName).toBe("last_tool");
|
||||
});
|
||||
|
||||
it("should return tool call when it is the only content", () => {
|
||||
const message: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tool_only",
|
||||
toolName: "single_tool",
|
||||
args: {},
|
||||
argsText: "{}",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getLastToolCall(message);
|
||||
|
||||
expect(result?.toolCallId).toBe("tool_only");
|
||||
});
|
||||
|
||||
it("should return undefined for empty content array", () => {
|
||||
const message: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
};
|
||||
|
||||
const result = getLastToolCall(message);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,415 @@
|
||||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
convertMessagesToModelFormat,
|
||||
convertToolsToModelFormat,
|
||||
} from "../utils";
|
||||
|
||||
describe("mistral utils", () => {
|
||||
// ==========================================================================
|
||||
// convertToolsToModelFormat
|
||||
// ==========================================================================
|
||||
|
||||
describe("convertToolsToModelFormat", () => {
|
||||
it("should convert tools to Mistral format", () => {
|
||||
const tools = [
|
||||
{
|
||||
name: "get_weather",
|
||||
description: "Get current weather",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string" },
|
||||
},
|
||||
required: ["city"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertToolsToModelFormat(tools);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get current weather",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string" },
|
||||
},
|
||||
required: ["city"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty tools array", () => {
|
||||
const result = convertToolsToModelFormat([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should convert multiple tools", () => {
|
||||
const tools = [
|
||||
{
|
||||
name: "tool_1",
|
||||
description: "First tool",
|
||||
inputSchema: { properties: {} },
|
||||
},
|
||||
{
|
||||
name: "tool_2",
|
||||
description: "Second tool",
|
||||
inputSchema: { properties: {} },
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertToolsToModelFormat(tools);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].function.name).toBe("tool_1");
|
||||
expect(result[1].function.name).toBe("tool_2");
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// convertMessagesToModelFormat
|
||||
// ==========================================================================
|
||||
|
||||
describe("convertMessagesToModelFormat", () => {
|
||||
describe("user messages", () => {
|
||||
it("should convert user message with string content", () => {
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{ role: "user", content: "Hello" },
|
||||
];
|
||||
|
||||
const result = convertMessagesToModelFormat(messages);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should convert user message with text parts", () => {
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello world" }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToModelFormat(messages);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello world" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should convert user message with file attachments", () => {
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "Check this file",
|
||||
attachments: [
|
||||
{
|
||||
type: "file",
|
||||
name: "test.txt",
|
||||
content: "file content",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToModelFormat(messages);
|
||||
|
||||
expect(result[0].content).toContainEqual({
|
||||
type: "text",
|
||||
text: "File: test.txt: file content",
|
||||
});
|
||||
});
|
||||
|
||||
it("should convert user message with image attachments", () => {
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "Check this image",
|
||||
attachments: [
|
||||
{
|
||||
type: "image",
|
||||
name: "photo.png",
|
||||
content: "base64data",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToModelFormat(messages);
|
||||
|
||||
expect(result[0].content).toContainEqual({
|
||||
type: "text",
|
||||
text: "Image: photo.png: base64data",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle user message with image content part", () => {
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Look at this" },
|
||||
{
|
||||
type: "image",
|
||||
image: "base64imagedata",
|
||||
filename: "screenshot.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToModelFormat(messages);
|
||||
|
||||
expect(result[0].content).toContainEqual({
|
||||
type: "text",
|
||||
text: "Image: screenshot.png: base64imagedata",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle user message with file content part", () => {
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Check this" },
|
||||
{
|
||||
type: "file",
|
||||
data: "file data here",
|
||||
filename: "doc.txt",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToModelFormat(messages);
|
||||
|
||||
expect(result[0].content).toContainEqual({
|
||||
type: "text",
|
||||
text: "File: doc.txt: file data here",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle tool result in user message", () => {
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tool_123",
|
||||
toolName: "get_weather",
|
||||
args: {},
|
||||
argsText: "{}",
|
||||
result: "Sunny, 72F",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToModelFormat(messages);
|
||||
|
||||
// Should have user message and tool message
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1]).toEqual({
|
||||
role: "tool",
|
||||
content: "Sunny, 72F",
|
||||
name: "get_weather",
|
||||
toolCallId: "tool_123",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("assistant messages", () => {
|
||||
it("should convert assistant message with string content", () => {
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{ role: "assistant", content: "Hello!" },
|
||||
];
|
||||
|
||||
const result = convertMessagesToModelFormat(messages);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello!" }],
|
||||
toolCalls: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should convert assistant message with text parts", () => {
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Response" }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToModelFormat(messages);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Response" }],
|
||||
toolCalls: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should convert assistant message with tool call", () => {
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tool_123",
|
||||
toolName: "get_weather",
|
||||
args: { city: "NYC" },
|
||||
argsText: '{"city":"NYC"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToModelFormat(messages);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [],
|
||||
toolCalls: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"city":"NYC"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should convert assistant message with reasoning part", () => {
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Let me think about this",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToModelFormat(messages);
|
||||
|
||||
expect(result[0].content).toContainEqual({
|
||||
type: "thinking",
|
||||
thinking: [{ type: "text", text: "Let me think about this" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle tool call with args object when argsText is empty", () => {
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tool_456",
|
||||
toolName: "test_tool",
|
||||
args: { key: "value" },
|
||||
argsText: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToModelFormat(messages);
|
||||
|
||||
expect(result[0].toolCalls[0].function.arguments).toBe(
|
||||
'{"key":"value"}'
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle tool call with empty args", () => {
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tool_789",
|
||||
toolName: "no_args",
|
||||
args: undefined,
|
||||
argsText: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToModelFormat(messages);
|
||||
|
||||
expect(result[0].toolCalls[0].function.arguments).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple messages", () => {
|
||||
it("should convert conversation with multiple messages", () => {
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{ role: "user", content: "Hi" },
|
||||
{ role: "assistant", content: "Hello!" },
|
||||
{ role: "user", content: "How are you?" },
|
||||
];
|
||||
|
||||
const result = convertMessagesToModelFormat(messages);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].role).toBe("user");
|
||||
expect(result[1].role).toBe("assistant");
|
||||
expect(result[2].role).toBe("user");
|
||||
});
|
||||
|
||||
it("should handle mixed content types in conversation", () => {
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{ role: "user", content: "Check the weather" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Let me check" },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tool_1",
|
||||
toolName: "get_weather",
|
||||
args: { city: "NYC" },
|
||||
argsText: '{"city":"NYC"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertMessagesToModelFormat(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].toolCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,11 @@
|
||||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||
|
||||
export type StreamResult =
|
||||
| ThreadMessageLike
|
||||
| { isEnd: true; responseMessage: ThreadMessageLike };
|
||||
|
||||
export type MessageArray = Exclude<ThreadMessageLike["content"], string>;
|
||||
export type ToolCallElement = MessageArray extends ReadonlyArray<infer T>
|
||||
? T
|
||||
: never;
|
||||
export type ToolCallPart = Extract<ToolCallElement, { type: "tool-call" }>;
|
||||
@ -0,0 +1,134 @@
|
||||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Messages,
|
||||
Tool,
|
||||
ToolMessage,
|
||||
UserMessage,
|
||||
} from "@mistralai/mistralai/models/components";
|
||||
import type { TMCPItem } from "@/lib/types";
|
||||
|
||||
export const convertToolsToModelFormat = (tools: TMCPItem[]): Tool[] => {
|
||||
return tools.map((tool) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
export const convertMessagesToModelFormat = (
|
||||
messages: ThreadMessageLike[]
|
||||
): Messages[] => {
|
||||
const result: Messages[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
const content: UserMessage["content"] = [];
|
||||
const toolContent: ToolMessage = { content: null };
|
||||
if (msg.attachments && msg.attachments.length > 0) {
|
||||
msg.attachments.forEach((attachment) => {
|
||||
if (attachment.type === "file") {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `File: ${attachment.name}: ${attachment.content}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (attachment.type === "image") {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `Image: ${attachment.name}: ${attachment.content}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
content.push({ type: "text", text: msg.content });
|
||||
} else {
|
||||
msg.content.forEach((part) => {
|
||||
if (part.type === "text") {
|
||||
content.push({ type: "text", text: part.text });
|
||||
}
|
||||
|
||||
if (part.type === "tool-call") {
|
||||
toolContent.content = part.result || null;
|
||||
toolContent.name = part.toolName;
|
||||
toolContent.toolCallId = part.toolCallId;
|
||||
}
|
||||
|
||||
if (part.type === "image") {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `Image: ${part.filename}: ${part.image}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (part.type === "file") {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `File: ${part.filename}: ${part.data}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
result.push({
|
||||
role: "user",
|
||||
content,
|
||||
});
|
||||
|
||||
if (toolContent.content) {
|
||||
result.push({
|
||||
role: "tool",
|
||||
...toolContent,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "assistant") {
|
||||
const content: AssistantMessage["content"] = [];
|
||||
const toolCalls: AssistantMessage["toolCalls"] = [];
|
||||
if (typeof msg.content === "string") {
|
||||
content.push({ type: "text", text: msg.content });
|
||||
} else {
|
||||
msg.content.forEach((part) => {
|
||||
if (part.type === "text") {
|
||||
content.push({ type: "text", text: part.text });
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
content.push({
|
||||
type: "thinking",
|
||||
thinking: [{ type: "text", text: part.text }],
|
||||
});
|
||||
}
|
||||
|
||||
if (part.type === "tool-call") {
|
||||
toolCalls.push({
|
||||
type: "function",
|
||||
id: part.parentId,
|
||||
function: {
|
||||
name: part.toolName,
|
||||
arguments:
|
||||
part.argsText || part.args ? JSON.stringify(part.args) : "",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
result.push({
|
||||
role: "assistant",
|
||||
content,
|
||||
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@ -3,6 +3,7 @@ import { type AnthropicProvider, anthropicProvider } from "./anthropic";
|
||||
import { type DeepSeekProvider, deepseekProvider } from "./deepseek";
|
||||
import { type GenAIProvider, genaiProvider } from "./genai";
|
||||
import { type LMStudioProvider, lmStudioProvider } from "./lm-studio";
|
||||
import { type MistralProvider, mistralProvider } from "./mistral";
|
||||
import { type OllamaProvider, ollamaProvider } from "./ollama";
|
||||
import { type OpenAIProvider, openaiProvider } from "./openai";
|
||||
import { type OpenRouterProvider, openrouterProvider } from "./openrouter";
|
||||
@ -18,7 +19,8 @@ export type BaseProvider =
|
||||
| GenAIProvider
|
||||
| DeepSeekProvider
|
||||
| XAIProvider
|
||||
| LMStudioProvider;
|
||||
| LMStudioProvider
|
||||
| MistralProvider;
|
||||
|
||||
/**
|
||||
* Registry mapping provider types to their singleton instances.
|
||||
@ -34,6 +36,7 @@ export const providerRegistry: Record<ProviderType, BaseProvider> = {
|
||||
deepseek: deepseekProvider,
|
||||
xai: xaiProvider,
|
||||
"lm-studio": lmStudioProvider,
|
||||
mistral: mistralProvider,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -168,6 +168,70 @@ export const createReasoningChunk = (
|
||||
],
|
||||
}) as unknown as ChatCompletionChunk;
|
||||
|
||||
// ============================================================================
|
||||
// Mistral Chunk Factories
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a Mistral text delta event for streaming.
|
||||
*/
|
||||
export const createMistralTextDeltaEvent = (
|
||||
content: string,
|
||||
finishReason?: string
|
||||
) => ({
|
||||
data: {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content },
|
||||
finishReason: finishReason ?? null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a Mistral tool call event for streaming.
|
||||
*/
|
||||
export const createMistralToolCallEvent = (
|
||||
id: string,
|
||||
name: string,
|
||||
args: string,
|
||||
finishReason?: string
|
||||
) => ({
|
||||
data: {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
toolCalls: [
|
||||
{
|
||||
id,
|
||||
function: { name, arguments: args },
|
||||
},
|
||||
],
|
||||
},
|
||||
finishReason: finishReason ?? null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a Mistral finish event to end the stream.
|
||||
*/
|
||||
export const createMistralFinishEvent = () => ({
|
||||
data: {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finishReason: "stop",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Message Factories
|
||||
// ============================================================================
|
||||
@ -197,17 +261,18 @@ export const createMessage = (
|
||||
export const createToolCallPart = (overrides?: {
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
args?: Record<string, unknown>;
|
||||
args?: Record<string, string | number | boolean | null>;
|
||||
argsText?: string;
|
||||
result?: unknown;
|
||||
}) => ({
|
||||
type: "tool-call" as const,
|
||||
toolCallId: overrides?.toolCallId ?? "call_123",
|
||||
toolName: overrides?.toolName ?? "test_tool",
|
||||
args: overrides?.args ?? {},
|
||||
argsText: overrides?.argsText ?? "{}",
|
||||
...(overrides?.result !== undefined && { result: overrides.result }),
|
||||
});
|
||||
}) =>
|
||||
({
|
||||
type: "tool-call" as const,
|
||||
toolCallId: overrides?.toolCallId ?? "call_123",
|
||||
toolName: overrides?.toolName ?? "test_tool",
|
||||
args: overrides?.args ?? {},
|
||||
argsText: overrides?.argsText ?? "{}",
|
||||
...(overrides?.result !== undefined && { result: overrides.result }),
|
||||
}) as const;
|
||||
|
||||
/**
|
||||
* Creates a text content part.
|
||||
@ -241,7 +306,17 @@ export const createTestProvider = (
|
||||
baseUrl?: string;
|
||||
}
|
||||
) => ({
|
||||
type: type as "openai" | "anthropic" | "genai",
|
||||
type: type as
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "genai"
|
||||
| "mistral"
|
||||
| "deepseek"
|
||||
| "together"
|
||||
| "xai"
|
||||
| "openrouter"
|
||||
| "ollama"
|
||||
| "lm-studio",
|
||||
name: overrides?.name ?? type,
|
||||
key: overrides?.key ?? "test-key",
|
||||
baseUrl: overrides?.baseUrl ?? `https://api.${type}.com/v1`,
|
||||
|
||||
Reference in New Issue
Block a user