mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-07 02:07:47 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab33dae69d |
@@ -77,7 +77,6 @@ export default function ExamplePanel({
|
||||
minimal?: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
||||
|
||||
const handleReplicateFlowchart = async () => {
|
||||
setInput("Replicate this flowchart.")
|
||||
@@ -126,31 +125,29 @@ export default function ExamplePanel({
|
||||
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
|
||||
{!minimal && (
|
||||
<>
|
||||
{/* MCP Server Notice, hidden on self-hosted deployments */}
|
||||
{!isSelfHosted && (
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<Terminal className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
||||
{dict.examples.mcpServer}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.examples.mcpDescription}
|
||||
</p>
|
||||
</div>
|
||||
{/* MCP Server Notice */}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<Terminal className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
||||
{dict.examples.mcpServer}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.examples.mcpDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Welcome section */}
|
||||
<div className="text-center mb-6">
|
||||
|
||||
@@ -427,16 +427,13 @@ export default function ChatPanel({
|
||||
let openModelConfig = false
|
||||
if (data?.type === "provider") {
|
||||
const hints = dict.errors.llm as Record<string, string>
|
||||
const hint = hints[data.code]
|
||||
text =
|
||||
hint && data.message
|
||||
? `${hint}\n\n${data.message}`
|
||||
: hint || data.message
|
||||
text = hints[data.code]
|
||||
? `${hints[data.code]}\n\n${data.message}`
|
||||
: data.message
|
||||
openModelConfig = [
|
||||
"invalid_api_key",
|
||||
"forbidden",
|
||||
"model_not_found",
|
||||
"server_key_forbidden",
|
||||
].includes(data.code)
|
||||
} else if (typeof data?.error === "string") {
|
||||
text = data.error
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
# AI Provider Configuration
|
||||
# AI_PROVIDER: Which provider to use
|
||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud
|
||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, gateway, novita
|
||||
# Default: bedrock
|
||||
AI_PROVIDER=bedrock
|
||||
|
||||
@@ -168,7 +168,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# which triggers the client UI to display messages suggesting self-hosting or sponsorship.
|
||||
# This switch allows self-hosted users to provide custom messages in response to a 429 code,
|
||||
# in messageTokenSelfHosted, messageApiSelfHosted, and tipSelfHosted translation strings.
|
||||
# It also turns off the MCP server advertisement in the chat examples.
|
||||
# NEXT_PUBLIC_SELFHOSTED=true
|
||||
|
||||
# Minimax Configuration (Optional)
|
||||
|
||||
@@ -125,12 +125,7 @@ let writableCache: boolean | null = null
|
||||
export function isSettingsWritable(): boolean {
|
||||
if (writableCache !== null) return writableCache
|
||||
try {
|
||||
const filePath = getSettingsPath()
|
||||
if (fs.existsSync(filePath) && !fs.statSync(filePath).isFile()) {
|
||||
writableCache = false
|
||||
return writableCache
|
||||
}
|
||||
const dir = path.dirname(filePath)
|
||||
const dir = path.dirname(getSettingsPath())
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.accessSync(dir, fs.constants.W_OK)
|
||||
writableCache = true
|
||||
|
||||
@@ -192,7 +192,6 @@
|
||||
"llm": {
|
||||
"invalid_api_key": "The provider rejected the API key. Check it in model settings.",
|
||||
"forbidden": "The provider refused the request. The key may not have access to this model or region.",
|
||||
"server_key_forbidden": "Today's free quota is used up. It resets tomorrow. You can also add your own API key in model settings to keep going.",
|
||||
"model_not_found": "The provider does not know this model. Check the model ID in model settings.",
|
||||
"insufficient_quota": "The provider account has no credit or quota left.",
|
||||
"rate_limited": "The provider is limiting requests. Wait a moment and try again.",
|
||||
|
||||
@@ -192,7 +192,6 @@
|
||||
"llm": {
|
||||
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
|
||||
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
|
||||
"server_key_forbidden": "本日の無料枠を使い切りました。明日になると自動的に回復します。モデル設定でご自身の API キーを入力すると、引き続きご利用いただけます。",
|
||||
"model_not_found": "プロバイダーがこのモデルを認識できません。モデル設定でモデル ID を確認してください。",
|
||||
"insufficient_quota": "プロバイダーのアカウントの残高または利用枠がなくなりました。",
|
||||
"rate_limited": "プロバイダーがリクエスト数を制限しています。少し待ってから再試行してください。",
|
||||
|
||||
@@ -192,7 +192,6 @@
|
||||
"llm": {
|
||||
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
|
||||
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
|
||||
"server_key_forbidden": "今天的免費額度已經用完,明天會自動恢復。您也可以在模型設定中填寫自己的 API Key 繼續使用。",
|
||||
"model_not_found": "服務商找不到這個模型,請在模型設定中檢查模型 ID。",
|
||||
"insufficient_quota": "服務商帳戶的餘額或額度已經用完。",
|
||||
"rate_limited": "服務商正在限制請求頻率,請稍候再試。",
|
||||
|
||||
@@ -192,7 +192,6 @@
|
||||
"llm": {
|
||||
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
|
||||
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
|
||||
"server_key_forbidden": "今天的免费额度已经用完,明天会自动恢复。您也可以在模型设置里填写自己的 API Key 继续使用。",
|
||||
"model_not_found": "服务商找不到这个模型,请在模型设置里检查模型 ID。",
|
||||
"insufficient_quota": "服务商账户的余额或额度已经用完。",
|
||||
"rate_limited": "服务商正在限制请求频率,请稍等片刻再试。",
|
||||
|
||||
+1
-9
@@ -24,8 +24,6 @@ export type LLMErrorCode =
|
||||
| "provider_unavailable"
|
||||
| "cannot_connect"
|
||||
| "timeout"
|
||||
// A 403 on the server's own key, e.g. a daily spend cap blocked it
|
||||
| "server_key_forbidden"
|
||||
| "unknown"
|
||||
|
||||
export interface LLMError {
|
||||
@@ -132,13 +130,7 @@ export function streamErrorText(error: unknown, hideDetails = false): string {
|
||||
const classified = classifyLLMError(error)
|
||||
if (hideDetails) {
|
||||
console.error("[chat] Provider error:", error)
|
||||
if (classified.code === "forbidden") {
|
||||
// The hint says all the user can do; there is no message to add
|
||||
classified.code = "server_key_forbidden"
|
||||
classified.message = ""
|
||||
} else {
|
||||
classified.message = "The provider returned an error."
|
||||
}
|
||||
classified.message = "The provider returned an error."
|
||||
}
|
||||
return JSON.stringify(classified)
|
||||
}
|
||||
|
||||
Generated
+1308
-466
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,7 @@
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@xmldom/xmldom": "^0.9.8",
|
||||
"ai": "^6.0.300",
|
||||
"base-64": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
|
||||
@@ -75,30 +75,3 @@ test("a provider rate limit is not shown as this site's quota", async ({
|
||||
// The site's own tokens-per-minute toast
|
||||
await expect(page.getByText("Rate limit reached")).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("a refused server key shows only the quota hint and a settings button", async ({
|
||||
page,
|
||||
}) => {
|
||||
// What the chat route streams when the server's key gets a 403, e.g.
|
||||
// after a daily spend cap blocked it
|
||||
const errorText = JSON.stringify({
|
||||
type: "provider",
|
||||
code: "server_key_forbidden",
|
||||
message: "",
|
||||
})
|
||||
await chatWith(page, {
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: `data: {"type":"start"}\n\ndata: ${JSON.stringify({ type: "error", errorText })}\n\ndata: [DONE]\n\n`,
|
||||
})
|
||||
await expect(
|
||||
page.getByText("Today's free quota is used up", { exact: false }),
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
await expect(page.getByText("The provider returned an error")).toHaveCount(
|
||||
0,
|
||||
)
|
||||
await page.getByRole("button", { name: "Open model settings" }).click()
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "AI Model Configuration" }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -154,9 +154,9 @@ describe("isSettingsWritable", () => {
|
||||
expect(isSettingsWritable()).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when the settings path is a directory", () => {
|
||||
it("returns false for an unwritable path", () => {
|
||||
_resetForTests()
|
||||
process.env.SETTINGS_FILE = tmpDir
|
||||
process.env.SETTINGS_FILE = "/nonexistent-root-dir/settings.json"
|
||||
expect(isSettingsWritable()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -170,9 +170,7 @@ describe("settings file on disk", () => {
|
||||
version: 1,
|
||||
values: { TEST_ADMIN_VAR: "secret" },
|
||||
})
|
||||
if (process.platform !== "win32") {
|
||||
const mode = fs.statSync(filePath).mode & 0o777
|
||||
expect(mode).toBe(0o600)
|
||||
}
|
||||
const mode = fs.statSync(filePath).mode & 0o777
|
||||
expect(mode).toBe(0o600)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -139,8 +139,7 @@ describe("provider error texts in the stream", () => {
|
||||
)
|
||||
const message = await streamedError({})
|
||||
expect(message).not.toMatch(/org-operator/)
|
||||
// A 403 on the server's key gets its own hint and no message
|
||||
expect(message).toBe("")
|
||||
expect(message).toBe("The provider returned an error.")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -215,29 +215,11 @@ describe("streamErrorText", () => {
|
||||
"User: arn:aws:sts::123456789012:assumed-role/app/s is not authorized to perform: bedrock:InvokeModel",
|
||||
)
|
||||
const hidden = JSON.parse(streamErrorText(error, true))
|
||||
expect(hidden.code).toBe("forbidden")
|
||||
expect(hidden.message).not.toMatch(/arn:aws|123456789012/)
|
||||
expect(JSON.parse(streamErrorText(error)).message).toMatch(
|
||||
/not authorized/,
|
||||
)
|
||||
const throttled = JSON.parse(
|
||||
streamErrorText(apiError(429, "Too many tokens"), true),
|
||||
)
|
||||
expect(throttled).toEqual({
|
||||
type: "provider",
|
||||
code: "rate_limited",
|
||||
message: "The provider returned an error.",
|
||||
})
|
||||
})
|
||||
|
||||
it("names a 403 on the server's keys, e.g. a spend cap blocked them", () => {
|
||||
const error = apiError(403, "explicit deny in an identity-based policy")
|
||||
expect(JSON.parse(streamErrorText(error, true))).toEqual({
|
||||
type: "provider",
|
||||
code: "server_key_forbidden",
|
||||
message: "",
|
||||
})
|
||||
// On the user's own key it stays a plain refusal
|
||||
expect(JSON.parse(streamErrorText(error)).code).toBe("forbidden")
|
||||
})
|
||||
|
||||
it("classifies a provider error", () => {
|
||||
|
||||
Reference in New Issue
Block a user