mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +08:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
149ed34b9e | ||
|
|
38b72e89b6 | ||
|
|
6e77b08eb3 | ||
|
|
3855ff2b02 | ||
|
|
668f79c0ab | ||
|
|
75be7cb3ea | ||
|
|
29736bb528 | ||
|
|
73aa6a45f6 | ||
|
|
3c9bcfb978 | ||
|
|
ba2a789d20 | ||
|
|
1a63498d5d | ||
|
|
dee0c80b3e | ||
|
|
4612f7c38d | ||
|
|
f3a4c98f26 | ||
|
|
6857633105 | ||
|
|
57729cf174 | ||
|
|
9c46e760af | ||
|
|
21ab839be7 | ||
|
|
23b76c098b | ||
|
|
a87ada3776 | ||
|
|
4c8d8427bc | ||
|
|
d47c5ebb4e | ||
|
|
52b1f24447 | ||
|
|
7fccf68edf | ||
|
|
113608ec02 | ||
|
|
58b73ef761 | ||
|
|
ea16ea1ece | ||
|
|
e355a50890 | ||
|
|
42ab388118 | ||
|
|
dfedd1b881 | ||
|
|
29acbc33b8 | ||
|
|
d6bdb25f58 | ||
|
|
eb2fa69f58 | ||
|
|
c1e4b1c9bf | ||
|
|
d08b28821e | ||
|
|
8986217a73 | ||
|
|
73788abff9 | ||
|
|
de0ba6b718 | ||
|
|
a8ff1ea2bc | ||
|
|
b723406a48 | ||
|
|
783cb4a6f1 | ||
|
|
801974a18b | ||
|
|
5f71b2783f | ||
|
|
691c7e34bf | ||
|
|
0d50a179aa |
@@ -77,6 +77,7 @@ export default function ExamplePanel({
|
|||||||
minimal?: boolean
|
minimal?: boolean
|
||||||
}) {
|
}) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
|
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
||||||
|
|
||||||
const handleReplicateFlowchart = async () => {
|
const handleReplicateFlowchart = async () => {
|
||||||
setInput("Replicate this flowchart.")
|
setInput("Replicate this flowchart.")
|
||||||
@@ -125,29 +126,31 @@ export default function ExamplePanel({
|
|||||||
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
|
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
|
||||||
{!minimal && (
|
{!minimal && (
|
||||||
<>
|
<>
|
||||||
{/* MCP Server Notice */}
|
{/* MCP Server Notice, hidden on self-hosted deployments */}
|
||||||
<a
|
{!isSelfHosted && (
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
<a
|
||||||
target="_blank"
|
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
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"
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<Terminal className="w-4 h-4 text-purple-500" />
|
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||||
</div>
|
<Terminal className="w-4 h-4 text-purple-500" />
|
||||||
<div className="min-w-0">
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
<div className="flex items-center gap-2">
|
||||||
{dict.examples.mcpServer}
|
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
||||||
</span>
|
{dict.examples.mcpServer}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.examples.mcpDescription}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.examples.mcpDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
</a>
|
)}
|
||||||
|
|
||||||
{/* Welcome section */}
|
{/* Welcome section */}
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
|
|||||||
@@ -427,13 +427,16 @@ export default function ChatPanel({
|
|||||||
let openModelConfig = false
|
let openModelConfig = false
|
||||||
if (data?.type === "provider") {
|
if (data?.type === "provider") {
|
||||||
const hints = dict.errors.llm as Record<string, string>
|
const hints = dict.errors.llm as Record<string, string>
|
||||||
text = hints[data.code]
|
const hint = hints[data.code]
|
||||||
? `${hints[data.code]}\n\n${data.message}`
|
text =
|
||||||
: data.message
|
hint && data.message
|
||||||
|
? `${hint}\n\n${data.message}`
|
||||||
|
: hint || data.message
|
||||||
openModelConfig = [
|
openModelConfig = [
|
||||||
"invalid_api_key",
|
"invalid_api_key",
|
||||||
"forbidden",
|
"forbidden",
|
||||||
"model_not_found",
|
"model_not_found",
|
||||||
|
"server_key_forbidden",
|
||||||
].includes(data.code)
|
].includes(data.code)
|
||||||
} else if (typeof data?.error === "string") {
|
} else if (typeof data?.error === "string") {
|
||||||
text = data.error
|
text = data.error
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
# AI Provider Configuration
|
# AI Provider Configuration
|
||||||
# AI_PROVIDER: Which provider to use
|
# AI_PROVIDER: Which provider to use
|
||||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, gateway, novita
|
# 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
|
||||||
# Default: bedrock
|
# Default: bedrock
|
||||||
AI_PROVIDER=bedrock
|
AI_PROVIDER=bedrock
|
||||||
|
|
||||||
@@ -168,6 +168,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# which triggers the client UI to display messages suggesting self-hosting or sponsorship.
|
# 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,
|
# This switch allows self-hosted users to provide custom messages in response to a 429 code,
|
||||||
# in messageTokenSelfHosted, messageApiSelfHosted, and tipSelfHosted translation strings.
|
# in messageTokenSelfHosted, messageApiSelfHosted, and tipSelfHosted translation strings.
|
||||||
|
# It also turns off the MCP server advertisement in the chat examples.
|
||||||
# NEXT_PUBLIC_SELFHOSTED=true
|
# NEXT_PUBLIC_SELFHOSTED=true
|
||||||
|
|
||||||
# Minimax Configuration (Optional)
|
# Minimax Configuration (Optional)
|
||||||
|
|||||||
@@ -125,7 +125,12 @@ let writableCache: boolean | null = null
|
|||||||
export function isSettingsWritable(): boolean {
|
export function isSettingsWritable(): boolean {
|
||||||
if (writableCache !== null) return writableCache
|
if (writableCache !== null) return writableCache
|
||||||
try {
|
try {
|
||||||
const dir = path.dirname(getSettingsPath())
|
const filePath = getSettingsPath()
|
||||||
|
if (fs.existsSync(filePath) && !fs.statSync(filePath).isFile()) {
|
||||||
|
writableCache = false
|
||||||
|
return writableCache
|
||||||
|
}
|
||||||
|
const dir = path.dirname(filePath)
|
||||||
fs.mkdirSync(dir, { recursive: true })
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
fs.accessSync(dir, fs.constants.W_OK)
|
fs.accessSync(dir, fs.constants.W_OK)
|
||||||
writableCache = true
|
writableCache = true
|
||||||
|
|||||||
@@ -192,6 +192,7 @@
|
|||||||
"llm": {
|
"llm": {
|
||||||
"invalid_api_key": "The provider rejected the API key. Check it in model settings.",
|
"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.",
|
"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.",
|
"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.",
|
"insufficient_quota": "The provider account has no credit or quota left.",
|
||||||
"rate_limited": "The provider is limiting requests. Wait a moment and try again.",
|
"rate_limited": "The provider is limiting requests. Wait a moment and try again.",
|
||||||
|
|||||||
@@ -192,6 +192,7 @@
|
|||||||
"llm": {
|
"llm": {
|
||||||
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
|
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
|
||||||
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
|
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
|
||||||
|
"server_key_forbidden": "本日の無料枠を使い切りました。明日になると自動的に回復します。モデル設定でご自身の API キーを入力すると、引き続きご利用いただけます。",
|
||||||
"model_not_found": "プロバイダーがこのモデルを認識できません。モデル設定でモデル ID を確認してください。",
|
"model_not_found": "プロバイダーがこのモデルを認識できません。モデル設定でモデル ID を確認してください。",
|
||||||
"insufficient_quota": "プロバイダーのアカウントの残高または利用枠がなくなりました。",
|
"insufficient_quota": "プロバイダーのアカウントの残高または利用枠がなくなりました。",
|
||||||
"rate_limited": "プロバイダーがリクエスト数を制限しています。少し待ってから再試行してください。",
|
"rate_limited": "プロバイダーがリクエスト数を制限しています。少し待ってから再試行してください。",
|
||||||
|
|||||||
@@ -192,6 +192,7 @@
|
|||||||
"llm": {
|
"llm": {
|
||||||
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
|
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
|
||||||
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
|
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
|
||||||
|
"server_key_forbidden": "今天的免費額度已經用完,明天會自動恢復。您也可以在模型設定中填寫自己的 API Key 繼續使用。",
|
||||||
"model_not_found": "服務商找不到這個模型,請在模型設定中檢查模型 ID。",
|
"model_not_found": "服務商找不到這個模型,請在模型設定中檢查模型 ID。",
|
||||||
"insufficient_quota": "服務商帳戶的餘額或額度已經用完。",
|
"insufficient_quota": "服務商帳戶的餘額或額度已經用完。",
|
||||||
"rate_limited": "服務商正在限制請求頻率,請稍候再試。",
|
"rate_limited": "服務商正在限制請求頻率,請稍候再試。",
|
||||||
|
|||||||
@@ -192,6 +192,7 @@
|
|||||||
"llm": {
|
"llm": {
|
||||||
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
|
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
|
||||||
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
|
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
|
||||||
|
"server_key_forbidden": "今天的免费额度已经用完,明天会自动恢复。您也可以在模型设置里填写自己的 API Key 继续使用。",
|
||||||
"model_not_found": "服务商找不到这个模型,请在模型设置里检查模型 ID。",
|
"model_not_found": "服务商找不到这个模型,请在模型设置里检查模型 ID。",
|
||||||
"insufficient_quota": "服务商账户的余额或额度已经用完。",
|
"insufficient_quota": "服务商账户的余额或额度已经用完。",
|
||||||
"rate_limited": "服务商正在限制请求频率,请稍等片刻再试。",
|
"rate_limited": "服务商正在限制请求频率,请稍等片刻再试。",
|
||||||
|
|||||||
+9
-1
@@ -24,6 +24,8 @@ export type LLMErrorCode =
|
|||||||
| "provider_unavailable"
|
| "provider_unavailable"
|
||||||
| "cannot_connect"
|
| "cannot_connect"
|
||||||
| "timeout"
|
| "timeout"
|
||||||
|
// A 403 on the server's own key, e.g. a daily spend cap blocked it
|
||||||
|
| "server_key_forbidden"
|
||||||
| "unknown"
|
| "unknown"
|
||||||
|
|
||||||
export interface LLMError {
|
export interface LLMError {
|
||||||
@@ -130,7 +132,13 @@ export function streamErrorText(error: unknown, hideDetails = false): string {
|
|||||||
const classified = classifyLLMError(error)
|
const classified = classifyLLMError(error)
|
||||||
if (hideDetails) {
|
if (hideDetails) {
|
||||||
console.error("[chat] Provider error:", error)
|
console.error("[chat] Provider error:", error)
|
||||||
classified.message = "The provider returned an 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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return JSON.stringify(classified)
|
return JSON.stringify(classified)
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+466
-1308
File diff suppressed because it is too large
Load Diff
@@ -67,7 +67,6 @@
|
|||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
"@xmldom/xmldom": "^0.9.8",
|
"@xmldom/xmldom": "^0.9.8",
|
||||||
"ai": "^6.0.300",
|
"ai": "^6.0.300",
|
||||||
"base-64": "^1.0.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ const REQUIRED = [
|
|||||||
"dist/preview/preview.js",
|
"dist/preview/preview.js",
|
||||||
]
|
]
|
||||||
|
|
||||||
const [pack] = JSON.parse(
|
const output = JSON.parse(
|
||||||
execSync("npm pack --dry-run --json", { encoding: "utf8" }),
|
execSync("npm pack --dry-run --json", { encoding: "utf8" }),
|
||||||
)
|
)
|
||||||
|
// npm 11 prints a list, npm 12 an object keyed by package name
|
||||||
|
const pack = Array.isArray(output) ? output[0] : Object.values(output)[0]
|
||||||
const files = new Set(pack.files.map((f) => f.path))
|
const files = new Set(pack.files.map((f) => f.path))
|
||||||
const missing = REQUIRED.filter((f) => !files.has(f))
|
const missing = REQUIRED.filter((f) => !files.has(f))
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
|
|||||||
@@ -75,3 +75,30 @@ test("a provider rate limit is not shown as this site's quota", async ({
|
|||||||
// The site's own tokens-per-minute toast
|
// The site's own tokens-per-minute toast
|
||||||
await expect(page.getByText("Rate limit reached")).toHaveCount(0)
|
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)
|
expect(isSettingsWritable()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("returns false for an unwritable path", () => {
|
it("returns false when the settings path is a directory", () => {
|
||||||
_resetForTests()
|
_resetForTests()
|
||||||
process.env.SETTINGS_FILE = "/nonexistent-root-dir/settings.json"
|
process.env.SETTINGS_FILE = tmpDir
|
||||||
expect(isSettingsWritable()).toBe(false)
|
expect(isSettingsWritable()).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -170,7 +170,9 @@ describe("settings file on disk", () => {
|
|||||||
version: 1,
|
version: 1,
|
||||||
values: { TEST_ADMIN_VAR: "secret" },
|
values: { TEST_ADMIN_VAR: "secret" },
|
||||||
})
|
})
|
||||||
const mode = fs.statSync(filePath).mode & 0o777
|
if (process.platform !== "win32") {
|
||||||
expect(mode).toBe(0o600)
|
const mode = fs.statSync(filePath).mode & 0o777
|
||||||
|
expect(mode).toBe(0o600)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -139,7 +139,8 @@ describe("provider error texts in the stream", () => {
|
|||||||
)
|
)
|
||||||
const message = await streamedError({})
|
const message = await streamedError({})
|
||||||
expect(message).not.toMatch(/org-operator/)
|
expect(message).not.toMatch(/org-operator/)
|
||||||
expect(message).toBe("The provider returned an error.")
|
// A 403 on the server's key gets its own hint and no message
|
||||||
|
expect(message).toBe("")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -215,11 +215,29 @@ describe("streamErrorText", () => {
|
|||||||
"User: arn:aws:sts::123456789012:assumed-role/app/s is not authorized to perform: bedrock:InvokeModel",
|
"User: arn:aws:sts::123456789012:assumed-role/app/s is not authorized to perform: bedrock:InvokeModel",
|
||||||
)
|
)
|
||||||
const hidden = JSON.parse(streamErrorText(error, true))
|
const hidden = JSON.parse(streamErrorText(error, true))
|
||||||
expect(hidden.code).toBe("forbidden")
|
|
||||||
expect(hidden.message).not.toMatch(/arn:aws|123456789012/)
|
expect(hidden.message).not.toMatch(/arn:aws|123456789012/)
|
||||||
expect(JSON.parse(streamErrorText(error)).message).toMatch(
|
expect(JSON.parse(streamErrorText(error)).message).toMatch(
|
||||||
/not authorized/,
|
/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", () => {
|
it("classifies a provider error", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user