Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ test {

tasks.register('installWebviewDependencies', Exec) {
workingDir 'webview'
commandLine 'npm', 'ci'
commandLine 'npm.cmd', 'ci'
inputs.file('webview/package.json')
inputs.file('webview/package-lock.json')
outputs.dir('webview/node_modules')
Expand All @@ -60,7 +60,7 @@ tasks.register('installWebviewDependencies', Exec) {
tasks.register('buildWebview', Exec) {
dependsOn 'installWebviewDependencies'
workingDir 'webview'
commandLine 'npm', 'run', 'build'
commandLine 'npm.cmd', 'run', 'build'
inputs.file('webview/index.html')
inputs.dir('webview/src')
inputs.file('webview/package.json')
Expand Down
6 changes: 6 additions & 0 deletions src/main/kotlin/com/github/codeplangui/ChatService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,12 @@ $selection
lowerMsg.contains("api key") || lowerMsg.contains("unauthorized") ->
BridgeErrorPayload(type = "config", message = message, action = "openSettings")

lowerMsg.contains("insufficient_quota") || lowerMsg.contains("quota") ||
lowerMsg.contains("billing") || lowerMsg.contains("credit") ||
lowerMsg.contains("payment") || lowerMsg.contains("余额不足") ||
lowerMsg.contains("超出限额") || lowerMsg.contains("额度不足") || lowerMsg.contains("欠费") ->
BridgeErrorPayload(type = "quota", message = message, action = "openSettings")

lowerMsg.contains("timeout") || lowerMsg.contains("超时") ||
lowerMsg.contains("无法连接") || lowerMsg.contains("connectexception") ||
lowerMsg.contains("http 5") || lowerMsg.contains("http 429") ->
Expand Down
174 changes: 86 additions & 88 deletions src/main/resources/webview/index.html

Large diffs are not rendered by default.

28 changes: 26 additions & 2 deletions webview/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export default function App() {
const isComposingRef = useRef(false)
const [includeContext, setIncludeContext] = useState(true)
const messagesEndRef = useRef<HTMLDivElement>(null)
const lastUserMessageRef = useRef<{ text: string; includeContext: boolean } | null>(null)

const { groups, isLoading, error, status, themeMode, approvalOpen, approvalRequestId, approvalCommand, approvalDescription, continuationInfo } = appState

Expand Down Expand Up @@ -109,7 +110,13 @@ export default function App() {
const handleSend = () => {
if (!composerReadiness.canSend) {
if (composerReadiness.reason && composerReadiness.text) {
setAppState(prev => ({ ...prev, error: { type: 'runtime' as const, message: composerReadiness.reason! } }))
const errorType = (status.connectionState === 'unconfigured' || status.connectionState === 'error')
? 'config' as const
: 'runtime' as const
const action = (status.connectionState === 'unconfigured' || status.connectionState === 'error')
? 'openSettings' as 'openSettings' | undefined
: undefined
setAppState(prev => ({ ...prev, error: { type: errorType, message: composerReadiness.reason!, action } }))
}
return
}
Expand All @@ -118,6 +125,7 @@ export default function App() {
if (!payload) return

const userMsgId = uuidv4()
lastUserMessageRef.current = { text: payload.text, includeContext }
setAppState(prev => ({
...prev,
groups: [...prev.groups, { type: 'human' as const, id: userMsgId, message: { id: userMsgId, content: payload.text } }],
Expand Down Expand Up @@ -149,6 +157,22 @@ export default function App() {
window.__bridge?.cancelStream()
}, [isLoading])

const handleErrorAction = useCallback((action: 'openSettings' | 'retry') => {
if (action === 'openSettings') {
window.__bridge?.openSettings()
setAppState(prev => ({ ...prev, error: null }))
} else if (action === 'retry' && lastUserMessageRef.current) {
const msg = lastUserMessageRef.current
const userMsgId = uuidv4()
setAppState(prev => ({
...prev,
groups: [...prev.groups, { type: 'human' as const, id: userMsgId, message: { id: userMsgId, content: msg.text } }],
error: null,
}))
window.__bridge?.sendMessage(msg.text, msg.includeContext)
}
}, [])

// ESC key to cancel streaming
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
Expand Down Expand Up @@ -194,7 +218,7 @@ export default function App() {
bridgeReady={bridgeReady}
/>

{error && <ErrorBanner error={error} onClose={() => setAppState(prev => ({ ...prev, error: null }))} />}
{error && <ErrorBanner error={error} onClose={() => setAppState(prev => ({ ...prev, error: null }))} onAction={handleErrorAction} />}

<div className="messages-area">
{groups.length === 0 && (
Expand Down
161 changes: 161 additions & 0 deletions webview/src/components/ErrorBanner.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
.error-banner {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 16px 0;
padding: 10px 14px;
border-radius: 14px;
border: 1px solid;
backdrop-filter: blur(12px);
font-size: 13px;
line-height: 1.5;
}

.error-banner-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
}

.error-banner-body {
flex: 1;
min-width: 0;
}

.error-banner-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
}

.error-banner-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.85;
}

.error-banner-close {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
margin-left: auto;
border: none;
border-radius: 6px;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.15s, background 0.15s;
}

.error-banner-close:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.1);
}

.error-banner-message {
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
opacity: 0.9;
}

/* ---- Config: warm amber ---- */
.error-banner-config {
background: rgba(210, 161, 94, 0.1);
border-color: rgba(210, 161, 94, 0.3);
}
.error-banner-config .error-banner-icon {
background: rgba(210, 161, 94, 0.18);
color: #d2a15e;
}
.error-banner-config .error-banner-label {
color: #d2a15e;
}

/* ---- Quota: gold ---- */
.error-banner-quota {
background: rgba(212, 160, 23, 0.1);
border-color: rgba(212, 160, 23, 0.3);
}
.error-banner-quota .error-banner-icon {
background: rgba(212, 160, 23, 0.18);
color: #d4a017;
}
.error-banner-quota .error-banner-label {
color: #d4a017;
}

/* ---- Network: blue ---- */
.error-banner-network {
background: rgba(74, 144, 217, 0.1);
border-color: rgba(74, 144, 217, 0.3);
}
.error-banner-network .error-banner-icon {
background: rgba(74, 144, 217, 0.18);
color: #4a90d9;
}
.error-banner-network .error-banner-label {
color: #4a90d9;
}

/* ---- Runtime: muted red ---- */
.error-banner-runtime {
background: rgba(91, 24, 20, 0.45);
border-color: rgba(255, 138, 117, 0.25);
}
.error-banner-runtime .error-banner-icon {
background: rgba(255, 138, 117, 0.15);
color: #ff8a75;
}
.error-banner-runtime .error-banner-label {
color: #ff8a75;
}

/* ---- Action Buttons ---- */
.error-banner-btn-settings,
.error-banner-btn-retry {
flex-shrink: 0;
height: 28px !important;
padding: 0 12px !important;
border-radius: 10px !important;
font-size: 12px !important;
font-weight: 500 !important;
border: 1px solid !important;
cursor: pointer;
transition: all 0.15s ease;
}

.error-banner-btn-settings {
background: linear-gradient(135deg, rgba(185, 131, 70, 0.9), rgba(135, 85, 43, 0.9)) !important;
border-color: rgba(240, 196, 136, 0.4) !important;
color: #f2eadf !important;
box-shadow: 0 4px 12px rgba(89, 47, 17, 0.25);
}

.error-banner-btn-settings:hover {
background: linear-gradient(135deg, rgba(200, 145, 78, 0.95), rgba(150, 95, 48, 0.95)) !important;
border-color: rgba(240, 196, 136, 0.55) !important;
box-shadow: 0 6px 16px rgba(89, 47, 17, 0.3);
transform: translateY(-1px);
}

.error-banner-btn-retry {
background: rgba(74, 144, 217, 0.12) !important;
border-color: rgba(74, 144, 217, 0.35) !important;
color: #4a90d9 !important;
}

.error-banner-btn-retry:hover {
background: rgba(74, 144, 217, 0.22) !important;
border-color: rgba(74, 144, 217, 0.5) !important;
transform: translateY(-1px);
}
101 changes: 81 additions & 20 deletions webview/src/components/ErrorBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,93 @@
import { Alert, Button, Space } from 'antd'
import { Button } from 'antd'
import type { BridgeError } from '../types/bridge'
import './ErrorBanner.css'

interface Props {
error: BridgeError
onClose: () => void
onAction?: (action: 'openSettings' | 'retry') => void
}

export function ErrorBanner({ error, onClose }: Props) {
const alertType = error.type === 'config' ? 'warning' : 'error'
const ERROR_CONFIG = {
config: {
label: '配置错误',
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
),
cssClass: 'error-banner-config',
},
quota: {
label: '配额不足',
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="1" x2="12" y2="23"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
),
cssClass: 'error-banner-quota',
},
network: {
label: '网络错误',
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
),
cssClass: 'error-banner-network',
},
runtime: {
label: '操作失败',
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
),
cssClass: 'error-banner-runtime',
},
}

const action = error.action === 'openSettings' ? (
<Button size="small" type="link" onClick={() => window.__bridge?.openSettings()}>
打开设置
</Button>
) : error.action === 'retry' ? (
<Button size="small" type="link" onClick={onClose}>
关闭
</Button>
) : undefined
export function ErrorBanner({ error, onClose, onAction }: Props) {
const config = ERROR_CONFIG[error.type as keyof typeof ERROR_CONFIG] ?? ERROR_CONFIG.runtime

return (
<Alert
message={error.message}
type={alertType}
closable
onClose={onClose}
className="error-banner"
action={action ? <Space>{action}</Space> : undefined}
/>
<div className={`error-banner ${config.cssClass}`}>
<div className="error-banner-icon">{config.icon}</div>
<div className="error-banner-body">
<div className="error-banner-header">
<span className="error-banner-label">{config.label}</span>
<button className="error-banner-close" onClick={onClose} aria-label="关闭">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div className="error-banner-message">{error.message}</div>
</div>
{error.action === 'openSettings' && (
<Button
size="small"
className="error-banner-btn-settings"
onClick={() => onAction?.('openSettings')}
>
打开设置
</Button>
)}
{error.action === 'retry' && (
<Button
size="small"
className="error-banner-btn-retry"
onClick={() => onAction?.('retry')}
>
重试
</Button>
)}
</div>
)
}
2 changes: 1 addition & 1 deletion webview/src/types/bridge.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface ExecutionResult {
}

export interface BridgeError {
type: 'config' | 'network' | 'runtime'
type: 'config' | 'quota' | 'network' | 'runtime'
message: string
action?: 'openSettings' | 'retry'
}
Expand Down
Loading
Loading