+
+
+
+
+
+
+
+
{!isOAuth && (
= {
zhipu: 4,
deepseek: 5,
openrouter: 6,
- qwen: 7,
- moonshot: 8,
- groq: 9,
- "github-copilot": 10,
- antigravity: 11,
- nvidia: 12,
- cerebras: 13,
- shengsuanyun: 14,
- ollama: 15,
- vllm: 16,
- mistral: 17,
- avian: 18,
- mimo: 19,
+ "qwen-portal": 7,
+ "qwen-intl": 8,
+ moonshot: 9,
+ groq: 10,
+ "github-copilot": 11,
+ antigravity: 12,
+ nvidia: 13,
+ cerebras: 14,
+ shengsuanyun: 15,
+ venice: 16,
+ vivgrid: 17,
+ minimax: 18,
+ longcat: 19,
+ modelscope: 20,
+ mistral: 21,
+ avian: 22,
+ azure: 23,
+ ollama: 24,
+ vllm: 25,
+ lmstudio: 26,
+ zai: 27,
+ mimo: 28,
}
interface ProviderGroup {
@@ -95,10 +104,10 @@ export function ModelsPage() {
const grouped: Record = {}
for (const model of models) {
- const providerKey = getProviderKey(model.model)
+ const providerKey = getProviderKey(model.provider)
if (!grouped[providerKey]) {
grouped[providerKey] = {
- label: getProviderLabel(model.model),
+ label: getProviderLabel(model.provider),
models: [],
}
}
diff --git a/web/frontend/src/components/models/provider-icon.tsx b/web/frontend/src/components/models/provider-icon.tsx
index 814a59834..8d1cfe2c9 100644
--- a/web/frontend/src/components/models/provider-icon.tsx
+++ b/web/frontend/src/components/models/provider-icon.tsx
@@ -3,9 +3,11 @@ import { useMemo, useState } from "react"
const PROVIDER_ICON_SLUGS: Record = {
openai: "openai",
anthropic: "anthropic",
+ azure: "microsoftazure",
gemini: "googlegemini",
deepseek: "deepseek",
- qwen: "alibabacloud",
+ "qwen-portal": "alibabacloud",
+ "qwen-intl": "alibabacloud",
groq: "groq",
openrouter: "openrouter",
nvidia: "nvidia",
@@ -20,9 +22,11 @@ const PROVIDER_ICON_SLUGS: Record = {
const PROVIDER_DOMAINS: Record = {
openai: "openai.com",
anthropic: "anthropic.com",
+ azure: "azure.com",
gemini: "gemini.google.com",
deepseek: "deepseek.com",
- qwen: "qwenlm.ai",
+ "qwen-portal": "qwenlm.ai",
+ "qwen-intl": "alibabacloud.com",
moonshot: "moonshot.ai",
groq: "groq.com",
openrouter: "openrouter.ai",
@@ -33,11 +37,18 @@ const PROVIDER_DOMAINS: Record = {
antigravity: "antigravity.google",
"github-copilot": "github.com",
ollama: "ollama.com",
+ lmstudio: "lmstudio.ai",
mistral: "mistral.ai",
avian: "avian.io",
vllm: "vllm.ai",
zhipu: "zhipuai.cn",
+ zai: "z.ai",
mimo: "xiaomi.com",
+ venice: "venice.ai",
+ vivgrid: "vivgrid.com",
+ minimax: "minimaxi.com",
+ longcat: "longcat.chat",
+ modelscope: "modelscope.cn",
}
interface ProviderIconProps {
diff --git a/web/frontend/src/components/models/provider-label.ts b/web/frontend/src/components/models/provider-label.ts
index 82600a96f..123640fe5 100644
--- a/web/frontend/src/components/models/provider-label.ts
+++ b/web/frontend/src/components/models/provider-label.ts
@@ -1,9 +1,11 @@
const PROVIDER_LABELS: Record = {
openai: "OpenAI",
anthropic: "Anthropic",
+ azure: "Azure OpenAI",
gemini: "Google Gemini",
deepseek: "DeepSeek",
- qwen: "Qwen (阿里云)",
+ "qwen-portal": "Qwen (阿里云)",
+ "qwen-intl": "Qwen International",
moonshot: "Moonshot (月之暗面)",
groq: "Groq",
openrouter: "OpenRouter",
@@ -14,21 +16,37 @@ const PROVIDER_LABELS: Record = {
antigravity: "Google Code Assist",
"github-copilot": "GitHub Copilot",
ollama: "Ollama (local)",
+ lmstudio: "LM Studio (local)",
mistral: "Mistral AI",
avian: "Avian",
vllm: "VLLM (local)",
zhipu: "Zhipu AI (智谱)",
+ zai: "Z.ai",
mimo: "Xiaomi MiMo",
+ venice: "Venice AI",
+ vivgrid: "Vivgrid",
+ minimax: "MiniMax",
+ longcat: "LongCat",
+ modelscope: "ModelScope (魔搭社区)",
}
-export function getProviderKey(model: string): string {
- return model.split("/")[0]
+const PROVIDER_ALIASES: Record = {
+ qwen: "qwen-portal",
+ "qwen-international": "qwen-intl",
+ "dashscope-intl": "qwen-intl",
+ "z.ai": "zai",
+ "z-ai": "zai",
+ google: "gemini",
+ "google-antigravity": "antigravity",
}
-export function getProviderLabel(model: string): string {
- const prefix = getProviderKey(model)
- const labels: Record = {
- ...PROVIDER_LABELS,
- }
- return labels[prefix] ?? prefix
+export function getProviderKey(provider?: string): string {
+ const normalized = provider?.trim().toLowerCase()
+ if (!normalized) return "openai"
+ return PROVIDER_ALIASES[normalized] ?? normalized
+}
+
+export function getProviderLabel(provider?: string): string {
+ const prefix = getProviderKey(provider)
+ return PROVIDER_LABELS[prefix] ?? prefix
}
diff --git a/web/frontend/src/features/chat/history.ts b/web/frontend/src/features/chat/history.ts
index 92beb06b7..a3e6ce14d 100644
--- a/web/frontend/src/features/chat/history.ts
+++ b/web/frontend/src/features/chat/history.ts
@@ -2,16 +2,37 @@ import { getSessionHistory } from "@/api/sessions"
import { normalizeUnixTimestamp } from "@/features/chat/state"
import type { ChatAttachment, ChatMessage } from "@/store/chat"
-function toChatAttachments(media?: string[]): ChatAttachment[] | undefined {
- if (!media || media.length === 0) {
- return undefined
- }
+function toChatAttachments({
+ media,
+ attachments,
+}: {
+ media?: string[]
+ attachments?: {
+ type?: "image" | "audio" | "video" | "file"
+ url: string
+ filename?: string
+ content_type?: string
+ }[]
+}): ChatAttachment[] | undefined {
+ const normalizedAttachments = attachments
+ ?.filter((attachment) => attachment.url)
+ .map(
+ (attachment) =>
+ ({
+ type: attachment.type ?? "file",
+ url: attachment.url,
+ filename: attachment.filename,
+ contentType: attachment.content_type,
+ }) satisfies ChatAttachment,
+ )
- const attachments = media
+ const legacyMediaAttachments = (media ?? [])
.filter((item) => item.startsWith("data:image/"))
.map((url) => ({ type: "image" as const, url }))
- return attachments.length > 0 ? attachments : undefined
+ const merged = [...(normalizedAttachments ?? []), ...legacyMediaAttachments]
+
+ return merged.length > 0 ? merged : undefined
}
export async function loadSessionMessages(
@@ -25,7 +46,10 @@ export async function loadSessionMessages(
role: message.role,
content: message.content,
kind: message.role === "assistant" ? "normal" : undefined,
- attachments: toChatAttachments(message.media),
+ attachments: toChatAttachments({
+ media: message.media,
+ attachments: message.attachments,
+ }),
timestamp: fallbackTime,
}))
}
@@ -46,7 +70,10 @@ function normalizeMessageTimestamp(timestamp: number | string): string {
function messageSignature(message: ChatMessage): string {
const attachmentSignature = (message.attachments ?? [])
- .map((attachment) => `${attachment.type}\u0001${attachment.url}`)
+ .map(
+ (attachment) =>
+ `${attachment.type}\u0001${attachment.url}\u0001${attachment.filename ?? ""}`,
+ )
.join("\u0002")
return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp(
diff --git a/web/frontend/src/features/chat/protocol.ts b/web/frontend/src/features/chat/protocol.ts
index cc2ef45e7..b255f55cb 100644
--- a/web/frontend/src/features/chat/protocol.ts
+++ b/web/frontend/src/features/chat/protocol.ts
@@ -3,6 +3,7 @@ import { toast } from "sonner"
import { normalizeUnixTimestamp } from "@/features/chat/state"
import {
type AssistantMessageKind,
+ type ChatAttachment,
type ContextUsage,
updateChatStore,
} from "@/store/chat"
@@ -25,6 +26,52 @@ function hasAssistantKindPayload(payload: Record): boolean {
return typeof payload.thought === "boolean"
}
+function parseAttachments(
+ payload: Record,
+): ChatAttachment[] | undefined {
+ const raw = payload.attachments
+ if (!Array.isArray(raw)) {
+ return undefined
+ }
+
+ const attachments: ChatAttachment[] = []
+ for (const item of raw) {
+ if (!item || typeof item !== "object") {
+ continue
+ }
+
+ const attachment = item as Record
+ const url = typeof attachment.url === "string" ? attachment.url : ""
+ if (!url) {
+ continue
+ }
+
+ const type =
+ attachment.type === "audio" ||
+ attachment.type === "video" ||
+ attachment.type === "file" ||
+ attachment.type === "image"
+ ? attachment.type
+ : "file"
+
+ const filename =
+ typeof attachment.filename === "string" ? attachment.filename : undefined
+ const contentType =
+ typeof attachment.content_type === "string"
+ ? attachment.content_type
+ : undefined
+
+ attachments.push({
+ type,
+ url,
+ ...(filename ? { filename } : {}),
+ ...(contentType ? { contentType } : {}),
+ })
+ }
+
+ return attachments.length > 0 ? attachments : undefined
+}
+
function parseContextUsage(
payload: Record,
): ContextUsage | undefined {
@@ -54,10 +101,12 @@ export function handlePicoMessage(
const payload = message.payload || {}
switch (message.type) {
- case "message.create": {
+ case "message.create":
+ case "media.create": {
const content = (payload.content as string) || ""
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
const kind = parseAssistantMessageKind(payload)
+ const attachments = parseAttachments(payload)
const contextUsage = parseContextUsage(payload)
const timestamp =
message.timestamp !== undefined &&
@@ -73,6 +122,7 @@ export function handlePicoMessage(
role: "assistant",
content,
kind,
+ attachments,
timestamp,
},
],
@@ -87,6 +137,7 @@ export function handlePicoMessage(
const messageId = payload.message_id as string
const hasKind = hasAssistantKindPayload(payload)
const kind = parseAssistantMessageKind(payload)
+ const attachments = parseAttachments(payload)
if (!messageId) {
break
}
@@ -98,6 +149,7 @@ export function handlePicoMessage(
...msg,
content,
...(hasKind ? { kind } : {}),
+ ...(attachments ? { attachments } : {}),
}
: msg,
),
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index be01a949c..cf8d91f0c 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -244,8 +244,8 @@
"modelNamePlaceholder": "e.g. my-gpt4",
"modelNameHint": "A short name used to identify this model in conversations.",
"modelId": "Model Identifier",
- "modelIdPlaceholder": "e.g. openai/gpt-4o",
- "modelIdHint": "Format: protocol/model-id. Supported: openai, anthropic, gemini, groq, …",
+ "modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o",
+ "modelIdHint": "If Provider is not specified, values such as openai/gpt-4o are interpreted using the provider/model format. If Provider is specified, this field is treated as the canonical model ID and is not parsed for a provider prefix.",
"errorRequired": "This field is required.",
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
"saveError": "Failed to add model",
@@ -260,6 +260,9 @@
"toggle": "Advanced options"
},
"field": {
+ "provider": "Provider",
+ "providerPlaceholder": "e.g. openai",
+ "providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.",
"apiBase": "API Base URL",
"apiKey": "API Key",
"apiKeyPlaceholder": "Enter your API key",
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index 23c00db6e..1d3c571c3 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -244,8 +244,8 @@
"modelNamePlaceholder": "例如 my-gpt4",
"modelNameHint": "用于在对话中识别此模型的简短名称。",
"modelId": "模型标识符",
- "modelIdPlaceholder": "例如 openai/gpt-4o",
- "modelIdHint": "格式:协议/模型ID。支持:openai、anthropic、gemini、groq 等。",
+ "modelIdPlaceholder": "例如 gpt-4o 或 openai/gpt-4o",
+ "modelIdHint": "未指定 Provider 时,诸如 openai/gpt-4o 的值将按 provider/model 格式解析。已指定 Provider 时,此字段将作为规范模型 ID 使用,不再解析其中的 provider 前缀。",
"errorRequired": "此字段为必填项。",
"errorDuplicateModelName": "模型别名已存在,请使用其他名称。",
"saveError": "添加模型失败",
@@ -260,6 +260,9 @@
"toggle": "高级选项"
},
"field": {
+ "provider": "Provider",
+ "providerPlaceholder": "例如 openai",
+ "providerHint": "可选。指定后,将以该值作为最终 provider,并将“模型标识符”字段解释为规范模型 ID。",
"apiBase": "API Base URL",
"apiKey": "API Key",
"apiKeyPlaceholder": "请输入 API Key",
diff --git a/web/frontend/src/store/chat.ts b/web/frontend/src/store/chat.ts
index 0a5edb646..393254416 100644
--- a/web/frontend/src/store/chat.ts
+++ b/web/frontend/src/store/chat.ts
@@ -6,9 +6,10 @@ import {
} from "@/features/chat/state"
export interface ChatAttachment {
- type: "image"
+ type: "image" | "audio" | "video" | "file"
url: string
filename?: string
+ contentType?: string
}
export type AssistantMessageKind = "normal" | "thought"
diff --git a/web/frontend/vite.config.ts b/web/frontend/vite.config.ts
index 57512c8b9..0de085803 100644
--- a/web/frontend/vite.config.ts
+++ b/web/frontend/vite.config.ts
@@ -29,6 +29,10 @@ export default defineConfig({
target: "http://localhost:18800",
changeOrigin: true,
},
+ "/pico/media": {
+ target: "http://localhost:18800",
+ changeOrigin: true,
+ },
"/pico/ws": {
target: "ws://localhost:18800",
ws: true,