feat: 新增服务商 + 文件上传优化 + AskQuestion 改进

新增功能:
- 新增字节火山方舟 (Volcengine Ark) 快速配置预设
  - 支持用户输入模型名称,映射到聊天输入框模型选择器
  - 模型名称存入 extra_env 的 ANTHROPIC_MODEL
- 新增阿里云百炼 (Aliyun Bailian) 快速配置预设
  - 内置 7 个模型供用户直接选择(Qwen 3.5 Plus、Kimi K2.5、GLM-5 等)
  - 无需额外配置模型名称
- 两个服务商均使用 @lobehub/icons 品牌图标 (Volcengine, Bailian)
- ProviderManager 图标识别规则新增 volces.com/dashscope 等 URL 匹配
- PresetConnectDialog 新增 model_names 字段类型,渲染模型名称输入框
- models route 动态读取 extra_env 中的 ANTHROPIC_MODEL 展示到模型选择器

修复 & 优化:
- 移除文件上传的类型限制 (ACCEPTED_FILE_TYPES) 和大小限制 (MAX_FILE_SIZE)
  现在支持所有文件类型和任意大小,由 API 端处理限制
- AskQuestion 按钮样式对齐权限确认按钮 (h-8 px-3 text-sm font-medium)
- AskQuestion 组件渲染位置从工具调用区域移到流式文本下方
  解决长文本输出后用户找不到选择组件的问题
- Anthropic Third-party API 预设的 provider_type 从 "custom" 改为 "anthropic"
  修复第三方 Anthropic 兼容 API 无法正确生效的问题

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-28 12:44:07 +08:00
1 parent d872f91478
commit 68bb6b60bb
8 files changed
+102 -39

No files matched your search

+22 -1
View File
@@ -51,6 +51,15 @@ const PROVIDER_MODEL_LABELS: Record<string, { value: string; label: string }[]>
{ value: 'opus', label: 'Opus 4.6' },
{ value: 'haiku', label: 'Haiku 4.5' },
],
'https://coding.dashscope.aliyuncs.com/apps/anthropic': [
{ value: 'qwen3.5-plus', label: 'Qwen 3.5 Plus' },
{ value: 'qwen3-coder-next', label: 'Qwen 3 Coder Next' },
{ value: 'qwen3-coder-plus', label: 'Qwen 3 Coder Plus' },
{ value: 'kimi-k2.5', label: 'Kimi K2.5' },
{ value: 'glm-5', label: 'GLM-5' },
{ value: 'glm-4.7', label: 'GLM-4.7' },
{ value: 'MiniMax-M2.5', label: 'MiniMax-M2.5' },
],
};
/**
@@ -90,7 +99,19 @@ export async function GET() {
for (const provider of providers) {
if (MEDIA_PROVIDER_TYPES.has(provider.provider_type)) continue;
const matched = PROVIDER_MODEL_LABELS[provider.base_url];
const rawModels = matched || DEFAULT_MODELS;
let rawModels = matched || DEFAULT_MODELS;
// For providers with ANTHROPIC_MODEL in extra_env (e.g. Volcengine Ark),
// show the configured model name in the selector
if (!matched) {
try {
const envObj = JSON.parse(provider.extra_env || '{}');
if (envObj.ANTHROPIC_MODEL) {
rawModels = [{ value: envObj.ANTHROPIC_MODEL, label: envObj.ANTHROPIC_MODEL }];
}
} catch { /* use default */ }
}
const models = deduplicateModels(rawModels);
groups.push({
+2 -20
View File
@@ -81,18 +81,6 @@ const IMAGE_AGENT_SYSTEM_PROMPT = `你是一个图像生成助手。当用户请
- 如果用户要求修改上一张生成的图片,必须加 "useLastGenerated": true
- 在输出结构化块之前,可以先简要说明你的理解和计划`;
// Accepted file types for upload
const ACCEPTED_FILE_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'text/*',
'.md', '.json', '.csv', '.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs',
].join(',');
// Max file sizes
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_DOC_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_FILE_SIZE = MAX_DOC_SIZE; // Use larger limit; we validate per-type in conversion
interface MessageInputProps {
onSend: (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => void;
@@ -631,12 +619,7 @@ export function MessageInput({
file.filename || 'file',
file.mediaType || 'application/octet-stream',
);
// Enforce per-type size limits
const isImage = attachment.type.startsWith('image/');
const sizeLimit = isImage ? MAX_IMAGE_SIZE : MAX_DOC_SIZE;
if (attachment.size <= sizeLimit) {
attachments.push(attachment);
}
attachments.push(attachment);
} catch {
// Skip files that fail conversion
}
@@ -1082,9 +1065,8 @@ export function MessageInput({
{/* PromptInput replaces the old input area */}
<PromptInput
onSubmit={handleSubmit}
accept={ACCEPTED_FILE_TYPES}
accept=""
multiple
maxFileSize={MAX_FILE_SIZE}
>
{/* Bridge: listens for file tree "+" button events */}
<FileTreeAttachmentBridge />
+14 -14
View File
@@ -219,7 +219,7 @@ function AskUserQuestionUI({
<button
key={opt.label}
onClick={() => toggleOption(qIdx, opt.label, q.multiSelect)}
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
className={`rounded-lg border h-8 px-3 text-sm font-medium transition-colors ${
isSelected
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-foreground hover:bg-muted'
@@ -236,7 +236,7 @@ function AskUserQuestionUI({
{/* Other option */}
<button
onClick={() => toggleOther(qIdx, q.multiSelect)}
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
className={`rounded-lg border h-8 px-3 text-sm font-medium transition-colors ${
useOther[qIdx]
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-foreground hover:bg-muted'
@@ -261,7 +261,7 @@ function AskUserQuestionUI({
<button
onClick={handleSubmit}
disabled={!hasAnswer}
className="rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40"
className="rounded-lg bg-primary h-8 px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40"
>
Submit
</button>
@@ -439,17 +439,6 @@ export function StreamingMessage({
/>
)}
{/* Permission approval — AskUserQuestion gets a dedicated UI */}
{pendingPermission?.toolName === 'AskUserQuestion' && !permissionResolved && (
<AskUserQuestionUI
toolInput={pendingPermission.toolInput as Record<string, unknown>}
onSubmit={(decision, updatedInput) => onPermissionResponse?.(decision, updatedInput)}
/>
)}
{pendingPermission?.toolName === 'AskUserQuestion' && permissionResolved && (
<p className="py-1 text-xs text-green-600 dark:text-green-400">Answer submitted</p>
)}
{/* Permission approval — ExitPlanMode gets a dedicated UI */}
{pendingPermission?.toolName === 'ExitPlanMode' && !permissionResolved && (
<ExitPlanModeUI
@@ -576,6 +565,17 @@ export function StreamingMessage({
return stripped ? <MessageResponse>{stripped}</MessageResponse> : null;
})()}
{/* Permission approval — AskUserQuestion rendered after text so it stays in view */}
{pendingPermission?.toolName === 'AskUserQuestion' && !permissionResolved && (
<AskUserQuestionUI
toolInput={pendingPermission.toolInput as Record<string, unknown>}
onSubmit={(decision, updatedInput) => onPermissionResponse?.(decision, updatedInput)}
/>
)}
{pendingPermission?.toolName === 'AskUserQuestion' && permissionResolved && (
<p className="py-1 text-xs text-green-600 dark:text-green-400">Answer submitted</p>
)}
{/* Loading indicator when no content yet */}
{isStreaming && !content && toolUses.length === 0 && !pendingPermission && (
<div className="py-2">
+59 -1
View File
@@ -37,6 +37,7 @@ import { ProviderForm } from "./ProviderForm";
import type { ProviderFormData } from "./ProviderForm";
import type { ApiProvider } from "@/types";
import { useTranslation } from "@/hooks/useTranslation";
import type { TranslationKey } from "@/i18n";
import Anthropic from "@lobehub/icons/es/Anthropic";
import OpenRouter from "@lobehub/icons/es/OpenRouter";
import Zhipu from "@lobehub/icons/es/Zhipu";
@@ -46,6 +47,8 @@ import Minimax from "@lobehub/icons/es/Minimax";
import Aws from "@lobehub/icons/es/Aws";
import Bedrock from "@lobehub/icons/es/Bedrock";
import Google from "@lobehub/icons/es/Google";
import Volcengine from "@lobehub/icons/es/Volcengine";
import Bailian from "@lobehub/icons/es/Bailian";
// ---------------------------------------------------------------------------
// Brand icon resolver
@@ -62,6 +65,10 @@ function getProviderIcon(name: string, baseUrl: string): ReactNode {
if (url.includes("kimi.com") || lower.includes("kimi")) return <Kimi size={18} />;
if (url.includes("moonshot") || lower.includes("moonshot")) return <Moonshot size={18} />;
if (url.includes("minimax") || lower.includes("minimax")) return <Minimax size={18} />;
if (url.includes("volces.com") || url.includes("volcengine") || lower.includes("volcengine") || lower.includes("火山") || lower.includes("doubao") || lower.includes("豆包"))
return <Volcengine size={18} />;
if (url.includes("dashscope") || lower.includes("bailian") || lower.includes("百炼") || lower.includes("aliyun"))
return <Bailian size={18} />;
if (lower.includes("bedrock")) return <Bedrock size={18} />;
if (lower.includes("vertex") || lower.includes("google")) return <Google size={18} />;
if (lower.includes("aws")) return <Aws size={18} />;
@@ -118,7 +125,7 @@ const QUICK_PRESETS: QuickPreset[] = [
description: "Anthropic-compatible API — provide URL and Key",
descriptionZh: "Anthropic 兼容第三方 API — 填写地址和密钥",
icon: <Anthropic size={18} />,
provider_type: "custom",
provider_type: "anthropic",
base_url: "",
extra_env: '{"ANTHROPIC_API_KEY":""}',
fields: ["name", "api_key", "base_url", "model_names"],
@@ -211,6 +218,28 @@ const QUICK_PRESETS: QuickPreset[] = [
extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_API_KEY":""}',
fields: ["api_key"],
},
{
key: "volcengine",
name: "Volcengine Ark",
description: "Volcengine Ark Coding Plan — Doubao, GLM, DeepSeek, Kimi",
descriptionZh: "字节火山方舟 Coding Plan — 豆包、GLM、DeepSeek、Kimi",
icon: <Volcengine size={18} />,
provider_type: "custom",
base_url: "https://ark.cn-beijing.volces.com/api/coding",
extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}',
fields: ["api_key", "model_names"],
},
{
key: "bailian",
name: "Aliyun Bailian",
description: "Aliyun Bailian Coding Plan — Qwen, GLM, Kimi, MiniMax",
descriptionZh: "阿里云百炼 Coding Plan — 通义千问、GLM、Kimi、MiniMax",
icon: <Bailian size={18} />,
provider_type: "custom",
base_url: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}',
fields: ["api_key"],
},
{
key: "bedrock",
name: "AWS Bedrock",
@@ -294,6 +323,7 @@ function PresetConnectDialog({
const [baseUrl, setBaseUrl] = useState("");
const [name, setName] = useState("");
const [extraEnv, setExtraEnv] = useState("{}");
const [modelName, setModelName] = useState("");
const [showAdvanced, setShowAdvanced] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
@@ -307,6 +337,7 @@ function PresetConnectDialog({
setBaseUrl(preset.base_url);
setName(preset.name);
setExtraEnv(preset.extra_env);
setModelName("");
setError(null);
setSaving(false);
setShowAdvanced(false);
@@ -329,6 +360,15 @@ function PresetConnectDialog({
} catch { /* use as-is */ }
}
// Inject model name into extra_env if model_names field is used
if (preset.fields.includes("model_names") && modelName.trim()) {
try {
const envObj = JSON.parse(finalExtraEnv);
envObj["ANTHROPIC_MODEL"] = modelName.trim();
finalExtraEnv = JSON.stringify(envObj);
} catch { /* use as-is */ }
}
// Validate extra_env JSON
try {
JSON.parse(finalExtraEnv);
@@ -410,6 +450,24 @@ function PresetConnectDialog({
</div>
)}
{/* Model name — for providers that need user-specified model */}
{preset.fields.includes("model_names") && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('provider.modelName' as TranslationKey)}</Label>
<Input
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder="ark-code-latest"
className="text-sm font-mono"
/>
<p className="text-[11px] text-muted-foreground">
{isZh
? '在服务商控制台配置的模型名称,如 ark-code-latest、doubao-seed-2.0-code'
: 'Model name configured in provider console, e.g. ark-code-latest'}
</p>
</div>
)}
{/* Extra env — bedrock/vertex/custom always shown */}
{preset.fields.includes("extra_env") && (
<div className="space-y-2">
+1
View File
@@ -128,6 +128,7 @@ const en = {
'provider.providerType': 'Provider Type',
'provider.apiKey': 'API Key',
'provider.baseUrl': 'Base URL',
'provider.modelName': 'Model Name',
'provider.advancedOptions': 'Advanced Options',
'provider.extraEnvVars': 'Extra Environment Variables',
'provider.notes': 'Notes',
+1
View File
@@ -125,6 +125,7 @@ const zh: Record<TranslationKey, string> = {
'provider.providerType': '服务商类型',
'provider.apiKey': 'API 密钥',
'provider.baseUrl': '基础 URL',
'provider.modelName': '模型名称',
'provider.advancedOptions': '高级选项',
'provider.extraEnvVars': '额外环境变量',
'provider.notes': '备注',