From 44856606ebd5406350073c173707798c619cede6 Mon Sep 17 00:00:00 2001 From: lgxwool Date: Sat, 18 Apr 2026 19:31:16 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(webview):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=88=86=E5=B1=82=E9=94=99=E8=AF=AF=E6=98=BE=E7=A4=BA=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ErrorBanner 组件用于显示分层错误 - 添加 OkHttpSseClient 支持 SSE 错误流式传输 - 更新 BridgeHandler 和 useBridge 桥接错误数据 - 新增 bridge 类型定义 - 添加相关设计文档和规格说明 --- .../plans/2026-04-16-layered-error-display.md | 666 ++++++++++++++++++ ...2026-04-16-layered-error-display-design.md | 170 +++++ .../com/github/codeplangui/BridgeHandler.kt | 16 +- .../com/github/codeplangui/ChatService.kt | 35 +- .../github/codeplangui/api/OkHttpSseClient.kt | 28 +- src/main/resources/webview/index.html | 176 +++-- .../github/codeplangui/OkHttpSseClientTest.kt | 7 +- webview/src/App.css | 7 - webview/src/App.tsx | 51 +- webview/src/components/ErrorBanner.tsx | 67 +- webview/src/hooks/useBridge.ts | 5 +- webview/src/types/bridge.d.ts | 3 +- 12 files changed, 1068 insertions(+), 163 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-16-layered-error-display.md create mode 100644 docs/superpowers/specs/2026-04-16-layered-error-display-design.md diff --git a/docs/superpowers/plans/2026-04-16-layered-error-display.md b/docs/superpowers/plans/2026-04-16-layered-error-display.md new file mode 100644 index 0000000..599182b --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-layered-error-display.md @@ -0,0 +1,666 @@ +# 分层错误展示实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 前端按错误类型(配置/配额/临时/未知)分层展示,配置错误引导改设置,临时错误引导重试。 + +**Architecture:** OkHttpSseClient 已有错误分类逻辑,暴露 `ClassifiedError` data class;后端 BridgeHandler 改 `notifyError(type, message)` 签名;前端 ErrorBanner 按 type 渲染不同配色/icon/按钮。 + +**Tech Stack:** Kotlin (OkHttpSseClient, BridgeHandler, ChatService), TypeScript (ErrorBanner, App), JCEF bridge + +--- + +## 文件变更总览 + +| 文件 | 变更 | +|------|------| +| `src/main/kotlin/.../api/OkHttpSseClient.kt` | 新增 `ClassifiedError` data class 和 `classifyErrorType()` public 方法 | +| `src/main/kotlin/.../BridgeHandler.kt` | `notifyError(message)` → `notifyError(errorType, message)` | +| `src/main/kotlin/.../ChatService.kt` | `abortStream` 传 errorType;`startStreamingRound` onError 回调返回 `ClassifiedError` | +| `src/main/kotlin/.../api/SseChunkParser.kt` | 辅助解析 SSE 错误 | +| `webview/src/types/bridge.d.ts` | `onError(type, message)`;`BridgeStatus.lastErrorType` | +| `webview/src/components/ErrorBanner.tsx` | 重构,按 type 渲染不同配色/icon/按钮 | +| `webview/src/App.tsx` | `onError(type, message)` 回调,`errorMessage` state | + +--- + +## Task 1: OkHttpSseClient — 暴露 ClassifiedError 接口 + +**Files:** +- Modify: `src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt:81-84`(TestResult 附近) + +- [ ] **Step 1: 在 `TestResult` 下方添加 `ClassifiedError` data class** + +在 `sealed class TestResult` 下方、`summarizeInterestingSseFrame` 之前插入: + +```kotlin +/** + * Classified API error with type tag for frontend differentiation. + * type: "auth" | "quota" | "temp" | "generic" + */ +data class ClassifiedError( + val type: String, + val message: String +) +``` + +- [ ] **Step 2: 在 `testConnection` 方法下方添加 `classifyErrorType` public 方法** + +在 `testConnection` 方法(line 273)结束后、`parseBodyErrorDetail` 之前添加: + +```kotlin +/** + * Classifies a raw error message into a typed error for frontend display. + */ +fun classifyErrorType(rawMessage: String): String { + return when { + QUOTA_PATTERNS.any { it in rawMessage.lowercase() } -> "quota" + AUTH_PATTERNS.any { it in rawMessage.lowercase() } -> "auth" + BUSY_PATTERNS.any { it in rawMessage.lowercase() } -> "temp" + else -> "generic" + } +} +``` + +- [ ] **Step 3: 确认 `BUSY_PATTERNS`、`AUTH_PATTERNS`、`QUOTA_PATTERNS` 在 companion object 中已存在** + +这些在 line 478-490 已定义,无需修改。 + +- [ ] **Step 4: 运行测试确认未破坏现有功能** + +```bash +./gradlew test --tests "com.github.codeplangui.OkHttpSseClientTest" +``` + +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt +git commit -m "feat(api): add ClassifiedError data class and classifyErrorType()" +``` + +--- + +## Task 2: OkHttpSseClient — streamChat onError 回调返回 ClassifiedError + +**Files:** +- Modify: `src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt:172-179` + +- [ ] **Step 1: 修改 `streamChat` 方法的回调签名** + +找到 `streamChat` 方法签名(line 172): + +```kotlin +fun streamChat( + request: Request, + onToken: (String) -> Unit, + onEnd: () -> Unit, + onError: (String) -> Unit, // ← 旧 + onToolCallChunk: (ToolCallDelta) -> Unit = {}, + onFinishReason: (String) -> Unit = {} +): EventSource +``` + +改为: + +```kotlin + onError: (ClassifiedError) -> Unit, // ← 新 +``` + +- [ ] **Step 2: 修改 `EventSourceListener.onFailure` 中的 onError 调用** + +在 `onFailure` 方法内(line 202-211),找到: + +```kotlin +onError(msg) +``` + +替换为: + +```kotlin +val errorType = classifyErrorType(msg) +onError(ClassifiedError(type = errorType, message = msg)) +``` + +其中 `msg` 是 line 209 中的 `msg` 变量(已根据 HTTP 状态码和响应体构建)。 + +- [ ] **Step 3: 运行测试** + +```bash +./gradlew test --tests "com.github.codeplangui.OkHttpSseClientTest" +``` + +Expected: PASS(测试可能需要更新回调签名) + +- [ ] **Step 4: 提交** + +```bash +git add src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt +git commit -m "feat(api): streamChat onError callback returns ClassifiedError" +``` + +--- + +## Task 3: BridgeHandler — notifyError 签名改为双参数 + +**Files:** +- Modify: `src/main/kotlin/com/github/codeplangui/BridgeHandler.kt:199` + +- [ ] **Step 1: 修改 `notifyError` 方法签名和实现** + +找到 line 199: + +```kotlin +fun notifyError(message: String) = pushJS("window.__bridge.onError(${json.encodeToString(message)})") +``` + +替换为: + +```kotlin +fun notifyError(errorType: String, message: String) = + pushJS("window.__bridge.onError(${json.encodeToString(errorType)}, ${json.encodeToString(message)})") +``` + +- [ ] **Step 2: 确认 JS bridge 注入的 `onError` 接受两个参数** + +找到 JS 注入部分(line 176),`onError: function(message) {}` 保持为空实现(前端会替换),无需修改。但前端调用时会传两个参数,这是兼容的。 + +- [ ] **Step 3: 确认 `BridgeStatusPayload` 无需修改(spec 中计划加 lastErrorType,但暂不实现,作为后续增强)** + +跳过此步。 + +- [ ] **Step 4: 运行 Kotlin 编译确认无错误** + +```bash +./gradlew compileKotlin +``` + +Expected: BUILD SUCCESSFUL + +- [ ] **Step 5: 提交** + +```bash +git add src/main/kotlin/com/github/codeplangui/BridgeHandler.kt +git commit -m "feat(bridge): notifyError(type, message) dual-arg signature" +``` + +--- + +## Task 4: ChatService — abortStream 传 errorType,onError 回调更新 + +**Files:** +- Modify: `src/main/kotlin/com/github/codeplangui/ChatService.kt:359-369`, `371-443` + +- [ ] **Step 1: 修改 `abortStream` 方法签名** + +找到 line 359-369: + +```kotlin +private fun abortStream(msgId: String, errorMessage: String) { + if (activeMessageId != msgId) return + logger.warn("[CodePlanGUI Approval] aborting stream msgId=$msgId error=${errorMessage.summarizeForLog(240)}") + activeStream?.cancel() + activeStream = null + activeMessageId = null + bridgeNotifiedStart.remove(msgId) + resetToolCallState() + publishStatus() + bridgeHandler?.notifyError(errorMessage) // ← 旧 +} +``` + +改为: + +```kotlin +private fun abortStream(msgId: String, errorType: String, errorMessage: String) { + if (activeMessageId != msgId) return + logger.warn("[CodePlanGUI Approval] aborting stream msgId=$msgId type=$errorType error=${errorMessage.summarizeForLog(240)}") + activeStream?.cancel() + activeStream = null + activeMessageId = null + bridgeNotifiedStart.remove(msgId) + resetToolCallState() + publishStatus() + bridgeHandler?.notifyError(errorType, errorMessage) +} +``` + +- [ ] **Step 2: 更新所有 abortStream 调用处** + +找到所有 `abortStream(msgId, "...")` 调用,改为三参数 `abortStream(msgId, errorType, message)`: + +**调用处 1** — `prepareToolCallsForExecution` 中(line 459-460): +```kotlin +abortStream(msgId, "AI sent a tool_calls finish_reason but no tool call deltas were captured") +``` +改为: +```kotlin +abortStream(msgId, "generic", "AI sent a tool_calls finish_reason but no tool call deltas were captured") +``` + +**调用处 2** — 同文件 line 465-468(tool call id missing): +```kotlin +abortStream(msgId, "AI sent a tool_calls finish_reason but tool call index ${accumulated.index} had no id") +``` +改为传入 `"generic"`。 + +**调用处 3** — line 475(malformed args): +```kotlin +abortStream(msgId, "AI returned malformed tool call arguments for index ${accumulated.index}: '$argsJson'") +``` +改为传入 `"generic"`。 + +**调用处 4** — line 479(missing command field): +```kotlin +abortStream(msgId, "AI tool call index ${accumulated.index} is missing required 'command' field") +``` +改为传入 `"generic"`。 + +- [ ] **Step 3: 更新 `sendMessage` 中两处配置错误调用** + +找到 `sendMessage` 中 line 107-108: +```kotlin +bridgeHandler?.notifyError("请先在 Settings > Tools > CodePlanGUI 中配置 API Provider") +``` +改为: +```kotlin +bridgeHandler?.notifyError("auth", "请先在 Settings > Tools > CodePlanGUI 中配置 API Provider") +``` + +找到 line 114-115: +```kotlin +bridgeHandler?.notifyError("API Key 未设置或未保存,请在 Settings 中重新配置并点 Apply/OK") +``` +改为: +```kotlin +bridgeHandler?.notifyError("auth", "API Key 未设置或未保存,请在 Settings 中重新配置并点 Apply/OK") +``` + +- [ ] **Step 4: 更新 `startStreamingRound` 中 `onError` 回调** + +找到 `startStreamingRound` 中的 onError 回调(line 413-421): + +```kotlin +onError = { message -> + if (activeMessageId == msgId) { + logger.warn("[CodePlanGUI Approval] model round failed msgId=$msgId error=$message") + activeStream = null + activeMessageId = null + bridgeNotifiedStart.remove(msgId) + publishStatus() + bridgeHandler?.notifyError(message) + } +}, +``` + +改为: + +```kotlin +onError = { classifiedError -> + if (activeMessageId == msgId) { + logger.warn("[CodePlanGUI Approval] model round failed msgId=$msgId type=${classifiedError.type} error=${classifiedError.message}") + activeStream = null + activeMessageId = null + bridgeNotifiedStart.remove(msgId) + publishStatus() + bridgeHandler?.notifyError(classifiedError.type, classifiedError.message) + } +}, +``` + +- [ ] **Step 5: 更新 `streamChat` 调用处传入回调** + +找到 `startStreamingRound` 中 `client.streamChat` 调用(line 374),确认传入的 `onError` 参数已被上一步更新。确认无误。 + +- [ ] **Step 6: 运行 Kotlin 编译确认无错误** + +```bash +./gradlew compileKotlin +``` + +Expected: BUILD SUCCESSFUL + +- [ ] **Step 7: 运行测试** + +```bash +./gradlew test --tests "com.github.codeplangui.ChatServiceStatusTest" +``` + +Expected: PASS + +- [ ] **Step 8: 提交** + +```bash +git add src/main/kotlin/com/github/codeplangui/ChatService.kt +git commit -m "feat(service): pass errorType through abortStream and onError callback" +``` + +--- + +## Task 5: TypeScript — bridge.d.ts 类型更新 + +**Files:** +- Modify: `webview/src/types/bridge.d.ts:30`, `webview/src/types/bridge.d.ts:1-6` + +- [ ] **Step 1: 更新 `onError` 签名** + +找到 line 30: +```typescript +onError: (message: string) => void +``` + +改为: +```typescript +onError: (type: string, message: string) => void +``` + +- [ ] **Step 2: 更新 `BridgeStatus` 接口** + +找到 line 1-6: +```typescript +export interface BridgeStatus { + providerName: string + model: string + connectionState: 'unconfigured' | 'ready' | 'streaming' | 'error' + contextFile?: string +} +``` + +改为: +```typescript +export interface BridgeStatus { + providerName: string + model: string + connectionState: 'unconfigured' | 'ready' | 'streaming' | 'error' + contextFile?: string + lastErrorType?: string +} +``` + +- [ ] **Step 3: 提交** + +```bash +git add webview/src/types/bridge.d.ts +git commit -m "feat(types): Bridge.onError(type, message), BridgeStatus.lastErrorType" +``` + +--- + +## Task 6: ErrorBanner — 按 type 渲染不同样式 + +**Files:** +- Modify: `webview/src/components/ErrorBanner.tsx` + +- [ ] **Step 1: 重写 ErrorBanner 组件** + +```typescript +import { Alert } from 'antd' + +interface Props { + errorType: 'auth' | 'quota' | 'temp' | 'generic' + message: string + onClose: () => void + onAction?: () => void +} + +const ERROR_STYLES = { + auth: { + bg: '#2d1a1a', + border: '#c0392b', + icon: '🔐', + label: '配置错误', + actionLabel: '打开设置', + }, + quota: { + bg: '#2d2416', + border: '#d4a017', + icon: '💰', + label: '配额不足', + actionLabel: '打开设置', + }, + temp: { + bg: '#1a2a3a', + border: '#2980b9', + icon: '⏳', + label: '临时错误', + actionLabel: '重试', + }, + generic: { + bg: '#2a2a2a', + border: '#7f8c8d', + icon: '❓', + label: '未知错误', + actionLabel: null, + }, +} as const + +export function ErrorBanner({ errorType, message, onClose, onAction }: Props) { + const style = ERROR_STYLES[errorType] ?? ERROR_STYLES.generic + + return ( +
+ {style.icon} +
+
+ {style.label} +
+
{message}
+
+ {style.actionLabel && onAction && ( + + )} + +
+ ) +} +``` + +- [ ] **Step 2: 确认 Alert 组件已无用,移除 import(如果只用了 Alert 的样式,可以整个替换)** + +上面代码已完全重写,不再使用 Ant Design Alert。 + +- [ ] **Step 3: 提交** + +```bash +git add webview/src/components/ErrorBanner.tsx +git commit -m "feat(ui): ErrorBanner renders different styles per error type" +``` + +--- + +## Task 7: App.tsx — onError 回调和重试逻辑 + +**Files:** +- Modify: `webview/src/App.tsx:25`, `webview/src/App.tsx:77-83`, `webview/src/App.tsx:209-214`, `webview/src/App.tsx:291` + +- [ ] **Step 1: 新增 `errorMessage` state** + +找到 line 25 附近: +```typescript +const [error, setError] = useState(null) +``` + +改为: +```typescript +const [errorType, setErrorType] = useState(null) +const [errorMessage, setErrorMessage] = useState(null) +``` + +- [ ] **Step 2: 新增 `lastUserMessage` ref 记录上一条用户消息(用于重试)** + +在 `messagesEndRef` 附近(line 33)添加: +```typescript +const lastUserMessageRef = useRef<{ text: string; includeContext: boolean } | null>(null) +``` + +在 `handleSend` 中,更新消息后记录: +```typescript +lastUserMessageRef.current = { text: payload.text, includeContext } +``` + +- [ ] **Step 3: 更新 `onError` 回调** + +找到 line 77-83: +```typescript +const onError = useCallback((message: string) => { + setIsLoading(false) + setMessages(prev => + prev.map(item => (item.isStreaming ? { ...item, isStreaming: false } : item)), + ) + setError(message) +}, []) +``` + +改为: +```typescript +const onError = useCallback((type: string, message: string) => { + setIsLoading(false) + setMessages(prev => + prev.map(item => (item.isStreaming ? { ...item, isStreaming: false } : item)), + ) + setErrorType(type) + setErrorMessage(message) +}, []) +``` + +- [ ] **Step 4: 添加 `handleErrorAction` 函数** + +在 `handleCancel` 函数附近(line 241)添加: +```typescript +const handleErrorAction = useCallback(() => { + const type = errorType + if (type === 'auth' || type === 'quota') { + window.__bridge?.openSettings() + } else if (type === 'temp' && lastUserMessageRef.current) { + // Retry: re-send last user message + const msgToRetry = lastUserMessageRef.current + window.__bridge?.sendMessage(msgToRetry.text, msgToRetry.includeContext) + } + setErrorType(null) + setErrorMessage(null) + }, [errorType]) +``` + +- [ ] **Step 5: 更新 ErrorBanner 渲染** + +找到 line 291: +```typescript +{error && setError(null)} />} +``` + +改为: +```typescript +{errorType && errorMessage && ( + { setErrorType(null); setErrorMessage(null) }} + onAction={handleErrorAction} + /> +)} +``` + +- [ ] **Step 6: 更新 `handleSend` 记录 lastUserMessageRef** + +在 `handleSend` 函数(line 209)末尾,在 `window.__bridge?.sendMessage` 调用之前或之后添加: +```typescript +lastUserMessageRef.current = { text: payload.text, includeContext } +``` + +(`payload.text` 即用户发送的文本,`includeContext` 为当前 toggle 状态) + +- [ ] **Step 7: 提交** + +```bash +git add webview/src/App.tsx +git commit -m "feat(ui): App routes error actions (openSettings/retry) by errorType" +``` + +--- + +## Task 8: 端到端验证 + +**Files:** +- Test: All changed files + +- [ ] **Step 1: 构建 webview** + +```bash +cd webview && npm run build +``` + +Expected: BUILD SUCCESSFUL + +- [ ] **Step 2: 构建插件** + +```bash +JAVA_HOME=/path/to/jdk17 ./gradlew buildWebview buildPlugin +``` + +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: 提交最终变更** + +```bash +git add -A && git commit -m "feat: layered error display with typed banners + +- OkHttpSseClient: ClassifiedError data class + classifyErrorType() +- BridgeHandler: notifyError(type, message) dual-arg +- ChatService: errorType propagated through abortStream and onError +- ErrorBanner: per-type styling (auth/quota/temp/generic) +- App.tsx: error action routing (openSettings for auth/quota, retry for temp) + +Closes #IMPLEMENTATION" +``` + +--- + +## 自检清单 + +- [ ] 所有 `abortStream` 调用处都传了 3 个参数(msgId, errorType, message) +- [ ] `ChatService` 中两处 `notifyError("...")` 改为 `notifyError("auth", "...")` +- [ ] `streamChat` 的 `onError` 回调参数从 `String` 改为 `ClassifiedError` +- [ ] `ErrorBanner` 不再使用 Ant Design Alert,纯 CSS 自渲染 +- [ ] `errorType` 和 `errorMessage` 两个 state 正确分离 +- [ ] 重试逻辑使用 `lastUserMessageRef` 记录并重发 +- [ ] `bridge.d.ts` 中 `onError` 签名为 `(type: string, message: string) => void` diff --git a/docs/superpowers/specs/2026-04-16-layered-error-display-design.md b/docs/superpowers/specs/2026-04-16-layered-error-display-design.md new file mode 100644 index 0000000..1eb9649 --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-layered-error-display-design.md @@ -0,0 +1,170 @@ +# 分层错误展示设计 + +## 背景 + +后端 `OkHttpSseClient` 已将 API 错误分为 4 类(Auth/Quota/Temp/Generic),但统一通过 `notifyError(message: String)` 传给前端,前端只用一个红色 ErrorBanner 展示。用户无法判断该「改设置」还是「等一下」。 + +## 错误分类 + +| 类型 | 判断依据 | 标签 | 配色 | 按钮 | +|------|----------|------|------|------| +| `auth` 配置错误 | HTTP 401/403 | 🔐 配置错误 | 红色 #c0392b | 打开设置 | +| `quota` 配额错误 | QUOTA 语义关键词 | 💰 配额不足 | 橙色 #d4a017 | 打开设置 | +| `temp` 临时错误 | HTTP 429/503/529 + BUSY 语义 | ⏳ 临时错误 | 蓝色 #2980b9 | 重试 | +| `generic` 未知错误 | 其他所有情况 | ❓ 未知错误 | 灰色 #7f8c8d | 无 | + +## 架构变更 + +### Kotlin 后端 + +**1. `BridgeHandler.kt` — 修改 `notifyError` 签名** + +```kotlin +// 旧 +fun notifyError(message: String) = pushJS("window.__bridge.onError(${json.encodeToString(message)})") + +// 新 +fun notifyError(errorType: String, message: String) = + pushJS("window.__bridge.onError(${json.encodeToString(errorType)}, ${json.encodeToString(message)})") +``` + +**2. `BridgeStatusPayload` 增加字段** + +```kotlin +@Serializable +data class BridgeStatusPayload( + val providerName: String = "", + val model: String = "", + val connectionState: String = "unconfigured", + val lastErrorType: String? = null // 新增 +) +``` + +**3. `ChatService.kt` — `abortStream` 传递错误类型** + +`abortStream` 调用 `bridgeHandler?.notifyError(errorType, message)`,由调用处传入错误类型。 + +现有调用处(需要传递错误类型): +- `sendMessage` 中 provider/apiKey 检查 → `"auth"` +- `startStreamingRound` 中 `onError` 回调 → 从 SSE 错误解析得到类型 +- `prepareToolCallsForExecution` 中的 abort → `"generic"` + +**4. `OkHttpSseClient.kt` — 已有分类逻辑,暴露接口** + +`ErrorType` enum 和 `classifyError` 已是 private,需提升可见性或新增 public 方法: + +```kotlin +fun classifyErrorType(msg: String): String { + return when (classifyError(msg)) { + ErrorType.AUTH -> "auth" + ErrorType.QUOTA -> "quota" + ErrorType.RETRIABLE_BUSY -> "temp" + ErrorType.GENERIC -> "generic" + } +} +``` + +`buildErrorMessage` 返回值需同步带上类型信息,建议封装为 data class: + +```kotlin +data class ClassifiedError( + val type: String, // "auth" | "quota" | "temp" | "generic" + val message: String +) + +// streamChat 的 onError 回调改为 onError: (ClassifiedError) -> Unit +``` + +### TypeScript 前端 + +**1. `bridge.d.ts` — 更新类型** + +```typescript +export interface Bridge { + // ... + onError: (type: string, message: string) => void // 旧: onError: (message: string) => void + // ... +} + +export interface BridgeStatus { + // ... + lastErrorType?: string // 新增 +} +``` + +**2. `ErrorBanner.tsx` — 按 type 渲染不同样式** + +```typescript +interface ErrorBannerProps { + errorType: string // "auth" | "quota" | "temp" | "generic" + message: string + onClose: () => void + onAction?: () => void // "打开设置" | "重试" +} + +// 配色映射 +const ERROR_STYLES = { + auth: { bg: "#2d1a1a", border: "#c0392b", icon: "🔐", label: "配置错误" }, + quota: { bg: "#2d2416", border: "#d4a017", icon: "💰", label: "配额不足" }, + temp: { bg: "#1a2a3a", border: "#2980b9", icon: "⏳", label: "临时错误" }, + generic: { bg: "#2a2a2a", border: "#7f8c8d", icon: "❓", label: "未知错误" }, +} +``` + +**3. `App.tsx` — 更新 onError 回调** + +```typescript +const onError = useCallback((type: string, message: string) => { + setIsLoading(false) + setMessages(prev => + prev.map(item => item.isStreaming ? { ...item, isStreaming: false } : item) + ) + setError(type) // 存 type,message 通过其他方式传递 +}, []) + +// ErrorBanner 接收 errorType +{error && setError(null)} />} + +// 需要新增 state: errorMessage: string | null +``` + +**4. ProviderBar 内联状态(可选增强)** + +当 `status.lastErrorType` 存在时,ProviderBar 右侧显示错误类型 tag: + +```typescript +{status.lastErrorType && ( + + {ERROR_LABELS[status.lastErrorType]} + +)} +``` + +## 数据流 + +``` +OkHttpSseClient.classifyErrorType(msg) + ↓ ClassifiedError(type, message) +ChatService.abortStream(msgId, errorType, errorMessage) + ↓ +BridgeHandler.notifyError(errorType, message) + ↓ pushJS +window.__bridge.onError(type, message) + ↓ +App.tsx onError(type, message) + ↓ +ErrorBanner errorType={type} message={message} +``` + +## 测试要点 + +- HTTP 401/403 → 显示红色"配置错误",点击"打开设置"跳转 Settings +- 配额错误(insufficient_quota)→ 显示橙色"配额不足",点击"打开设置" +- HTTP 429/503/529 → 显示蓝色"临时错误",点击"重试"重新发送上一条消息 +- 网络超时/ConnectException → 显示蓝色"临时错误"(属于 BUSY 类型) +- 未知错误 → 显示灰色"未知错误",无按钮 + +## 未纳入 + +- ProviderBar 错误 tag(可作为后续增强) +- 错误自动重试逻辑(重试按钮仅重新触发发送,用户主动操作) diff --git a/src/main/kotlin/com/github/codeplangui/BridgeHandler.kt b/src/main/kotlin/com/github/codeplangui/BridgeHandler.kt index 45f95ff..1a245dd 100644 --- a/src/main/kotlin/com/github/codeplangui/BridgeHandler.kt +++ b/src/main/kotlin/com/github/codeplangui/BridgeHandler.kt @@ -146,7 +146,7 @@ class BridgeHandler( } is BridgePayloadHandlingResult.CommandError -> { logger.warn(result.message, result.cause) - notifyError(result.message) + notifyError("generic", result.message) null } } @@ -156,6 +156,7 @@ class BridgeHandler( override fun onLoadEnd(browser: CefBrowser, frame: CefFrame, httpStatusCode: Int) { if (frame.isMain) { isReady = true + logger.warn("[CodePlanGUI Bridge] onLoadEnd fired, isReady=true, setting up window.__bridge") chatService.attachBridge(this@BridgeHandler) val js = """ window.__bridge = { @@ -225,11 +226,14 @@ class BridgeHandler( fun notifyStart(msgId: String) = pushJS("window.__bridge.onStart(${msgId.quoted()})") - fun notifyToken(token: String) = pushJS("window.__bridge.onToken(${json.encodeToString(token)})") + fun notifyToken(token: String) = pushJS("window.__bridge.onToken(${token.quoted()})") fun notifyEnd(msgId: String) = pushJS("window.__bridge.onEnd(${msgId.quoted()})") - fun notifyError(message: String) = pushJS("window.__bridge.onError(${json.encodeToString(message)})") + fun notifyError(errorType: String, message: String) { + logger.warn("[CodePlanGUI Bridge] notifyError: type=$errorType, message=${message.take(80)}") + pushJS("window.__bridge.onError(${errorType.quoted()}, ${message.quoted()})") + } fun notifyStructuredError(error: BridgeErrorPayload) = pushJS("window.__bridge.onStructuredError(${json.encodeToString(error)})") @@ -238,10 +242,10 @@ class BridgeHandler( pushJS("window.__bridge.onStatus(${json.encodeToString(status)})") fun notifyContextFile(fileName: String) = - pushJS("window.__bridge.onContextFile(${json.encodeToString(fileName)})") + pushJS("window.__bridge.onContextFile(${fileName.quoted()})") fun notifyTheme(theme: String) = - pushJS("window.__bridge.onTheme(${json.encodeToString(theme)})") + pushJS("window.__bridge.onTheme(${theme.quoted()})") fun notifyLog(msgId: String, logLine: String, type: String) = pushJS( @@ -286,7 +290,7 @@ class BridgeHandler( } fun notifyRestoreMessages(messages: String) = - pushJS("window.__bridge.onRestoreMessages(${json.encodeToString(messages)})") + pushJS("window.__bridge.onRestoreMessages($messages)") fun notifyContinuation(current: Int, max: Int) = pushJS("window.__bridge.onContinuation($current, $max)") diff --git a/src/main/kotlin/com/github/codeplangui/ChatService.kt b/src/main/kotlin/com/github/codeplangui/ChatService.kt index e23f2d7..da3424d 100644 --- a/src/main/kotlin/com/github/codeplangui/ChatService.kt +++ b/src/main/kotlin/com/github/codeplangui/ChatService.kt @@ -1,6 +1,7 @@ package com.github.codeplangui import com.github.codeplangui.api.OkHttpSseClient +import com.github.codeplangui.api.ClassifiedError import com.github.codeplangui.api.ToolCallAccumulator import com.github.codeplangui.api.ToolCallDelta import com.github.codeplangui.api.ToolDefinition @@ -117,22 +118,14 @@ class ChatService(private val project: Project) : Disposable { val provider = settings.getActiveProvider() if (provider == null) { publishStatus() - bridgeHandler?.notifyStructuredError(BridgeErrorPayload( - type = "config", - message = "请先在 Settings > Tools > CodePlanGUI 中配置 API Provider", - action = "openSettings" - )) + bridgeHandler?.notifyError("auth", "请先在 Settings > Tools > CodePlanGUI 中配置 API Provider") return } val apiKey = ApiKeyStore.load(provider.id) ?: "" if (apiKey.isBlank()) { publishStatus() - bridgeHandler?.notifyStructuredError(BridgeErrorPayload( - type = "config", - message = "API Key 未设置或未保存,请在 Settings 中重新配置并点 Apply/OK", - action = "openSettings" - )) + bridgeHandler?.notifyError("auth", "API Key 未设置或未保存,请在 Settings 中重新配置并点 Apply/OK") return } @@ -391,19 +384,16 @@ $selection } /** Terminates an in-progress stream with an error and resets all state, preventing a permanent stuck spinner. */ - private fun abortStream(msgId: String, errorMessage: String) { + private fun abortStream(msgId: String, errorType: String, errorMessage: String) { if (activeMessageId != msgId) return - logger.warn("[CodePlanGUI Approval] aborting stream msgId=$msgId error=${errorMessage.summarizeForLog(240)}") + logger.warn("[CodePlanGUI Approval] aborting stream msgId=$msgId type=$errorType error=${errorMessage.summarizeForLog(240)}") activeStream?.cancel() activeStream = null activeMessageId = null bridgeNotifiedStart.remove(msgId) resetToolCallState() publishStatus() - bridgeHandler?.notifyStructuredError(BridgeErrorPayload( - type = "runtime", - message = errorMessage - )) + bridgeHandler?.notifyError(errorType, errorMessage) } private fun startStreamingRound(msgId: String, request: okhttp3.Request, toolsEnabled: Boolean) { @@ -448,14 +438,14 @@ $selection bridgeHandler?.notifyEnd(msgId) } }, - onError = { message -> + onError = { classifiedError -> if (activeMessageId == msgId) { - logger.warn("[CodePlanGUI Approval] model round failed msgId=$msgId error=$message") + logger.warn("[CodePlanGUI Approval] model round failed msgId=$msgId type=${classifiedError.type} error=${classifiedError.message}") activeStream = null activeMessageId = null bridgeNotifiedStart.remove(msgId) publishStatus() - bridgeHandler?.notifyStructuredError(classifyStreamError(message)) + bridgeHandler?.notifyError(classifiedError.type, classifiedError.message) } }, onToolCallChunk = { delta -> @@ -545,7 +535,7 @@ $selection private fun prepareToolCallsForExecution(msgId: String): List? { val accumulatedToolCalls = toolCallAccumulator.snapshot() if (accumulatedToolCalls.isEmpty()) { - abortStream(msgId, "AI sent a tool_calls finish_reason but no tool call deltas were captured") + abortStream(msgId, "generic", "AI sent a tool_calls finish_reason but no tool call deltas were captured") return null } @@ -553,6 +543,7 @@ $selection val toolCallId = accumulated.id ?: run { abortStream( msgId, + "generic", "AI sent a tool_calls finish_reason but tool call index ${accumulated.index} had no id" ) return null @@ -561,11 +552,11 @@ $selection val argsObj = try { kotlinx.serialization.json.Json.parseToJsonElement(argsJson).jsonObject } catch (_: Exception) { - abortStream(msgId, "AI returned malformed tool call arguments for index ${accumulated.index}: '$argsJson'") + abortStream(msgId, "generic", "AI returned malformed tool call arguments for index ${accumulated.index}: '$argsJson'") return null } val command = argsObj["command"]?.jsonPrimitive?.contentOrNull ?: run { - abortStream(msgId, "AI tool call index ${accumulated.index} is missing required 'command' field") + abortStream(msgId, "generic", "AI tool call index ${accumulated.index} is missing required 'command' field") return null } val description = argsObj["description"]?.jsonPrimitive?.contentOrNull ?: "" diff --git a/src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt b/src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt index 49f7a48..7c1e801 100644 --- a/src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt +++ b/src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt @@ -86,6 +86,15 @@ sealed class TestResult { data class Failure(val message: String) : TestResult() } +/** + * Classified API error with type tag for frontend differentiation. + * type: "auth" | "quota" | "temp" | "generic" + */ +data class ClassifiedError( + val type: String, + val message: String +) + private const val STREAM_DEBUG_MAX_LENGTH = 1200 internal fun summarizeInterestingSseFrame(id: String?, type: String?, data: String): String? { @@ -176,7 +185,7 @@ class OkHttpSseClient( request: Request, onToken: (String) -> Unit, onEnd: () -> Unit, - onError: (String) -> Unit, + onError: (ClassifiedError) -> Unit, onToolCallChunk: (ToolCallDelta) -> Unit = {}, onFinishReason: (String) -> Unit = {} ): EventSource { @@ -210,7 +219,8 @@ class OkHttpSseClient( } else { buildErrorMessage(response, t, responseBody = peeked) } - onError(msg) + val errorType = classifyErrorType(msg) + onError(ClassifiedError(type = errorType, message = msg)) } } return eventSourceFactory.newEventSource(request, listener) @@ -234,7 +244,7 @@ class OkHttpSseClient( if (cont.isActive) cont.resume(Result.success(accumulated.toString())) }, onError = { error -> - if (cont.isActive) cont.resume(Result.failure(Exception(error))) + if (cont.isActive) cont.resume(Result.failure(Exception(error.message))) } ) cont.invokeOnCancellation { source.cancel() } @@ -322,6 +332,18 @@ class OkHttpSseClient( } } + /** + * Classifies a raw error message into a typed error for frontend display. + */ + fun classifyErrorType(rawMessage: String): String { + return when { + QUOTA_PATTERNS.any { it in rawMessage.lowercase() } -> "quota" + AUTH_PATTERNS.any { it in rawMessage.lowercase() } -> "auth" + BUSY_PATTERNS.any { it in rawMessage.lowercase() } -> "temp" + else -> "generic" + } + } + /** * Checks if a response body indicates an error despite HTTP 200. * Handles providers like GLM/Doubao/Qianwen that wrap errors as: diff --git a/src/main/resources/webview/index.html b/src/main/resources/webview/index.html index 544c6aa..aeb1fe7 100644 --- a/src/main/resources/webview/index.html +++ b/src/main/resources/webview/index.html @@ -1,10 +1,10 @@ - - - - - - CodePlanGUI - - - - -
- - +*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#79c0ff}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-comment,.hljs-code,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}:root,.theme-dark{color-scheme:dark;--bg: #0f1115;--bg-elevated: rgba(22, 24, 30, .86);--panel: rgba(29, 31, 38, .84);--panel-strong: rgba(40, 30, 20, .72);--border: rgba(232, 214, 190, .12);--border-strong: rgba(214, 164, 95, .28);--text: #f2eadf;--muted: #bcae9a;--accent: #d2a15e;--accent-strong: #f0c488;--danger: #ff8a75;--shadow: 0 18px 48px rgba(0, 0, 0, .35);--font-body: "Avenir Next", "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;--font-mono: "SFMono-Regular", "JetBrains Mono", "Menlo", monospace;--input-bg: rgba(22, 24, 30, .92);--input-border: rgba(232, 214, 190, .14);--input-placeholder: rgba(188, 174, 154, .68);--input-focus-border: rgba(240, 196, 136, .38);--input-focus-ring: rgba(210, 161, 94, .09);--gradient-bg: linear-gradient(180deg, #14171c 0%, #0f1115 100%);--bar-bg: linear-gradient(180deg, rgba(23, 25, 31, .92), rgba(16, 18, 24, .88));--input-area-bg: linear-gradient(180deg, rgba(16, 18, 24, .82), rgba(12, 14, 19, .94));--assistant-bubble-bg: linear-gradient(180deg, rgba(29, 31, 38, .94), rgba(17, 19, 24, .92));--pre-bg: rgba(7, 8, 11, .88);--pre-border: rgba(240, 196, 136, .12);--card-bg: linear-gradient(180deg, rgba(35, 28, 23, .54), rgba(16, 18, 24, .88));--send-button-bg: linear-gradient(135deg, #b98346, #87552b);--send-button-shadow: 0 10px 24px rgba(89, 47, 17, .28);--send-button-disabled-bg: rgba(70, 72, 80, .65);--send-button-disabled-border: rgba(255, 255, 255, .08)}.theme-light{color-scheme:light;--bg: #f5f5f5;--bg-elevated: rgba(255, 255, 255, .9);--panel: rgba(255, 255, 255, .85);--panel-strong: rgba(245, 240, 230, .8);--border: rgba(0, 0, 0, .08);--border-strong: rgba(210, 161, 94, .35);--text: #1a1a1a;--muted: #666666;--accent: #b98346;--accent-strong: #87552b;--danger: #d94e41;--shadow: 0 18px 48px rgba(0, 0, 0, .12);--input-bg: rgba(255, 255, 255, .95);--input-border: rgba(135, 85, 43, .18);--input-placeholder: rgba(102, 102, 102, .6);--input-focus-border: rgba(135, 85, 43, .35);--input-focus-ring: rgba(185, 131, 70, .14);--gradient-bg: linear-gradient(180deg, #fafafa 0%, #f5f5f5 100%);--bar-bg: linear-gradient(180deg, rgba(250, 250, 252, .97), rgba(242, 242, 246, .95));--input-area-bg: linear-gradient(180deg, rgba(248, 248, 250, .95), rgba(240, 240, 244, .98));--assistant-bubble-bg: linear-gradient(180deg, rgba(255, 255, 255, .95), rgba(248, 248, 250, .92));--pre-bg: rgba(236, 236, 240, .9);--pre-border: rgba(180, 130, 70, .15);--card-bg: linear-gradient(180deg, rgba(255, 255, 255, .85), rgba(242, 242, 246, .9));--send-button-bg: linear-gradient(135deg, #d5ae77, #b98346);--send-button-shadow: 0 10px 20px rgba(185, 131, 70, .18);--send-button-disabled-bg: rgba(205, 205, 210, .88);--send-button-disabled-border: rgba(120, 120, 128, .12)}*{box-sizing:border-box}html,body,#root{margin:0;min-height:100%;height:100%}body{overflow:hidden;font-family:var(--font-body);font-size:13px;color:var(--text);background:radial-gradient(circle at top left,rgba(210,161,94,.12),transparent 32%),radial-gradient(circle at bottom right,rgba(125,72,43,.14),transparent 30%),var(--gradient-bg)}body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.1;background-image:linear-gradient(rgba(255,255,255,.04) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.04) 1px,transparent 1px);background-size:36px 36px;mask-image:linear-gradient(180deg,rgba(255,255,255,.75),transparent)}.app-shell{display:flex;flex-direction:column;height:100vh;position:relative}.provider-bar{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:18px 18px 14px;border-bottom:1px solid var(--border);backdrop-filter:blur(16px);background:var(--bar-bg)}.provider-eyebrow,.assistant-bubble-label,.context-caption,.empty-kicker{text-transform:uppercase;letter-spacing:.18em;font-size:10px;color:var(--muted)}.provider-title{margin:4px 0 0!important;color:var(--text)!important;font-size:18px!important;font-weight:600!important}.provider-meta{display:block;margin-top:6px;color:var(--muted)!important}.provider-context{display:block;margin-top:4px;color:var(--accent)!important;font-size:11px}.provider-actions{display:flex;gap:8px}.provider-action.ant-btn{color:var(--accent)!important;border:1px solid var(--border-strong);border-radius:999px;background:#d2a15e14}.provider-action.ant-btn:hover{color:var(--accent-strong)!important;border-color:#f0c48873!important;background:#d2a15e24!important}.messages-area{flex:1;overflow-y:auto;padding:16px 18px 12px;scroll-behavior:smooth}.empty-state{min-height:100%;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px}.empty-card{width:min(100%,420px);padding:28px 24px;border-radius:28px;border:1px solid var(--border);background:var(--card-bg),var(--bg-elevated);box-shadow:var(--shadow)}.empty-icon{width:74px;height:74px;margin:0 auto 16px;display:grid;place-items:center;border-radius:50%;border:1px solid rgba(210,161,94,.28);background:radial-gradient(circle,#d2a15e33,#d2a15e08);color:var(--accent-strong);font-size:28px}.empty-title{margin:8px 0!important;color:var(--text)!important}.empty-copy{color:var(--muted);line-height:1.8}.message-row{display:flex;margin-bottom:16px}.message-row-user{justify-content:flex-end}.message-row-assistant{justify-content:flex-start}.message-bubble{max-width:min(88%,720px);border-radius:22px;border:1px solid var(--border);box-shadow:var(--shadow)}.message-bubble-user{padding:13px 15px;background:linear-gradient(135deg,#d2a15e3d,#7345266b),var(--panel-strong);border-radius:22px 22px 8px}.message-bubble-user .ant-typography{color:var(--text);white-space:pre-wrap;word-break:break-word}.message-bubble-assistant{width:100%;padding:14px 16px 16px;background:var(--assistant-bubble-bg),var(--panel)}.assistant-bubble-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}.bubble-copy-button.ant-btn{color:var(--muted)!important}.bubble-copy-button.ant-btn:hover{color:var(--accent-strong)!important;background:#d2a15e14!important}.assistant-markdown{line-height:1.75;color:var(--text);word-break:break-word}.assistant-markdown h1,.assistant-markdown h2,.assistant-markdown h3{font-weight:600;letter-spacing:.02em}.assistant-markdown p,.assistant-markdown ul,.assistant-markdown ol{margin:0 0 .9em}.assistant-markdown a{color:var(--accent-strong)}.assistant-markdown pre{margin:1.1em 0;padding:14px 14px 16px;overflow-x:auto;border-radius:18px;border:1px solid var(--pre-border);background:var(--pre-bg);position:relative}.assistant-markdown code{font-family:var(--font-mono)}.assistant-markdown code:not(pre code){padding:.15em .45em;border-radius:999px;background:#d2a15e1f;color:var(--accent-strong)}.bubble-copy-anchor{position:absolute;top:10px;right:10px}.bubble-copy-fallback{border:1px solid rgba(240,196,136,.18);border-radius:999px;background:#22242deb;color:var(--muted);font-family:var(--font-body);font-size:11px;padding:4px 9px;cursor:pointer}.bubble-copy-fallback:hover{color:var(--accent-strong)}.stream-cursor{display:inline-block;width:8px;height:14px;margin-left:4px;border-radius:3px;background:var(--accent);animation:blink .7s infinite;vertical-align:text-bottom}.continuation-indicator{display:flex;align-items:center;gap:8px;padding:8px 16px;color:var(--muted);font-size:12px}.continuation-text{color:var(--muted);font-size:12px}.continuation-spinner{display:inline-block;width:12px;height:12px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.input-area{display:flex;flex-direction:column;gap:10px;padding:14px 16px 18px;border-top:1px solid var(--border);background:var(--input-area-bg)}.input-meta{display:flex;align-items:center;justify-content:flex-start;gap:12px}.context-toggle{display:inline-flex;align-items:center;gap:10px}.context-caption{font-size:10px}.context-file-label{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--accent)}.composer-row{display:flex;align-items:flex-end;gap:10px}.composer-input{flex:1;resize:none;min-height:44px;max-height:120px;padding:11px 13px;border-radius:18px;border:1px solid var(--input-border);background:var(--input-bg);color:var(--text);font:inherit;line-height:1.6;outline:none;box-shadow:inset 0 1px #ffffff0a;caret-color:var(--accent-strong);cursor:text}.composer-input:focus{border-color:var(--input-focus-border);box-shadow:0 0 0 4px var(--input-focus-ring)}.composer-input::placeholder{color:var(--input-placeholder)}.send-button.ant-btn{height:44px;border-radius:16px;border:1px solid rgba(240,196,136,.28);background:var(--send-button-bg)!important;box-shadow:var(--send-button-shadow)}.send-button.ant-btn:disabled{background:var(--send-button-disabled-bg)!important;border-color:var(--send-button-disabled-border)!important;box-shadow:none}@keyframes blink{0%,to{opacity:1}50%{opacity:0}}.exec-card{max-width:min(88%,720px);border-radius:14px;border:1px solid var(--border);background:var(--assistant-bubble-bg),var(--panel);padding:10px 14px;font-size:13px}.exec-card-header{margin-bottom:6px}.exec-card-command{font-size:12px!important}.exec-log-panel{margin-top:8px;border-radius:10px;border:1px solid var(--border);background:var(--pre-bg);overflow:hidden}.exec-log-header{display:flex;align-items:center;gap:8px;padding:5px 12px;cursor:pointer;user-select:none;font-size:11px;color:var(--muted);border-bottom:1px solid var(--border)}.exec-log-header:hover{color:var(--accent-strong)}.exec-log-title{font-family:var(--font-mono);letter-spacing:.02em}.exec-log-body{max-height:220px;overflow-y:auto;padding:6px 12px;font-family:var(--font-mono);font-size:11px;line-height:1.6}.exec-log-line{white-space:pre-wrap;word-break:break-all;padding:1px 0}.exec-log-stdout{color:var(--text);opacity:.85}.exec-log-stderr{color:var(--danger)}.exec-log-info{color:var(--accent);opacity:.9} + + +
+ + diff --git a/src/test/kotlin/com/github/codeplangui/OkHttpSseClientTest.kt b/src/test/kotlin/com/github/codeplangui/OkHttpSseClientTest.kt index aa505a1..bbed47f 100644 --- a/src/test/kotlin/com/github/codeplangui/OkHttpSseClientTest.kt +++ b/src/test/kotlin/com/github/codeplangui/OkHttpSseClientTest.kt @@ -1,5 +1,6 @@ package com.github.codeplangui +import com.github.codeplangui.api.ClassifiedError import com.github.codeplangui.api.FunctionDefinition import com.github.codeplangui.api.OkHttpSseClient import com.github.codeplangui.api.TestResult @@ -170,7 +171,7 @@ class OkHttpSseClientTest { val client = OkHttpSseClient(eventSourceFactory = factory) val tokens = mutableListOf() var ended = false - var error: String? = null + var error: ClassifiedError? = null val source = client.streamChat( request = simpleRequest(), @@ -196,7 +197,7 @@ class OkHttpSseClientTest { fun `streamChat forwards mapped error messages`() { val factory = FakeEventSourceFactory() val client = OkHttpSseClient(eventSourceFactory = factory) - var error: String? = null + var error: ClassifiedError? = null val source = client.streamChat( request = simpleRequest(), @@ -211,7 +212,7 @@ class OkHttpSseClientTest { responseFor(simpleRequest(), 404, "missing") ) - assertEquals("HTTP 404:endpoint 路径不正确(应包含 /v1)", error) + assertEquals("HTTP 404:endpoint 路径不正确(应包含 /v1)", error?.message) } @Test diff --git a/webview/src/App.css b/webview/src/App.css index 8e58f95..c4c292f 100644 --- a/webview/src/App.css +++ b/webview/src/App.css @@ -169,13 +169,6 @@ body::before { background: rgba(210, 161, 94, 0.14) !important; } -.error-banner.ant-alert { - margin: 12px 16px 0; - border-radius: 16px; - background: rgba(91, 24, 20, 0.78); - border: 1px solid rgba(255, 138, 117, 0.25); -} - .messages-area { flex: 1; overflow-y: auto; diff --git a/webview/src/App.tsx b/webview/src/App.tsx index ea5d9c7..afd30cc 100644 --- a/webview/src/App.tsx +++ b/webview/src/App.tsx @@ -23,7 +23,8 @@ export default function App() { const isComposingRef = useRef(false) const [isLoading, setIsLoading] = useState(false) const [includeContext, setIncludeContext] = useState(true) - const [error, setError] = useState(null) + const [errorType, setErrorType] = useState(null) + const [errorMessage, setErrorMessage] = useState(null) const [status, setStatus] = useState({ providerName: '', model: '', @@ -32,6 +33,7 @@ export default function App() { }) const [themeMode, setThemeMode] = useState<'dark' | 'light'>('dark') const messagesEndRef = useRef(null) + const lastUserMessageRef = useRef<{ text: string; includeContext: boolean } | null>(null) const [approvalOpen, setApprovalOpen] = useState(false) const [approvalRequestId, setApprovalRequestId] = useState('') const [approvalCommand, setApprovalCommand] = useState('') @@ -54,7 +56,8 @@ export default function App() { const onStart = useCallback((msgId: string) => { setIsLoading(true) - setError(null) + setErrorType(null) + setErrorMessage(null) setMessages((prev) => [ ...prev, { id: msgId, role: 'assistant', content: '', isStreaming: true }, @@ -77,13 +80,15 @@ export default function App() { ) }, []) - const onError = useCallback((message: string) => { + const onError = useCallback((type: string, message: string) => { + console.log('[CodePlanGUI] onError called:', { type, message }) setIsLoading(false) setContinuationInfo(null) setMessages((prev) => prev.map((item) => (item.isStreaming ? { ...item, isStreaming: false } : item)), ) - setError({ type: 'runtime', message }) + setErrorType(type) + setErrorMessage(message) }, []) const onStructuredError = useCallback((bridgeError: BridgeError) => { @@ -91,7 +96,8 @@ export default function App() { setMessages((prev) => prev.map((item) => (item.isStreaming ? { ...item, isStreaming: false } : item)), ) - setError(bridgeError) + setErrorType(bridgeError.type) + setErrorMessage(bridgeError.message) }, []) const onContextFile = useCallback((fileName: string) => { @@ -233,7 +239,8 @@ export default function App() { // Clear stale errors when the bridge reconnects (e.g., after webview reload) useEffect(() => { if (bridgeReady) { - setError(null) + setErrorType(null) + setErrorMessage(null) } }, [bridgeReady]) @@ -248,7 +255,10 @@ export default function App() { const handleSend = () => { if (!composerReadiness.canSend) { if (composerReadiness.reason && composerReadiness.text) { - setError({ type: 'runtime', message: composerReadiness.reason! }) + // When connectionState is 'error' (API key missing), use 'auth' type to show "打开设置" button + const effectiveErrorType = status.connectionState === 'error' ? 'auth' : 'generic' + setErrorType(effectiveErrorType) + setErrorMessage(composerReadiness.reason!) } return } @@ -257,6 +267,7 @@ export default function App() { if (!payload) return const userMsgId = uuidv4() + lastUserMessageRef.current = { text: payload.text, includeContext } setMessages((prev) => [...prev, { id: userMsgId, role: 'user', content: payload.text }]) setInputText('') // loading + error are set in onStart (source of truth), which fires when the backend @@ -273,7 +284,8 @@ export default function App() { const handleNewChat = () => { setMessages([]) - setError(null) + setErrorType(null) + setErrorMessage(null) setIsLoading(false) window.__bridge?.newChat() } @@ -283,6 +295,19 @@ export default function App() { window.__bridge?.cancelStream() }, [isLoading]) + const handleErrorAction = useCallback(() => { + const type = errorType + if (type === 'auth' || type === 'quota') { + window.__bridge?.openSettings() + } else if (type === 'temp' && lastUserMessageRef.current) { + // Retry: re-send last user message + const msgToRetry = lastUserMessageRef.current + window.__bridge?.sendMessage(msgToRetry.text, msgToRetry.includeContext) + } + setErrorType(null) + setErrorMessage(null) + }, [errorType]) + // ESC key to cancel streaming useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { @@ -328,7 +353,15 @@ export default function App() { bridgeReady={bridgeReady} /> - {error && setError(null)} />} + + {errorType && errorMessage && ( + { setErrorType(null); setErrorMessage(null) }} + onAction={handleErrorAction} + /> + )}
{messages.length === 0 && ( diff --git a/webview/src/components/ErrorBanner.tsx b/webview/src/components/ErrorBanner.tsx index cf598c8..f8cb4d0 100644 --- a/webview/src/components/ErrorBanner.tsx +++ b/webview/src/components/ErrorBanner.tsx @@ -1,32 +1,55 @@ -import { Alert, Button, Space } from 'antd' -import type { BridgeError } from '../types/bridge' +import './ErrorBanner.css' interface Props { - error: BridgeError + errorType: 'auth' | 'quota' | 'temp' | 'generic' + message: string onClose: () => void + onAction?: () => void } -export function ErrorBanner({ error, onClose }: Props) { - const alertType = error.type === 'config' ? 'warning' : 'error' +const ERROR_CONFIG = { + auth: { + label: '配置错误', + icon: '🔐', + actionLabel: '打开设置', + }, + quota: { + label: '配额不足', + icon: '💰', + actionLabel: '打开设置', + }, + temp: { + label: '临时错误', + icon: '⏳', + actionLabel: '重试', + }, + generic: { + label: '未知错误', + icon: '❓', + actionLabel: null, + }, +} as const - const action = error.action === 'openSettings' ? ( - - ) : error.action === 'retry' ? ( - - ) : undefined +export function ErrorBanner({ errorType, message, onClose, onAction }: Props) { + const config = ERROR_CONFIG[errorType] ?? ERROR_CONFIG.generic return ( - {action} : undefined} - /> +
+ {config.icon} +
+
{config.label}
+
{message}
+
+
+ {config.actionLabel && onAction && ( + + )} + +
+
) } diff --git a/webview/src/hooks/useBridge.ts b/webview/src/hooks/useBridge.ts index 5cf12e8..3081634 100644 --- a/webview/src/hooks/useBridge.ts +++ b/webview/src/hooks/useBridge.ts @@ -5,7 +5,7 @@ interface BridgeCallbacks { onStart: (msgId: string) => void onToken: (token: string) => void onEnd: (msgId: string) => void - onError: (message: string) => void + onError: (type: string, message: string) => void onStructuredError: (error: BridgeError) => void onStatus: (status: BridgeStatus) => void onContextFile: (fileName: string) => void @@ -47,8 +47,10 @@ export function useBridge(callbacks: BridgeCallbacks) { useEffect(() => { const setup = () => { + console.log('[CodePlanGUI Bridge] setup called, window.__bridge exists:', !!window.__bridge) const currentCallbacks = callbacksRef.current if (!window.__bridge) { + console.log('[CodePlanGUI Bridge] Creating dummy bridge') window.__bridge = { isReady: false, sendMessage: () => {}, @@ -74,6 +76,7 @@ export function useBridge(callbacks: BridgeCallbacks) { onContinuation: currentCallbacks.onContinuation, } } else { + console.log('[CodePlanGUI Bridge] Overwriting existing bridge callbacks') window.__bridge.onStart = currentCallbacks.onStart window.__bridge.onToken = currentCallbacks.onToken window.__bridge.onEnd = currentCallbacks.onEnd diff --git a/webview/src/types/bridge.d.ts b/webview/src/types/bridge.d.ts index cf519c8..2e1817f 100644 --- a/webview/src/types/bridge.d.ts +++ b/webview/src/types/bridge.d.ts @@ -3,6 +3,7 @@ export interface BridgeStatus { model: string connectionState: 'unconfigured' | 'ready' | 'streaming' | 'error' contextFile?: string + lastErrorType?: string } export interface ExecutionResult { @@ -33,7 +34,7 @@ export interface Bridge { onStart: (msgId: string) => void onToken: (token: string) => void onEnd: (msgId: string) => void - onError: (message: string) => void + onError: (type: string, message: string) => void onStructuredError: (error: BridgeError) => void onStatus: (status: BridgeStatus) => void onContextFile: (fileName: string) => void From 3cb3d5466ccb517d82d5d69126159fc55696ea98 Mon Sep 17 00:00:00 2001 From: lgxwool Date: Mon, 20 Apr 2026 08:23:15 +0800 Subject: [PATCH 2/2] =?UTF-8?q?style(components):=20=E6=B7=BB=E5=8A=A0=20E?= =?UTF-8?q?rrorBanner=20=E7=BB=84=E4=BB=B6=E6=A0=B7=E5=BC=8F=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2026-04-16-layered-error-display-design.md | 150 +++++++++++------- docs/tech-spec.md | 24 ++- webview/src/components/ErrorBanner.css | 129 +++++++++++++++ 3 files changed, 241 insertions(+), 62 deletions(-) create mode 100644 webview/src/components/ErrorBanner.css diff --git a/docs/superpowers/specs/2026-04-16-layered-error-display-design.md b/docs/superpowers/specs/2026-04-16-layered-error-display-design.md index 1eb9649..bea10a0 100644 --- a/docs/superpowers/specs/2026-04-16-layered-error-display-design.md +++ b/docs/superpowers/specs/2026-04-16-layered-error-display-design.md @@ -6,12 +6,12 @@ ## 错误分类 -| 类型 | 判断依据 | 标签 | 配色 | 按钮 | +| 类型 | 判断依据 | 标签 | 图标 | 按钮 | |------|----------|------|------|------| -| `auth` 配置错误 | HTTP 401/403 | 🔐 配置错误 | 红色 #c0392b | 打开设置 | -| `quota` 配额错误 | QUOTA 语义关键词 | 💰 配额不足 | 橙色 #d4a017 | 打开设置 | -| `temp` 临时错误 | HTTP 429/503/529 + BUSY 语义 | ⏳ 临时错误 | 蓝色 #2980b9 | 重试 | -| `generic` 未知错误 | 其他所有情况 | ❓ 未知错误 | 灰色 #7f8c8d | 无 | +| `auth` 配置错误 | HTTP 401/403 | 配置错误 | 🔐 | 打开设置 | +| `quota` 配额错误 | QUOTA 语义关键词 | 配额不足 | 💰 | 打开设置 | +| `temp` 临时错误 | HTTP 429/503/529 + BUSY 语义 | 临时错误 | ⏳ | 重试 | +| `generic` 未知错误 | 其他所有情况 | 未知错误 | ❓ | 无 | ## 架构变更 @@ -28,19 +28,21 @@ fun notifyError(errorType: String, message: String) = pushJS("window.__bridge.onError(${json.encodeToString(errorType)}, ${json.encodeToString(message)})") ``` -**2. `BridgeStatusPayload` 增加字段** +同时新增 `notifyStructuredError` 支持结构化错误: ```kotlin @Serializable -data class BridgeStatusPayload( - val providerName: String = "", - val model: String = "", - val connectionState: String = "unconfigured", - val lastErrorType: String? = null // 新增 +data class BridgeErrorPayload( + val type: String, + val message: String, + val action: String? = null ) + +fun notifyStructuredError(error: BridgeErrorPayload) = + pushJS("window.__bridge.onStructuredError(${json.encodeToString(error)})") ``` -**3. `ChatService.kt` — `abortStream` 传递错误类型** +**2. `ChatService.kt` — `abortStream` 传递错误类型** `abortStream` 调用 `bridgeHandler?.notifyError(errorType, message)`,由调用处传入错误类型。 @@ -49,22 +51,7 @@ data class BridgeStatusPayload( - `startStreamingRound` 中 `onError` 回调 → 从 SSE 错误解析得到类型 - `prepareToolCallsForExecution` 中的 abort → `"generic"` -**4. `OkHttpSseClient.kt` — 已有分类逻辑,暴露接口** - -`ErrorType` enum 和 `classifyError` 已是 private,需提升可见性或新增 public 方法: - -```kotlin -fun classifyErrorType(msg: String): String { - return when (classifyError(msg)) { - ErrorType.AUTH -> "auth" - ErrorType.QUOTA -> "quota" - ErrorType.RETRIABLE_BUSY -> "temp" - ErrorType.GENERIC -> "generic" - } -} -``` - -`buildErrorMessage` 返回值需同步带上类型信息,建议封装为 data class: +**3. `OkHttpSseClient.kt` — 新增 ClassifiedError 和 classifyErrorType** ```kotlin data class ClassifiedError( @@ -72,6 +59,15 @@ data class ClassifiedError( val message: String ) +fun classifyErrorType(rawMessage: String): String { + return when { + QUOTA_PATTERNS.any { it in rawMessage.lowercase() } -> "quota" + AUTH_PATTERNS.any { it in rawMessage.lowercase() } -> "auth" + BUSY_PATTERNS.any { it in rawMessage.lowercase() } -> "temp" + else -> "generic" + } +} + // streamChat 的 onError 回调改为 onError: (ClassifiedError) -> Unit ``` @@ -83,9 +79,16 @@ data class ClassifiedError( export interface Bridge { // ... onError: (type: string, message: string) => void // 旧: onError: (message: string) => void + onStructuredError: (error: BridgeError) => void // 新增 // ... } +export interface BridgeError { + type: string + message: string + action?: string +} + export interface BridgeStatus { // ... lastErrorType?: string // 新增 @@ -95,23 +98,36 @@ export interface BridgeStatus { **2. `ErrorBanner.tsx` — 按 type 渲染不同样式** ```typescript -interface ErrorBannerProps { - errorType: string // "auth" | "quota" | "temp" | "generic" +interface Props { + errorType: 'auth' | 'quota' | 'temp' | 'generic' message: string onClose: () => void onAction?: () => void // "打开设置" | "重试" } -// 配色映射 -const ERROR_STYLES = { - auth: { bg: "#2d1a1a", border: "#c0392b", icon: "🔐", label: "配置错误" }, - quota: { bg: "#2d2416", border: "#d4a017", icon: "💰", label: "配额不足" }, - temp: { bg: "#1a2a3a", border: "#2980b9", icon: "⏳", label: "临时错误" }, - generic: { bg: "#2a2a2a", border: "#7f8c8d", icon: "❓", label: "未知错误" }, +const ERROR_CONFIG = { + auth: { label: '配置错误', icon: '🔐', actionLabel: '打开设置' }, + quota: { label: '配额不足', icon: '💰', actionLabel: '打开设置' }, + temp: { label: '临时错误', icon: '⏳', actionLabel: '重试' }, + generic: { label: '未知错误', icon: '❓', actionLabel: null }, } ``` -**3. `App.tsx` — 更新 onError 回调** +**样式实现**:使用 CSS class 区分类型,配合 CSS 变量实现主题适配: + +```css +.error-banner-auth { + background: rgba(210, 161, 94, 0.12); + border-color: rgba(210, 161, 94, 0.35); +} +.error-banner-quota { + background: rgba(212, 160, 23, 0.12); + border-color: rgba(212, 160, 23, 0.35); +} +/* ... */ +``` + +**3. `App.tsx` — 更新 onError 回调和重试逻辑** ```typescript const onError = useCallback((type: string, message: string) => { @@ -119,26 +135,33 @@ const onError = useCallback((type: string, message: string) => { setMessages(prev => prev.map(item => item.isStreaming ? { ...item, isStreaming: false } : item) ) - setError(type) // 存 type,message 通过其他方式传递 + setErrorType(type) + setErrorMessage(message) }, []) -// ErrorBanner 接收 errorType -{error && setError(null)} />} +const onStructuredError = useCallback((bridgeError: BridgeError) => { + setIsLoading(false) + setErrorType(bridgeError.type) + setErrorMessage(bridgeError.message) +}, []) -// 需要新增 state: errorMessage: string | null +const handleErrorAction = useCallback(() => { + const type = errorType + if (type === 'auth' || type === 'quota') { + window.__bridge?.openSettings() + } else if (type === 'temp' && lastUserMessageRef.current) { + // Retry: re-send last user message + const msgToRetry = lastUserMessageRef.current + window.__bridge?.sendMessage(msgToRetry.text, msgToRetry.includeContext) + } + setErrorType(null) + setErrorMessage(null) +}, [errorType]) ``` -**4. ProviderBar 内联状态(可选增强)** - -当 `status.lastErrorType` 存在时,ProviderBar 右侧显示错误类型 tag: +**4. `composerState.ts` — connectionState 为 'error' 时显示提示** -```typescript -{status.lastErrorType && ( - - {ERROR_LABELS[status.lastErrorType]} - -)} -``` +当 API Key 未设置时,`connectionState` 为 `'error'`,composer 区域显示"API Key 未设置或未保存,请在 Settings 中重新配置并应用",点击发送按钮会触发 `errorType='auth'` 的 ErrorBanner,显示"打开设置"按钮。 ## 数据流 @@ -156,13 +179,28 @@ App.tsx onError(type, message) ErrorBanner errorType={type} message={message} ``` +**结构化错误流**(SSE 流中分类错误): + +``` +OkHttpSseClient 分类错误 + ↓ BridgeErrorPayload(type, message, action) +BridgeHandler.notifyStructuredError(error) + ↓ pushJS +window.__bridge.onStructuredError(error) + ↓ +App.tsx onStructuredError(bridgeError) + ↓ +ErrorBanner(action 可触发 openSettings 或 retry) +``` + ## 测试要点 -- HTTP 401/403 → 显示红色"配置错误",点击"打开设置"跳转 Settings -- 配额错误(insufficient_quota)→ 显示橙色"配额不足",点击"打开设置" -- HTTP 429/503/529 → 显示蓝色"临时错误",点击"重试"重新发送上一条消息 -- 网络超时/ConnectException → 显示蓝色"临时错误"(属于 BUSY 类型) -- 未知错误 → 显示灰色"未知错误",无按钮 +- HTTP 401/403 → 显示"配置错误" 🔐,点击"打开设置"跳转 Settings +- 配额错误(insufficient_quota)→ 显示"配额不足" 💰,点击"打开设置" +- HTTP 429/503/529 → 显示"临时错误" ⏳,点击"重试"重新发送上一条消息 +- 网络超时/ConnectException → 显示"临时错误"(属于 BUSY 类型) +- API Key 未设置时点击发送 → 显示"配置错误",点击"打开设置" +- 未知错误 → 显示"未知错误" ❓,无按钮 ## 未纳入 diff --git a/docs/tech-spec.md b/docs/tech-spec.md index 8756da0..171dce9 100644 --- a/docs/tech-spec.md +++ b/docs/tech-spec.md @@ -86,7 +86,7 @@ webview/src/ ├── components/ │ ├── MessageBubble.tsx — 单条消息,含 Markdown 渲染 │ ├── ProviderBar.tsx — 顶栏:provider 名称 + 连接状态 + New Chat 按钮 -│ └── ErrorBanner.tsx — 顶部错误横幅,8s 自动消失或手动关闭 +│ └── ErrorBanner.tsx — 顶部错误横幅,按类型分层展示(配置错误/配额不足/临时错误/未知错误) ├── hooks/ │ └── useBridge.ts — 封装 window.__bridge,注册 token/error 回调 └── types/ @@ -174,7 +174,8 @@ fun notifyError(message: String) = pushJS("window.__bridge.onError(${Json.encode window.__bridge.onStart = (msgId: string) => { /* 新建 AI 消息气泡 */ } window.__bridge.onToken = (token: string) => { /* 追加 token 到当前 AI 消息 */ } window.__bridge.onEnd = (msgId: string) => { /* 移除闪烁光标,解锁输入框 */ } -window.__bridge.onError = (message: string) => { /* 显示 ErrorBanner */ } +window.__bridge.onError = (type: string, message: string) => { /* 显示 ErrorBanner,按 type 渲染不同样式 */ } +window.__bridge.onStructuredError = (error: BridgeError) => { /* 显示 ErrorBanner,支持 action 按钮 */ } ``` **Prohibited:** 不得用轮询(setInterval)从 Kotlin 侧拉取 token。必须使用 `executeJavaScript` 主动推送。 @@ -752,15 +753,26 @@ cd webview && npm run dev | 触发点 | 用户看到 | 恢复路径 | |---|---|---| -| 发送时未配置 Provider | ErrorBanner:"请先配置 API Provider" + 设置链接 | 点链接打开 Settings | -| API Key 错误(401) | ErrorBanner:"HTTP 401: 认证失败,请检查 API Key" | 输入框解锁,可重发 | -| 网络超时(readTimeout 60s) | ErrorBanner:"请求超时,请检查网络或 endpoint" | 输入框解锁,可重发 | -| endpoint 路径错误(404) | ErrorBanner:"HTTP 404: endpoint 路径可能有误(应包含 /v1)" | 输入框解锁 | +| 发送时未配置 Provider | ErrorBanner auth 类型:"请先配置 API Provider" + 打开设置按钮 | 点按钮打开 Settings | +| API Key 未设置或错误 | ErrorBanner auth 类型:"API Key 未设置或未保存,请在 Settings 中重新配置" + 打开设置按钮 | 点按钮打开 Settings | +| 网络超时(readTimeout 60s) | ErrorBanner temp 类型:"请求超时,请检查网络或 endpoint" + 重试按钮 | 点按钮重发 | +| endpoint 路径错误(404) | ErrorBanner auth 类型:"HTTP 404: endpoint 路径可能有误(应包含 /v1)" | 输入框解锁,可重发 | +| 配额不足 | ErrorBanner quota 类型:"配额不足" + 打开设置按钮 | 点按钮打开 Settings | +| 未知错误 | ErrorBanner generic 类型:"未知错误" + 无按钮 | 输入框解锁,可重发 | | JBR 不支持 JCEF | 静态 JLabel 说明,不崩溃 | 用户切换 JBR | | git diff --staged 失败 | Messages.showError:"无法读取 staged 改动,请确认在 git 仓库中" | 无需恢复 | | 未找到 Commit 对话框 | 消息写入剪贴板 + 通知"已复制" | 手动粘贴 | | JCEF 加载 HTML 失败 | ErrorBanner(初始化时):"前端加载失败,请重启 IDE" | 重启 | +**ErrorBanner 分层类型:** + +| 类型 | 标签 | 按钮 | +|---|---|---| +| auth | 配置错误 | 打开设置 | +| quota | 配额不足 | 打开设置 | +| temp | 临时错误 | 重试 | +| generic | 未知错误 | 无 | + --- ## 7. Open Design Questions(在实现前解答) diff --git a/webview/src/components/ErrorBanner.css b/webview/src/components/ErrorBanner.css new file mode 100644 index 0000000..0ae8c99 --- /dev/null +++ b/webview/src/components/ErrorBanner.css @@ -0,0 +1,129 @@ +/* Error Banner - matches plugin theme variables */ + +.error-banner { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 10px 16px 0; + padding: 12px 14px; + border-radius: 16px; + border: 1px solid; + backdrop-filter: blur(12px); +} + +.error-banner-auth { + background: rgba(210, 161, 94, 0.12); + border-color: rgba(210, 161, 94, 0.35); +} + +.error-banner-quota { + background: rgba(212, 160, 23, 0.12); + border-color: rgba(212, 160, 23, 0.35); +} + +.error-banner-temp { + background: rgba(41, 128, 185, 0.12); + border-color: rgba(41, 128, 185, 0.35); +} + +.error-banner-generic { + background: rgba(188, 174, 154, 0.08); + border-color: rgba(188, 174, 154, 0.2); +} + +.error-banner-icon { + font-size: 18px; + line-height: 1.4; + flex-shrink: 0; +} + +.error-banner-content { + flex: 1; + min-width: 0; +} + +.error-banner-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 3px; +} + +.error-banner-auth .error-banner-label { color: var(--accent-strong, #f0c488); } +.error-banner-quota .error-banner-label { color: #d4a017; } +.error-banner-temp .error-banner-label { color: #5dade2; } +.error-banner-generic .error-banner-label { color: var(--muted, #bcae9a); } + +.error-banner-message { + font-size: 12px; + color: var(--text, #f2eadf); + opacity: 0.85; + line-height: 1.5; +} + +.error-banner-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.error-banner-action { + padding: 5px 14px; + border-radius: 20px; + border: 1px solid; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + font-family: inherit; +} + +.error-banner-auth .error-banner-action { + background: linear-gradient(135deg, rgba(210, 161, 94, 0.3), rgba(185, 131, 70, 0.3)); + border-color: rgba(210, 161, 94, 0.5); + color: var(--accent-strong, #f0c488); +} + +.error-banner-auth .error-banner-action:hover { + background: linear-gradient(135deg, rgba(210, 161, 94, 0.45), rgba(185, 131, 70, 0.45)); + border-color: rgba(210, 161, 94, 0.7); +} + +.error-banner-quota .error-banner-action { + background: rgba(212, 160, 23, 0.2); + border-color: rgba(212, 160, 23, 0.4); + color: #d4a017; +} + +.error-banner-quota .error-banner-action:hover { + background: rgba(212, 160, 23, 0.35); +} + +.error-banner-temp .error-banner-action { + background: rgba(41, 128, 185, 0.2); + border-color: rgba(41, 128, 185, 0.4); + color: #5dade2; +} + +.error-banner-temp .error-banner-action:hover { + background: rgba(41, 128, 185, 0.35); +} + +.error-banner-close { + background: transparent; + border: none; + color: var(--muted, #bcae9a); + cursor: pointer; + font-size: 14px; + padding: 4px 6px; + border-radius: 8px; + transition: all 0.15s ease; + line-height: 1; +} + +.error-banner-close:hover { + color: var(--text, #f2eadf); + background: rgba(255, 255, 255, 0.08); +}