Merge branch 'release/v9.3.0' into feature/ai-agent-langs_9.3

This commit is contained in:
Timofey
2026-02-03 15:37:46 +08:00
18 changed files with 2998 additions and 170 deletions

View File

@ -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",

View File

@ -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",

View File

@ -22,7 +22,8 @@ export type ProviderType =
| "genai"
| "deepseek"
| "xai"
| "lm-studio";
| "lm-studio"
| "mistral";
export type Model = {
id: string;

View File

@ -24,8 +24,6 @@ const AvailableTools = () => {
React.useEffect(() => {
setAllToolsCount(webSearchEnabled ? tools.length - 2 : tools.length);
console.log(tools.length);
}, [tools.length, webSearchEnabled]);
React.useEffect(() => {

View File

@ -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];
};

View File

@ -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;
};

View File

@ -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 };

View File

@ -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][],
};

View File

@ -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();
});
});
});

View File

@ -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

View File

@ -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);
});
});
});
});

View File

@ -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" }>;

View File

@ -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;
};

View File

@ -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,
};
/**

View File

@ -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`,