diff --git a/apps/desktop/src/bun/env/hydrate.ts b/apps/desktop/src/bun/env/hydrate.ts index a7d85666..0d889c07 100644 --- a/apps/desktop/src/bun/env/hydrate.ts +++ b/apps/desktop/src/bun/env/hydrate.ts @@ -139,7 +139,3 @@ function _parseEnvBlock( } return env; } - -// Run on import so the environment is fixed before any other module reads -// `process.env`. `bun/index.ts` imports this first for that reason. -hydrateShellEnv(); diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts index 1eee5ed9..5bb65eeb 100644 --- a/apps/desktop/src/bun/index.ts +++ b/apps/desktop/src/bun/index.ts @@ -1,17 +1,23 @@ -/* eslint-disable import-x/order -- load order is load-bearing: `./env/hydrate` - must resolve the real login-shell environment (API keys, PATH) before any - other module reads `process.env`. GUI launches don't inherit it. */ -import "./env/hydrate"; -// Attach the deep-link (`open-url`) listener at the earliest possible point so a -// cold-start launch URL isn't dropped before the composition root is ready. -import "./deep-link/launch"; -// Seed a fresh workspace (before `./app` pulls in storage/RPC). -import "./workspace/seed"; -// Seed the managed skills folder (before `./app` pulls in the SkillsManager). -import "./skills/seed"; -/* eslint-enable import-x/order */ +import { hydrateShellEnv } from "./env/hydrate"; -// Dynamic import is intentional: environment hydration and seeding must finish -// before the composition root evaluates manager modules and reads configuration. -const { startDesktopApp } = await import("./app"); -await startDesktopApp(); +/** + * Initialize process state before loading the desktop composition root. + */ +async function _bootstrapDesktopApp(): Promise { + hydrateShellEnv(); + + // Attach the listener immediately after hydration so cold-start deep links + // are buffered before the longer bootstrap imports evaluate. + await import("./deep-link/launch"); + + const { seedWorkspace } = await import("./workspace/seed"); + seedWorkspace(); + + const { seedSkills } = await import("./skills/seed"); + seedSkills(); + + const { startDesktopApp } = await import("./app"); + await startDesktopApp(); +} + +await _bootstrapDesktopApp(); diff --git a/apps/desktop/src/bun/skills/seed.ts b/apps/desktop/src/bun/skills/seed.ts index 8c543a33..f851ce8f 100644 --- a/apps/desktop/src/bun/skills/seed.ts +++ b/apps/desktop/src/bun/skills/seed.ts @@ -35,7 +35,3 @@ export function seedSkills(): void { writeFileSync(path.join(dir, "SKILL.md"), content, "utf8"); } } - -// Run on import so the managed skills folder exists before the SkillsManager -// (and the Skill tool) read it. -seedSkills(); diff --git a/apps/desktop/src/bun/workspace/seed.ts b/apps/desktop/src/bun/workspace/seed.ts index e6c76143..fa5616c6 100644 --- a/apps/desktop/src/bun/workspace/seed.ts +++ b/apps/desktop/src/bun/workspace/seed.ts @@ -16,6 +16,3 @@ export function seedWorkspace(): void { } mkdirSync(workspace, { recursive: true }); } - -// Run on import so the workspace is seeded before storage/RPC touch it. -seedWorkspace(); diff --git a/packages/runtime/src/models/model-manager.ts b/packages/runtime/src/models/model-manager.ts index 16bff987..0313e536 100644 --- a/packages/runtime/src/models/model-manager.ts +++ b/packages/runtime/src/models/model-manager.ts @@ -1,5 +1,4 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import os from "node:os"; import path from "node:path"; import { env } from "node:process"; @@ -22,6 +21,7 @@ import { BUILTIN_PROVIDERS, } from "./providers/builtin-providers"; import { createCustomProvider } from "./providers/custom-provider"; +import { getCodexCredentials } from "./providers/openai-codex"; import { DEFAULT_CUSTOM_PROVIDER_API, type CustomModelConfig, @@ -666,18 +666,6 @@ export class ModelManager { } private _getCodexApiKey(): string | undefined { - const authPath = path.join(os.homedir(), ".codex", "auth.json"); - if (!existsSync(authPath)) { - return undefined; - } - try { - const parsed = JSON.parse(readFileSync(authPath, "utf8")) as { - tokens?: { access_token?: unknown }; - }; - const accessToken = parsed?.tokens?.access_token; - return typeof accessToken === "string" ? accessToken : undefined; - } catch { - return undefined; - } + return getCodexCredentials()?.apiKey; } } diff --git a/packages/runtime/src/models/providers/openai-codex.test.ts b/packages/runtime/src/models/providers/openai-codex.test.ts index f0184986..0baae699 100644 --- a/packages/runtime/src/models/providers/openai-codex.test.ts +++ b/packages/runtime/src/models/providers/openai-codex.test.ts @@ -6,7 +6,7 @@ import { openaiCodexProvider } from "./openai-codex"; describe("openaiCodexProvider", () => { test("resolves the Codex CLI access token passed as an API-key override", async () => { - const provider = openaiCodexProvider(); + const provider = openaiCodexProvider(null); const model = provider.getModels()[0]; const models = createModels(); models.setProvider(provider); @@ -20,4 +20,48 @@ describe("openaiCodexProvider", () => { expect(auth?.auth.apiKey).toBe("codex-cli-access-token"); }); + + test("uses the configured endpoint and API for API key credentials", async () => { + const provider = openaiCodexProvider({ + api: "anthropic-messages", + apiKey: "codex-cli-api-key", + baseUrl: "https://proxy.example.com", + mode: "apiKey", + }); + const model = provider.getModels()[0]; + + expect(provider.auth.oauth).toBeUndefined(); + expect(provider.baseUrl).toBe("https://proxy.example.com"); + expect(model?.api).toBe("anthropic-messages"); + expect(model?.baseUrl).toBe("https://proxy.example.com"); + + const auth = await provider.auth.apiKey?.resolve({ + ctx: { + env: () => Promise.resolve(undefined), + fileExists: () => Promise.resolve(false), + }, + }); + expect(auth).toMatchObject({ + auth: { apiKey: "codex-cli-api-key" }, + source: "Codex CLI API key", + }); + }); + + test("prefers a per-run API-key override over CLI credentials", async () => { + const provider = openaiCodexProvider({ + api: "openai-responses", + apiKey: "codex-cli-api-key", + baseUrl: "https://proxy.example.com", + mode: "apiKey", + }); + const model = provider.getModels()[0]; + const models = createModels(); + models.setProvider(provider); + + const auth = await models.getAuth(model, { + apiKey: "per-run-api-key", + }); + + expect(auth?.auth.apiKey).toBe("per-run-api-key"); + }); }); diff --git a/packages/runtime/src/models/providers/openai-codex.ts b/packages/runtime/src/models/providers/openai-codex.ts deleted file mode 100644 index 963de54b..00000000 --- a/packages/runtime/src/models/providers/openai-codex.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { envApiKeyAuth } from "@earendil-works/pi-ai"; -import { openaiCodexProvider as _openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex"; - -/** - * Keep the upstream Codex OAuth provider, while accepting the access token - * that ModelManager reads from the Codex CLI's auth.json as a per-run API-key - * override. pi-ai 0.80.10 rejects explicit API-key overrides for OAuth-only - * providers, so without this compatibility auth method the token is ignored. - */ -export function openaiCodexProvider(): ReturnType< - typeof _openaiCodexProvider -> { - const provider = _openaiCodexProvider(); - return { - ...provider, - auth: { - ...provider.auth, - apiKey: envApiKeyAuth("OpenAI Codex access token", []), - }, - }; -} diff --git a/packages/runtime/src/models/providers/openai-codex/codex-credentials.ts b/packages/runtime/src/models/providers/openai-codex/codex-credentials.ts new file mode 100644 index 00000000..69cfe3f1 --- /dev/null +++ b/packages/runtime/src/models/providers/openai-codex/codex-credentials.ts @@ -0,0 +1,13 @@ +import type { CustomProviderApi } from "../../types"; + +export type CodexCredentials = + | { + api: CustomProviderApi; + apiKey: string; + baseUrl: string; + mode: "apiKey"; + } + | { + apiKey: string; + mode: "oauth"; + }; diff --git a/packages/runtime/src/models/providers/openai-codex/config.ts b/packages/runtime/src/models/providers/openai-codex/config.ts new file mode 100644 index 00000000..d3b9a28f --- /dev/null +++ b/packages/runtime/src/models/providers/openai-codex/config.ts @@ -0,0 +1,126 @@ +import { existsSync, readFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import type { CustomProviderApi } from "../../types"; + +import type { CodexCredentials } from "./codex-credentials"; + +const WIRE_API_MAP: Record = { + completions: "openai-completions", + messages: "anthropic-messages", + responses: "openai-responses", +}; + +/** + * Read Codex CLI credentials and the active API key provider configuration. + * + * @param codexDir The Codex CLI configuration directory. + */ +export function getCodexCredentials( + codexDir = path.join(os.homedir(), ".codex") +): CodexCredentials | undefined { + const auth = _readAuthJSON(path.join(codexDir, "auth.json")); + if (!auth) { + return undefined; + } + + if (auth.oauthToken) { + return { apiKey: auth.oauthToken, mode: "oauth" }; + } + + if (!auth.apiKey) { + return undefined; + } + + const config = _readConfigToml(path.join(codexDir, "config.toml")); + return { + api: config?.api ?? "openai-responses", + apiKey: auth.apiKey, + baseUrl: config?.baseUrl ?? "", + mode: "apiKey", + }; +} + +function _readAuthJSON( + authPath: string +): { apiKey?: string; oauthToken?: string } | undefined { + if (!existsSync(authPath)) { + return undefined; + } + try { + const parsed = JSON.parse(readFileSync(authPath, "utf8")) as Record< + string, + unknown + >; + const tokens = parsed.tokens as { access_token?: unknown } | undefined; + const oauthToken = + typeof tokens?.access_token === "string" + ? tokens.access_token + : undefined; + const apiKey = + typeof parsed.OPENAI_API_KEY === "string" + ? parsed.OPENAI_API_KEY + : undefined; + + return oauthToken || apiKey ? { apiKey, oauthToken } : undefined; + } catch { + return undefined; + } +} + +function _readConfigToml( + configPath: string +): { api: CustomProviderApi; baseUrl: string } | undefined { + if (!existsSync(configPath)) { + return undefined; + } + try { + const lines = readFileSync(configPath, "utf8").split("\n"); + const activeProvider = lines + .map((line) => /^\s*model_provider\s*=\s*"([^"]+)"/.exec(line)?.[1]) + .find((value) => value !== undefined); + if (!activeProvider) { + return undefined; + } + + const sectionHeader = `[model_providers.${activeProvider}]`; + let inSection = false; + let baseUrl: string | undefined; + let wireApi: string | undefined; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === sectionHeader) { + inSection = true; + continue; + } + if (inSection && trimmed.startsWith("[")) { + break; + } + if (!inSection) { + continue; + } + const match = /^(\w+)\s*=\s*"([^"]*)"/.exec(trimmed); + if (!match) { + continue; + } + const [, key, value] = match; + if (key === "base_url") { + baseUrl = value; + } else if (key === "wire_api") { + wireApi = value; + } + } + + if (!baseUrl) { + return undefined; + } + return { + api: (wireApi ? WIRE_API_MAP[wireApi] : undefined) ?? "openai-responses", + baseUrl, + }; + } catch { + return undefined; + } +} diff --git a/packages/runtime/src/models/providers/openai-codex/index.ts b/packages/runtime/src/models/providers/openai-codex/index.ts new file mode 100644 index 00000000..6d91b8c7 --- /dev/null +++ b/packages/runtime/src/models/providers/openai-codex/index.ts @@ -0,0 +1,3 @@ +export * from "./codex-credentials"; +export * from "./config"; +export * from "./provider"; diff --git a/packages/runtime/src/models/providers/openai-codex/provider.ts b/packages/runtime/src/models/providers/openai-codex/provider.ts new file mode 100644 index 00000000..79a6e026 --- /dev/null +++ b/packages/runtime/src/models/providers/openai-codex/provider.ts @@ -0,0 +1,82 @@ +import { + createProvider, + type ApiKeyAuth, + type AuthResult, + type Provider, + type ProviderStreams, +} from "@earendil-works/pi-ai"; +import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy"; +import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"; +import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy"; +import { openaiCodexProvider as _openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex"; + +import type { CustomProviderApi } from "../../types"; + +import type { CodexCredentials } from "./codex-credentials"; +import { getCodexCredentials } from "./config"; + +const API_IMPLEMENTATIONS: Record ProviderStreams> = { + "anthropic-messages": anthropicMessagesApi, + "openai-completions": openAICompletionsApi, + "openai-responses": openAIResponsesApi, +}; + +/** + * Create an OpenAI Codex provider for OAuth or API key CLI credentials. + * + * @param credentials The resolved Codex CLI credentials. + */ +export function openaiCodexProvider( + credentials: CodexCredentials | null = getCodexCredentials() ?? null +): Provider { + const codex = _openaiCodexProvider(); + if (credentials?.mode === "apiKey") { + const models = codex.getModels().map((model) => ({ + ...model, + api: credentials.api, + baseUrl: credentials.baseUrl, + })); + return createProvider({ + api: API_IMPLEMENTATIONS[credentials.api](), + auth: { apiKey: _getCodexApiKeyAuth(credentials) }, + baseUrl: credentials.baseUrl, + id: codex.id, + models, + name: codex.name, + }); + } + + return { + ...codex, + auth: { + ...codex.auth, + apiKey: _getCodexApiKeyAuth(credentials), + }, + }; +} + +function _getCodexApiKeyAuth(credentials: CodexCredentials | null): ApiKeyAuth { + return { + name: "Codex CLI credentials", + resolve({ credential }): Promise { + if (credential?.key) { + return Promise.resolve({ + auth: { apiKey: credential.key }, + env: credential.env, + source: "stored credential", + }); + } + return Promise.resolve( + credentials + ? { + auth: { apiKey: credentials.apiKey }, + source: + credentials.mode === "oauth" + ? "Codex CLI OAuth" + : "Codex CLI API key", + } + : undefined + ); + }, + }; +} diff --git a/packages/runtime/tests/models/providers/openai-codex/config.test.ts b/packages/runtime/tests/models/providers/openai-codex/config.test.ts new file mode 100644 index 00000000..f76f67bd --- /dev/null +++ b/packages/runtime/tests/models/providers/openai-codex/config.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { getCodexCredentials } from "../../../../src/models/providers/openai-codex"; + +const TEMP_DIRS: string[] = []; + +afterEach(() => { + for (const dir of TEMP_DIRS.splice(0)) { + rmSync(dir, { force: true, recursive: true }); + } +}); + +describe("getCodexCredentials", () => { + test("prefers OAuth credentials", () => { + const codexDir = _createCodexDir(); + writeFileSync( + path.join(codexDir, "auth.json"), + JSON.stringify({ + OPENAI_API_KEY: "api-key", + tokens: { access_token: "oauth-token" }, + }) + ); + + expect(getCodexCredentials(codexDir)).toEqual({ + apiKey: "oauth-token", + mode: "oauth", + }); + }); + + test("loads the active API key provider", () => { + const codexDir = _createCodexDir(); + writeFileSync( + path.join(codexDir, "auth.json"), + JSON.stringify({ OPENAI_API_KEY: "api-key" }) + ); + writeFileSync( + path.join(codexDir, "config.toml"), + [ + 'model_provider = "proxy"', + "", + "[model_providers.proxy]", + 'base_url = "https://proxy.example.com"', + 'wire_api = "completions"', + ].join("\n") + ); + + expect(getCodexCredentials(codexDir)).toEqual({ + api: "openai-completions", + apiKey: "api-key", + baseUrl: "https://proxy.example.com", + mode: "apiKey", + }); + }); +}); + +function _createCodexDir(): string { + const dir = mkdtempSync(path.join(os.tmpdir(), "llm-space-codex-")); + TEMP_DIRS.push(dir); + return dir; +} diff --git a/packages/runtime/tsconfig.json b/packages/runtime/tsconfig.json index 60bf28a6..60fa4626 100644 --- a/packages/runtime/tsconfig.json +++ b/packages/runtime/tsconfig.json @@ -5,5 +5,5 @@ "lib": ["ESNext", "DOM"], "noUncheckedIndexedAccess": false }, - "include": ["src"] + "include": ["src", "tests"] } diff --git a/packages/ui/src/components/code-editor/editor.tsx b/packages/ui/src/components/code-editor/editor.tsx index 1ffa2a5e..117bfc23 100644 --- a/packages/ui/src/components/code-editor/editor.tsx +++ b/packages/ui/src/components/code-editor/editor.tsx @@ -254,7 +254,7 @@ function _CodeEditor( const handleKeyDownCapture = useCallback( (e: KeyboardEvent) => { - if (e.key === "Enter" && e.metaKey) { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { commit(); } onKeyDown?.(e); diff --git a/packages/ui/src/components/code-editor/index.tsx b/packages/ui/src/components/code-editor/index.tsx index a2c9a273..9a968317 100644 --- a/packages/ui/src/components/code-editor/index.tsx +++ b/packages/ui/src/components/code-editor/index.tsx @@ -179,9 +179,12 @@ const PlainTextCodeEditor = forwardRef< [commit, insertText] ); - const handleChange = useCallback((event: ChangeEvent) => { - draftRef.current = event.currentTarget.value; - }, []); + const handleChange = useCallback( + (event: ChangeEvent) => { + draftRef.current = event.currentTarget.value; + }, + [] + ); return (
{ - if (event.key === "Enter" && event.metaKey) { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { commit(); } onKeyDown?.(event); diff --git a/packages/ui/src/components/thread-playground/message/message-list-item.tsx b/packages/ui/src/components/thread-playground/message/message-list-item.tsx index 202f977f..4c89f822 100644 --- a/packages/ui/src/components/thread-playground/message/message-list-item.tsx +++ b/packages/ui/src/components/thread-playground/message/message-list-item.tsx @@ -11,8 +11,8 @@ import { createMessagePromptVariablePlaceKey, summarizeToolCalls, } from "@llm-space/core/thread"; -import { PlusIcon } from "lucide-react"; -import { memo, useCallback, useMemo, useState } from "react"; +import { CircleAlertIcon, PlusIcon, TriangleAlertIcon } from "lucide-react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { CodeEditor } from "@llm-space/ui/components/code-editor"; @@ -27,8 +27,11 @@ import { Marker, MarkerContent } from "@llm-space/ui/ui/marker"; import { ShineBorder } from "@llm-space/ui/ui/shine-border"; import { Skeleton } from "@llm-space/ui/ui/skeleton"; - -import { useThreadStore, useThreadStoreActions } from "../stores"; +import { + type RunValidationIssue, + useThreadStore, + useThreadStoreActions, +} from "../stores"; import { usePromptVariableExtensionForContext } from "../variable/use-prompt-variable-extension"; import { ImageContentList } from "./image-content-view"; @@ -43,6 +46,7 @@ function _MessageListItem({ message, placeholder, readonly = false, + runValidationIssue = null, streaming, collapsed, autoFocus = false, @@ -53,12 +57,14 @@ function _MessageListItem({ message: Message; placeholder?: string; readonly?: boolean; + runValidationIssue?: RunValidationIssue | null; streaming?: boolean; collapsed?: boolean; /** Focus this message's editor on mount. Set only for a freshly-added message. */ autoFocus?: boolean; dragHandleProps?: DraggableProvidedDragHandleProps | null; }) { + const containerRef = useRef(null); const { fidelity } = useRenderingFidelity(); const variableExtension = usePromptVariableExtensionForContext( createMessagePromptVariablePlaceKey(message.id), @@ -149,21 +155,44 @@ function _MessageListItem({ ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === "Enter" && e.metaKey) { + if ( + message.role === "user" && + e.key === "Enter" && + (e.metaKey || e.ctrlKey) + ) { void handleRun(); e.preventDefault(); e.stopPropagation(); } }, - [handleRun] + [handleRun, message.role] ); + const validationErrorId = `message-${message.id}-run-error`; + useEffect(() => { + if (!runValidationIssue) { + return; + } + containerRef.current?.scrollIntoView({ + behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth", + block: "nearest", + }); + }, [runValidationIssue]); return (
+ {runValidationIssue ? ( + + ) : null}
); } diff --git a/packages/ui/src/components/thread-playground/message/message-list-view.tsx b/packages/ui/src/components/thread-playground/message/message-list-view.tsx index dedacf6e..4e1e1bac 100644 --- a/packages/ui/src/components/thread-playground/message/message-list-view.tsx +++ b/packages/ui/src/components/thread-playground/message/message-list-view.tsx @@ -12,8 +12,13 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@llm-space/ui/lib/utils"; import { Button } from "@llm-space/ui/ui/button"; import { ScrollArea } from "@llm-space/ui/ui/scroll-area"; +import { ShineBorder } from "@llm-space/ui/ui/shine-border"; -import { useThreadStore, useThreadStoreActions } from "../stores"; +import { + type RunValidationIssue, + useThreadStore, + useThreadStoreActions, +} from "../stores"; import { ImageDisplayProvider, @@ -39,8 +44,10 @@ export function MessageListView({ const status = useThreadStore((s) => s.status); const collapsedMessageIds = useThreadStore((s) => s.collapsedMessageIds); const autoFocusMessageId = useThreadStore((s) => s.autoFocusMessageId); + const runValidationIssue = useThreadStore((s) => s.runValidationIssue); const storeMessages = useThreadStore((s) => s.thread.context?.messages); - const { appendMessage, moveMessage } = useThreadStoreActions(); + const { appendMessage, moveMessage, resolveRunValidationIssue } = + useThreadStoreActions(); const [dragging, setDragging] = useState(false); const messages = useMemo( () => messagesFromProps ?? storeMessages ?? [], @@ -49,6 +56,8 @@ export function MessageListView({ const readonly = useMemo(() => { return readonlyFromProps || dragging || isSnapshotView; }, [dragging, isSnapshotView, readonlyFromProps]); + const addMessageSuggested = + runValidationIssue?.resolution?.type === "appendUserMessage"; // Number every image attachment sequentially across the thread so the compact // placeholder can label it `[Image #N]`. @@ -104,51 +113,71 @@ export function MessageListView({ return ( -
- {isSnapshotView ? ( - - ) : ( - - - {(droppableProvided) => ( - - )} - - - )} - {!isSnapshotView && ( - - )} - -
+ {!isSnapshotView && ( + + )} +
+ + {addMessageSuggested && !dragging && !readonly ? ( + <> + + + + ) : null} +
+
); @@ -184,12 +213,14 @@ function DroppableMessageList({ readonly, autoFocusMessageId, collapsedMessageIds, + runValidationIssue, }: { droppableProvided: DroppableProvided; messages: Message[]; readonly: boolean; autoFocusMessageId: string | null; collapsedMessageIds: string[]; + runValidationIssue: RunValidationIssue | null; }) { return (
))} {droppableProvided.placeholder} @@ -226,12 +262,14 @@ const _DraggableMessageRow = function DraggableMessageRow({ readonly, autoFocus, collapsed, + runValidationIssue, }: { message: Message; index: number; readonly: boolean; autoFocus: boolean; collapsed: boolean; + runValidationIssue: RunValidationIssue | null; }) { return ( @@ -253,6 +291,7 @@ const _DraggableMessageRow = function DraggableMessageRow({ readonly={readonly} autoFocus={autoFocus} collapsed={collapsed} + runValidationIssue={runValidationIssue} dragHandleProps={draggableProvided.dragHandleProps} />
diff --git a/packages/ui/src/components/thread-playground/message/tool-call-list-item.tsx b/packages/ui/src/components/thread-playground/message/tool-call-list-item.tsx index 41743e1b..1bc044b1 100644 --- a/packages/ui/src/components/thread-playground/message/tool-call-list-item.tsx +++ b/packages/ui/src/components/thread-playground/message/tool-call-list-item.tsx @@ -19,7 +19,10 @@ import { import { memo, useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; -import { CodeEditor, type CodeEditorProps } from "@llm-space/ui/components/code-editor"; +import { + CodeEditor, + type CodeEditorProps, +} from "@llm-space/ui/components/code-editor"; import { openFirecrawlLimitDialog } from "@llm-space/ui/components/firecrawl-limit-dialog"; import { PreviewDialog } from "@llm-space/ui/components/preview-dialog-lazy"; import { useRenderingFidelity } from "@llm-space/ui/components/theme-provider"; @@ -93,7 +96,7 @@ function _ToolCallListItem({ ]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === "Enter" && e.metaKey) { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); e.stopPropagation(); if (canContinue) { diff --git a/packages/ui/src/components/thread-playground/stores/index.ts b/packages/ui/src/components/thread-playground/stores/index.ts index 89ad239b..ecb648e4 100644 --- a/packages/ui/src/components/thread-playground/stores/index.ts +++ b/packages/ui/src/components/thread-playground/stores/index.ts @@ -1,3 +1,5 @@ export * from "./run-mode"; +export * from "./run-validation"; +export * from "./run-validation-issue"; export * from "./thread-history"; export * from "./thread-store"; diff --git a/packages/ui/src/components/thread-playground/stores/run-validation-issue.ts b/packages/ui/src/components/thread-playground/stores/run-validation-issue.ts new file mode 100644 index 00000000..537d6966 --- /dev/null +++ b/packages/ui/src/components/thread-playground/stores/run-validation-issue.ts @@ -0,0 +1,9 @@ +export interface RunValidationIssue { + readonly code: "lastAssistantMessage"; + readonly level: "error" | "warning"; + readonly message: string; + readonly messageId: string; + readonly resolution?: { + readonly type: "appendUserMessage"; + }; +} diff --git a/packages/ui/src/components/thread-playground/stores/run-validation.ts b/packages/ui/src/components/thread-playground/stores/run-validation.ts new file mode 100644 index 00000000..d2257789 --- /dev/null +++ b/packages/ui/src/components/thread-playground/stores/run-validation.ts @@ -0,0 +1,26 @@ +import { isRunnableConversation, type Message } from "@llm-space/core"; + +import type { RunValidationIssue } from "./run-validation-issue"; + +/** + * Return actionable feedback for a message list that cannot be run. + * + * @param messages The messages selected for the run. + */ +export function getRunValidationIssue( + messages: Message[] +): RunValidationIssue | null { + const lastMessage = messages.at(-1); + if (!lastMessage || isRunnableConversation(messages)) { + return null; + } + return { + code: "lastAssistantMessage", + level: "warning", + message: "Please add a user message to run", + messageId: lastMessage.id, + resolution: { + type: "appendUserMessage", + }, + }; +} diff --git a/packages/ui/src/components/thread-playground/stores/thread-store.ts b/packages/ui/src/components/thread-playground/stores/thread-store.ts index 8ba5a21d..53146206 100644 --- a/packages/ui/src/components/thread-playground/stores/thread-store.ts +++ b/packages/ui/src/components/thread-playground/stores/thread-store.ts @@ -4,11 +4,9 @@ import { getMessageText, isDangerousBashCommand, isExecutableTool, - isRunnableConversation, Message, normalizeThread, reduceMessages, - RUN_LAST_MESSAGE_ERROR, streamThread, Tool as ToolSchema, uuid, @@ -69,6 +67,8 @@ import { createFrameThrottle } from "@llm-space/ui/lib/frame-throttle"; import { PREVIEW_THROTTLE_MS } from "../streaming-preview"; +import { getRunValidationIssue } from "./run-validation"; +import type { RunValidationIssue } from "./run-validation-issue"; import { createInitialHistory, recordSnapshot, @@ -103,6 +103,7 @@ export interface ThreadState { abortController: AbortController | null; activeRunId: string | null; collapsedMessageIds: string[]; + runValidationIssue: RunValidationIssue | null; /** * Id of the message whose editor should grab focus on mount — set only by * append/insert. Every other editor mounts with autoFocus off so opening a @@ -119,6 +120,7 @@ export interface ThreadState { evaluationRubrics: EvaluationRubricRecord[]; run(fromMessageId?: string): Promise; + resolveRunValidationIssue(): void; undo(): void; redo(): void; restoreThread(thread: Thread): void; @@ -338,8 +340,23 @@ export function createThreadStore( }); }; + const reconcileRunValidationIssue = (messages: Message[]) => { + const current = get().runValidationIssue; + if (!current) { + return; + } + const next = getRunValidationIssue(messages); + if ( + next?.messageId !== current.messageId || + next?.code !== current.code + ) { + set({ runValidationIssue: null }); + } + }; + const setMessages = (messages: Message[]) => { patchContext({ messages }); + reconcileRunValidationIssue(messages); }; /** Replace the messages array; skips the update if nothing changed. */ @@ -512,6 +529,7 @@ export function createThreadStore( abortController: null, activeRunId: null, collapsedMessageIds: [], + runValidationIssue: null, autoFocusMessageId: null, changeHistory: createInitialHistory(normalizedInitialThread), runHistory: initialRunHistory, @@ -524,6 +542,12 @@ export function createThreadStore( set({ autoFocusMessageId: message.id }); return message.id; }, + resolveRunValidationIssue() { + const resolution = get().runValidationIssue?.resolution; + if (resolution?.type === "appendUserMessage") { + get().appendMessage(); + } + }, insertMessageBefore(beforeMessageId: string) { const messages = get().thread.context?.messages ?? []; const index = messages.findIndex((m) => m.id === beforeMessageId); @@ -931,10 +955,12 @@ export function createThreadStore( truncated = true; } } - if (!isRunnableConversation(messages)) { - toast.error("Error", { description: RUN_LAST_MESSAGE_ERROR }); + const runValidationIssue = getRunValidationIssue(messages); + if (runValidationIssue) { + set({ runValidationIssue }); return; } + set({ runValidationIssue: null }); let promptSnapshot: ThreadContext["snapshot"] = get().thread.context?.snapshot; let preparedContext: ThreadContext | null = null; @@ -1124,10 +1150,7 @@ export function createThreadStore( return "aborted"; } const receivedAt = now(); - if ( - firstTokenAt === null && - _isNonEmptyAssistantDelta(chunk) - ) { + if (firstTokenAt === null && _isNonEmptyAssistantDelta(chunk)) { firstTokenAt = receivedAt; } sawEvent = true; @@ -1261,6 +1284,7 @@ export function createThreadStore( }); set({ thread, + runValidationIssue: null, changeHistory: { ...result.history, snapshots: result.history.snapshots.map((snapshot, index) => @@ -1284,6 +1308,7 @@ export function createThreadStore( }); set({ thread, + runValidationIssue: null, changeHistory: { ...result.history, snapshots: result.history.snapshots.map((snapshot, index) => @@ -1307,6 +1332,7 @@ export function createThreadStore( // Replace the whole thread; recorded as a single undoable step. set({ thread: next, + runValidationIssue: null, changeHistory: recordSnapshot(get().changeHistory, next), }); }, @@ -1499,6 +1525,7 @@ export function useThreadStore(selector: (s: ThreadState) => T): T { const selectActions = (s: ThreadState) => ({ run: s.run, + resolveRunValidationIssue: s.resolveRunValidationIssue, abort: s.abort, undo: s.undo, redo: s.redo, diff --git a/packages/ui/src/components/thread-playground/use-shortcuts.ts b/packages/ui/src/components/thread-playground/use-shortcuts.ts index 26eb03e7..a5a4b5ea 100644 --- a/packages/ui/src/components/thread-playground/use-shortcuts.ts +++ b/packages/ui/src/components/thread-playground/use-shortcuts.ts @@ -13,7 +13,7 @@ export function useShortcuts({ readonly }: { readonly: boolean }) { return useCallback( (event: KeyboardEvent) => { - if (!(event.metaKey || event.ctrlKey || event.altKey)) { + if (!(event.metaKey || event.ctrlKey)) { return; } diff --git a/packages/ui/tests/components/thread-playground/stores/run-validation.test.ts b/packages/ui/tests/components/thread-playground/stores/run-validation.test.ts new file mode 100644 index 00000000..db8a2cef --- /dev/null +++ b/packages/ui/tests/components/thread-playground/stores/run-validation.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; + +import type { Message } from "@llm-space/core"; + +import { getRunValidationIssue } from "../../../../src/components/thread-playground/stores"; + +describe("getRunValidationIssue", () => { + test("identifies the message that prevents a run", () => { + const assistant: Message = { + content: [{ text: "Answer", type: "text" }], + id: "assistant-one", + role: "assistant", + }; + + expect(getRunValidationIssue([assistant])).toEqual({ + code: "lastAssistantMessage", + level: "warning", + message: "Please add a user message to run", + messageId: "assistant-one", + resolution: { + type: "appendUserMessage", + }, + }); + }); + + test("accepts an empty user message", () => { + const emptyUser: Message = { + content: [{ text: "", type: "text" }], + id: "user-empty", + role: "user", + }; + + expect(getRunValidationIssue([emptyUser])).toBeNull(); + }); +}); diff --git a/packages/ui/tests/components/thread-playground/stores/thread-store.test.ts b/packages/ui/tests/components/thread-playground/stores/thread-store.test.ts new file mode 100644 index 00000000..a59c7d6d --- /dev/null +++ b/packages/ui/tests/components/thread-playground/stores/thread-store.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; + +import type { Thread } from "@llm-space/core"; + +import { createThreadStore } from "../../../../src/components/thread-playground/stores"; + +const INVALID_THREAD: Thread = { + context: { + messages: [ + { + content: [{ text: "Question", type: "text" }], + id: "user-one", + role: "user", + }, + { + content: [{ text: "Answer", type: "text" }], + id: "assistant-one", + role: "assistant", + }, + ], + }, + model: { id: "model", provider: "fake" }, +}; + +describe("inline run validation", () => { + test("scopes feedback to the blocking message", async () => { + let transportCalls = 0; + const store = createThreadStore(INVALID_THREAD, { + resolveModel: (saved) => saved ?? null, + transport: () => { + transportCalls += 1; + throw new Error("Transport should not run for invalid input"); + }, + }); + + await store.getState().run(); + + expect(store.getState().runValidationIssue).toMatchObject({ + code: "lastAssistantMessage", + level: "warning", + messageId: "assistant-one", + resolution: { type: "appendUserMessage" }, + }); + expect(transportCalls).toBe(0); + + store.getState().updateMessageTextContent("assistant-one", "Edited answer"); + expect(store.getState().runValidationIssue?.messageId).toBe( + "assistant-one" + ); + + store.getState().resolveRunValidationIssue(); + const messages = store.getState().thread.context?.messages ?? []; + expect(store.getState().runValidationIssue).toBeNull(); + expect(messages.at(-1)?.role).toBe("user"); + expect(store.getState().autoFocusMessageId).toBe( + messages.at(-1)?.id ?? null + ); + }); + + test("clears feedback when the blocking role becomes runnable", async () => { + const store = createThreadStore(INVALID_THREAD, { + resolveModel: (saved) => saved ?? null, + transport: () => { + throw new Error("Transport should not run for invalid input"); + }, + }); + + await store.getState().run(); + store.getState().toggleMessageRole("assistant-one"); + + expect(store.getState().runValidationIssue).toBeNull(); + }); +}); diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 237d78c4..b7b5444f 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -3,11 +3,7 @@ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, - "lib": [ - "ES2020", - "DOM", - "DOM.Iterable" - ], + "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", @@ -18,16 +14,12 @@ "noEmit": true, "jsx": "react-jsx", "strict": true, + "types": ["bun"], "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "incremental": true }, - "include": [ - "src" - ], - "exclude": [ - "**/*.test.ts", - "**/*.test.tsx" - ] + "include": ["src", "tests"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] }