Merge pull request #107 from multica-ai/fix/new-user-onboarding
fix(desktop): new user onboarding — show errors and configure dialog in Chat
This commit is contained in:
commit
b7085b2bf5
9 changed files with 177 additions and 38 deletions
|
|
@ -281,6 +281,17 @@ export function registerHubIpcHandlers(): void {
|
|||
return
|
||||
}
|
||||
|
||||
// Agent error events: forward so the UI can display them
|
||||
if (event.type === 'agent_error') {
|
||||
safeLog(`[IPC] Sending agent_error event to renderer: ${(event as { message: string }).message}`)
|
||||
mainWindowRef.webContents.send('localChat:event', {
|
||||
agentId,
|
||||
streamId: null,
|
||||
event,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Filter events same as Hub.consumeAgent()
|
||||
const maybeMessage = (event as { message?: { role?: string } }).message
|
||||
const isAssistantMessage = maybeMessage?.role === 'assistant'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import { Loading } from '@multica/ui/components/ui/loading'
|
||||
import { ChatView } from '@multica/ui/components/chat-view'
|
||||
import { useLocalChat } from '../hooks/use-local-chat'
|
||||
import { useProvider } from '../hooks/use-provider'
|
||||
import { ApiKeyDialog } from './api-key-dialog'
|
||||
import { OAuthDialog } from './oauth-dialog'
|
||||
|
||||
export function LocalChat() {
|
||||
const {
|
||||
|
|
@ -17,8 +21,41 @@ export function LocalChat() {
|
|||
sendMessage,
|
||||
loadMore,
|
||||
resolveApproval,
|
||||
clearError,
|
||||
} = useLocalChat()
|
||||
|
||||
const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProvider()
|
||||
|
||||
// Provider config dialog state
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
|
||||
const [oauthDialogOpen, setOauthDialogOpen] = useState(false)
|
||||
|
||||
const handleConfigureProvider = useCallback(() => {
|
||||
const providerId = current?.provider
|
||||
if (!providerId) return
|
||||
|
||||
const meta = providers.find((p) => p.id === providerId)
|
||||
if (!meta) return
|
||||
|
||||
if (meta.authMethod === 'oauth') {
|
||||
setOauthDialogOpen(true)
|
||||
} else {
|
||||
setApiKeyDialogOpen(true)
|
||||
}
|
||||
}, [current, providers])
|
||||
|
||||
const handleProviderConfigSuccess = useCallback(async () => {
|
||||
const providerId = current?.provider
|
||||
if (!providerId) return
|
||||
|
||||
await refreshProviders()
|
||||
await switchProvider(providerId)
|
||||
clearError()
|
||||
}, [current, refreshProviders, switchProvider, clearError])
|
||||
|
||||
// Derive provider info for dialogs
|
||||
const currentMeta = current ? providers.find((p) => p.id === current.provider) : null
|
||||
|
||||
if (initError) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-destructive">
|
||||
|
|
@ -36,19 +73,48 @@ export function LocalChat() {
|
|||
)
|
||||
}
|
||||
|
||||
// Show "Configure" button when error is about provider/API key
|
||||
const errorAction = error?.code === 'AGENT_ERROR' && currentMeta
|
||||
? { label: 'Configure', onClick: handleConfigureProvider }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<ChatView
|
||||
messages={messages}
|
||||
streamingIds={streamingIds}
|
||||
isLoading={isLoading}
|
||||
isLoadingHistory={isLoadingHistory}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
error={error}
|
||||
pendingApprovals={pendingApprovals}
|
||||
sendMessage={sendMessage}
|
||||
loadMore={loadMore}
|
||||
resolveApproval={resolveApproval}
|
||||
/>
|
||||
<>
|
||||
<ChatView
|
||||
messages={messages}
|
||||
streamingIds={streamingIds}
|
||||
isLoading={isLoading}
|
||||
isLoadingHistory={isLoadingHistory}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
error={error}
|
||||
pendingApprovals={pendingApprovals}
|
||||
sendMessage={sendMessage}
|
||||
loadMore={loadMore}
|
||||
resolveApproval={resolveApproval}
|
||||
errorAction={errorAction}
|
||||
/>
|
||||
|
||||
{currentMeta && currentMeta.authMethod === 'api-key' && (
|
||||
<ApiKeyDialog
|
||||
open={apiKeyDialogOpen}
|
||||
onOpenChange={setApiKeyDialogOpen}
|
||||
providerId={currentMeta.id}
|
||||
providerName={currentMeta.name}
|
||||
onSuccess={handleProviderConfigSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentMeta && currentMeta.authMethod === 'oauth' && (
|
||||
<OAuthDialog
|
||||
open={oauthDialogOpen}
|
||||
onOpenChange={setOauthDialogOpen}
|
||||
providerId={currentMeta.id}
|
||||
providerName={currentMeta.name}
|
||||
loginCommand={currentMeta.loginCommand}
|
||||
onSuccess={handleProviderConfigSuccess}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
ExecApprovalRequestPayload,
|
||||
ApprovalDecision,
|
||||
AgentMessageItem,
|
||||
AgentErrorEvent,
|
||||
} from '@multica/sdk'
|
||||
import { DEFAULT_MESSAGES_LIMIT } from '@multica/sdk'
|
||||
|
||||
|
|
@ -56,6 +57,14 @@ export function useLocalChat() {
|
|||
const payload = data as unknown as StreamPayload
|
||||
if (!payload.event) return
|
||||
|
||||
// Handle agent error events
|
||||
if (payload.event.type === 'agent_error') {
|
||||
const errorEvent = payload.event as AgentErrorEvent
|
||||
chatRef.current.setError({ code: 'AGENT_ERROR', message: errorEvent.message })
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
chatRef.current.handleStream(payload)
|
||||
if (payload.event.type === 'message_start') setIsLoading(true)
|
||||
if (payload.event.type === 'message_end') setIsLoading(false)
|
||||
|
|
@ -133,6 +142,10 @@ export function useLocalChat() {
|
|||
[],
|
||||
)
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
chatRef.current.setError(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
agentId,
|
||||
initError,
|
||||
|
|
@ -147,5 +160,6 @@ export function useLocalChat() {
|
|||
sendMessage,
|
||||
loadMore,
|
||||
resolveApproval,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export {
|
|||
type CompactionEvent,
|
||||
type CompactionStartEvent,
|
||||
type CompactionEndEvent,
|
||||
type AgentErrorEvent,
|
||||
type ContentBlock,
|
||||
type TextContent,
|
||||
type ThinkingContent,
|
||||
|
|
|
|||
|
|
@ -45,16 +45,22 @@ export type CompactionEndEvent = {
|
|||
/** Union of all compaction events */
|
||||
export type CompactionEvent = CompactionStartEvent | CompactionEndEvent;
|
||||
|
||||
/** Emitted when an agent encounters an error during execution */
|
||||
export type AgentErrorEvent = {
|
||||
type: "agent_error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
// --- Stream event types ---
|
||||
|
||||
/**
|
||||
* Hub forwards AgentEvent from pi-agent-core and CompactionEvent as-is.
|
||||
* Hub forwards AgentEvent from pi-agent-core, CompactionEvent, and AgentErrorEvent as-is.
|
||||
* StreamPayload wraps them with routing metadata.
|
||||
*/
|
||||
export interface StreamPayload {
|
||||
streamId: string;
|
||||
agentId: string;
|
||||
event: AgentEvent | CompactionEvent;
|
||||
event: AgentEvent | CompactionEvent | AgentErrorEvent;
|
||||
}
|
||||
|
||||
/** Extract thinking/reasoning content from an AgentEvent that carries a message */
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ export interface ChatViewProps {
|
|||
loadMore?: () => void;
|
||||
resolveApproval: (approvalId: string, decision: "allow-once" | "allow-always" | "deny") => void;
|
||||
onDisconnect?: () => void;
|
||||
/** Optional action button in the error banner (e.g. "Configure Provider") */
|
||||
errorAction?: { label: string; onClick: () => void };
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
|
|
@ -53,6 +55,7 @@ export function ChatView({
|
|||
loadMore,
|
||||
resolveApproval,
|
||||
onDisconnect,
|
||||
errorAction,
|
||||
}: ChatViewProps) {
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -219,16 +222,28 @@ export function ChatView({
|
|||
<div className="container px-4" role="alert" aria-live="polite">
|
||||
<div className="rounded-lg bg-destructive/5 border border-destructive/15 text-xs px-3 py-2 flex items-center justify-between gap-3">
|
||||
<span className="text-foreground leading-snug">{error.message}</span>
|
||||
{onDisconnect && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onDisconnect}
|
||||
className="shrink-0 text-xs h-7 px-2.5"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{errorAction && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={errorAction.onClick}
|
||||
className="shrink-0 text-xs h-7 px-2.5"
|
||||
>
|
||||
{errorAction.label}
|
||||
</Button>
|
||||
)}
|
||||
{onDisconnect && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onDisconnect}
|
||||
className="shrink-0 text-xs h-7 px-2.5"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -236,8 +251,8 @@ export function ChatView({
|
|||
<footer className="container px-4 pb-3 pt-1">
|
||||
<ChatInput
|
||||
onSubmit={sendMessage}
|
||||
disabled={isLoading || !!error}
|
||||
placeholder={error ? "Connection error" : "Ask your Agent..."}
|
||||
disabled={isLoading || (!!error && error.code !== 'AGENT_ERROR')}
|
||||
placeholder={error && error.code !== 'AGENT_ERROR' ? "Connection error" : "Ask your Agent..."}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -62,11 +62,14 @@ export class AsyncAgent {
|
|||
// Normal text is delivered via message_end event; only handle errors here
|
||||
if (result.error) {
|
||||
this.channel.send({ id: uuidv7(), content: `[error] ${result.error}` });
|
||||
this.agent.emitError(result.error);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.channel.send({ id: uuidv7(), content: `[error] ${message}` });
|
||||
// Also emit through subscriber mechanism so IPC listeners receive the error
|
||||
this.agent.emitError(message);
|
||||
})
|
||||
.finally(() => {
|
||||
this.pendingWrites = Math.max(0, this.pendingWrites - 1);
|
||||
|
|
|
|||
|
|
@ -21,10 +21,16 @@ export type CompactionEndEvent = {
|
|||
type: "compaction_end";
|
||||
removed: number;
|
||||
kept: number;
|
||||
tokensRemoved?: number;
|
||||
tokensKept?: number;
|
||||
tokensRemoved?: number | undefined;
|
||||
tokensKept?: number | undefined;
|
||||
reason: "count" | "tokens" | "summary" | "pruning";
|
||||
};
|
||||
|
||||
/** Emitted when an agent encounters an error during execution */
|
||||
export type AgentErrorEvent = {
|
||||
type: "agent_error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
/** Union of all Multica-specific events */
|
||||
export type MulticaEvent = CompactionStartEvent | CompactionEndEvent;
|
||||
export type MulticaEvent = CompactionStartEvent | CompactionEndEvent | AgentErrorEvent;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Agent as PiAgentCore, type AgentEvent, type AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type { AgentOptions, AgentRunResult, ReasoningMode } from "./types.js";
|
||||
import type { MulticaEvent } from "./events.js";
|
||||
import type { MulticaEvent, CompactionEndEvent } from "./events.js";
|
||||
import { createAgentOutput } from "./cli/output.js";
|
||||
import { resolveModel, resolveTools, type ResolveToolsOptions } from "./tools.js";
|
||||
import {
|
||||
|
|
@ -163,11 +163,14 @@ export class Agent {
|
|||
: 0;
|
||||
}
|
||||
|
||||
this.agent = new PiAgentCore(
|
||||
this.currentApiKey
|
||||
? { getApiKey: (_provider: string) => this.currentApiKey! }
|
||||
: {},
|
||||
);
|
||||
this.agent = new PiAgentCore({
|
||||
getApiKey: (_provider: string) => {
|
||||
if (!this.currentApiKey) {
|
||||
throw new Error(`No API key configured for provider: ${this.resolvedProvider}`);
|
||||
}
|
||||
return this.currentApiKey;
|
||||
},
|
||||
});
|
||||
|
||||
// Load Agent Profile (if profileId is specified)
|
||||
// Every Agent should have a Profile for memory, tools config, and other settings
|
||||
|
|
@ -356,6 +359,11 @@ export class Agent {
|
|||
}
|
||||
}
|
||||
|
||||
/** Emit an error event through the subscriber mechanism */
|
||||
emitError(message: string): void {
|
||||
this.emitMulticaEvent({ type: "agent_error", message });
|
||||
}
|
||||
|
||||
async run(prompt: string): Promise<AgentRunResult> {
|
||||
// Run-level mutex: prevents concurrent run/runInternal from mis-tagging messages
|
||||
return this.withRunMutex(() => this._run(prompt));
|
||||
|
|
@ -402,6 +410,14 @@ export class Agent {
|
|||
await this.ensureInitialized();
|
||||
this.output.state.lastAssistantText = "";
|
||||
|
||||
// Early validation: check API key before calling PiAgentCore.prompt(),
|
||||
// because getApiKey errors thrown inside PiAgentCore's internal async
|
||||
// context result in UnhandledPromiseRejection instead of propagating.
|
||||
if (!this.currentApiKey) {
|
||||
const errorMsg = `No API key configured for provider: ${this.resolvedProvider}. Please configure a provider in Agent Settings.`;
|
||||
return { text: "", error: errorMsg };
|
||||
}
|
||||
|
||||
const canRotate = !this.pinnedProfile && this.profileCandidates.length > 1;
|
||||
let lastError: unknown;
|
||||
|
||||
|
|
@ -509,14 +525,15 @@ export class Agent {
|
|||
if (result?.kept) {
|
||||
this.agent.replaceMessages(result.kept);
|
||||
}
|
||||
this.emitMulticaEvent({
|
||||
const endEvent: CompactionEndEvent = {
|
||||
type: "compaction_end",
|
||||
removed: result?.removedCount ?? 0,
|
||||
kept: result?.kept.length ?? messages.length,
|
||||
tokensRemoved: result?.tokensRemoved,
|
||||
tokensKept: result?.tokensKept,
|
||||
reason: result?.reason ?? "tokens",
|
||||
});
|
||||
};
|
||||
this.emitMulticaEvent(endEvent);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue