Skip to content
Open
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
2 changes: 1 addition & 1 deletion .cursor/rules/we-schema.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -2317,7 +2317,7 @@ EditorStore:
- isOpen: boolean — the AI chat panel is open
- isStreaming: boolean — an assistant reply is arriving; streamingContent holds what has arrived so far
- streamingContent: string — the partial assistant reply while isStreaming, empty otherwise
- apiKeyConfigured: boolean — the agent has an API key set, so sendMessage can work. Gate the composer on it and say what is missing rather than hiding it
- apiKeyConfigured: boolean — the active provider has enough configuration to send requests. Gate the composer on it
- templateName: string — the name of the template being edited, for the editor’s own header
- templateIcon: string — its icon
- isReadOnly: boolean — the template on screen cannot be saved in place (a built-in, or somebody else's). Edits buffer as pending changes; offer Fork rather than Save. Answers for the template rendered, so do not use it to gate per-row controls in a list — switcherGroups carries `editable` per row
Expand Down
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2317,7 +2317,7 @@ EditorStore:
- isOpen: boolean — the AI chat panel is open
- isStreaming: boolean — an assistant reply is arriving; streamingContent holds what has arrived so far
- streamingContent: string — the partial assistant reply while isStreaming, empty otherwise
- apiKeyConfigured: boolean — the agent has an API key set, so sendMessage can work. Gate the composer on it and say what is missing rather than hiding it
- apiKeyConfigured: boolean — the active provider has enough configuration to send requests. Gate the composer on it
- templateName: string — the name of the template being edited, for the editor’s own header
- templateIcon: string — its icon
- isReadOnly: boolean — the template on screen cannot be saved in place (a built-in, or somebody else's). Edits buffer as pending changes; offer Fork rather than Save. Answers for the template rendered, so do not use it to gate per-row controls in a list — switcherGroups carries `editable` per row
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2317,7 +2317,7 @@ EditorStore:
- isOpen: boolean — the AI chat panel is open
- isStreaming: boolean — an assistant reply is arriving; streamingContent holds what has arrived so far
- streamingContent: string — the partial assistant reply while isStreaming, empty otherwise
- apiKeyConfigured: boolean — the agent has an API key set, so sendMessage can work. Gate the composer on it and say what is missing rather than hiding it
- apiKeyConfigured: boolean — the active provider has enough configuration to send requests. Gate the composer on it
- templateName: string — the name of the template being edited, for the editor’s own header
- templateIcon: string — its icon
- isReadOnly: boolean — the template on screen cannot be saved in place (a built-in, or somebody else's). Edits buffer as pending changes; offer Fork rather than Save. Answers for the template rendered, so do not use it to gate per-row controls in a list — switcherGroups carries `editable` per row
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2317,7 +2317,7 @@ EditorStore:
- isOpen: boolean — the AI chat panel is open
- isStreaming: boolean — an assistant reply is arriving; streamingContent holds what has arrived so far
- streamingContent: string — the partial assistant reply while isStreaming, empty otherwise
- apiKeyConfigured: boolean — the agent has an API key set, so sendMessage can work. Gate the composer on it and say what is missing rather than hiding it
- apiKeyConfigured: boolean — the active provider has enough configuration to send requests. Gate the composer on it
- templateName: string — the name of the template being edited, for the editor’s own header
- templateIcon: string — its icon
- isReadOnly: boolean — the template on screen cannot be saved in place (a built-in, or somebody else's). Edits buffer as pending changes; offer Fork rather than Save. Answers for the template rendered, so do not use it to gate per-row controls in a list — switcherGroups carries `editable` per row
Expand Down
18 changes: 17 additions & 1 deletion packages/ai-context/src/fragments/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1237,7 +1237,15 @@ export function generateStoresText(entries: StoreEntry[]): string {
isStreaming: 'boolean — an assistant reply is arriving; streamingContent holds what has arrived so far',
streamingContent: 'string — the partial assistant reply while isStreaming, empty otherwise',
apiKeyConfigured:
'boolean — the agent has an API key set, so sendMessage can work. Gate the composer on it and say what is missing rather than hiding it',
'boolean — the active provider has enough configuration to send requests. Gate the composer on it',
providers:
'AiProvider[] — all configured AI providers (id, name, baseUrl, apiKey, model, protocol, isBuiltIn). Persisted to localStorage',
activeProvider:
'AiProvider | undefined — the currently selected provider, derived from providers + activeProviderId',
activeProviderId: "string — id of the selected provider ('anthropic' by default)",
healthStatus: 'string (‘unknown’ | ‘checking’ | ‘ok’ | ‘error’) — reachability of the active provider',
healthError: 'string — human-readable error when healthStatus = error, empty otherwise',
availableModels: 'string[] — model IDs returned by the active provider during health check',
templateName: 'string — the name of the template being edited, for the editor’s own header',
templateIcon: 'string — its icon',
isReadOnly:
Expand Down Expand Up @@ -1274,6 +1282,14 @@ export function generateStoresText(entries: StoreEntry[]): string {
redo: '(): redoes the last undone schema edit',
open: '(): opens the AI chat panel',
close: '(): closes it',
setActiveProvider: '(id: string): switches the active AI provider by id',
updateProvider:
'(id: string, changes: Partial<AiProvider>): updates fields on an existing provider (e.g. apiKey, model, baseUrl)',
addProvider: '(provider: Omit<AiProvider, "isBuiltIn">): adds a custom (non-built-in) provider',
removeProvider:
'(id: string): removes a custom provider; built-in providers cannot be removed. Falls back to anthropic if the removed provider was active',
checkHealth:
'(): probes the active provider to verify reachability, auth, and model availability without spending tokens',
newChat: '(): starts a new AI session for this template and switches to it',
switchSession: '(sessionId: string): shows another saved session',
deleteSession: '(sessionId: string): deletes a saved session and its messages',
Expand Down
2 changes: 1 addition & 1 deletion packages/ai-context/src/schemaContext.ts

Large diffs are not rendered by default.

165 changes: 140 additions & 25 deletions packages/app-shell/src/frameworks/solid/stores/EditorStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,25 @@
* panel visibility and widths, preview/visual mode, unified template+theme undo/redo, pending
* (buffered) changes for read-only templates, and the fork/fresh picker.
*
* The AI half of a session is deliberately elsewhere: the Anthropic client, streaming, prompt
* assembly and tool definition live in `shared/ai/aiInfra`, and patch application in
* `shared/ai/schemaPatches` — this store orchestrates them against its own signals. That split is
* what keeps a future backend-executed assistant a drop-in: it would replace the infra modules
* and the `sendViaClaude` orchestration, not the session state.
* The AI half of a session is deliberately elsewhere: the multi-provider client, streaming, prompt
* assembly and tool definition live in `shared/ai/aiInfra`, provider persistence in
* `shared/ai/providers`, and patch application in `shared/ai/schemaPatches` — this store
* orchestrates them against its own signals. That split is what keeps a future backend-executed
* assistant a drop-in: it would replace the infra modules and the `sendViaProvider` orchestration,
* not the session state.
*/
import { formatExternalManifestForPrompt, sendClaudeRequest } from '@shared/ai/aiInfra';
import { formatExternalManifestForPrompt, sendProviderRequest } from '@shared/ai/aiInfra';
import {
type AiProvider,
checkProviderHealth,
getActiveProviderId,
type HealthStatus,
isProviderReady,
loadProviders,
migrateFromClaudeApiKey,
saveProviders,
setActiveProviderId,
} from '@shared/ai/providers';
import { applySchemaPatches, type SchemaPatch } from '@shared/ai/schemaPatches';
import { registerHostDockStore, unregisterHostDockStore } from '@shared/registries/dockRegistry';
import { EDITOR_STORE_ID } from '@shared/registries/editorDocks';
Expand Down Expand Up @@ -61,6 +73,19 @@ export interface EditorStore {
streamingContent: Accessor<string>;
apiKeyConfigured: Accessor<boolean>;

// --- Provider management ---
providers: Accessor<AiProvider[]>;
activeProvider: Accessor<AiProvider | undefined>;
activeProviderId: Accessor<string>;
setActiveProvider: (id: string) => void;
updateProvider: (id: string, changes: Partial<AiProvider>) => void;
addProvider: (provider: Omit<AiProvider, 'isBuiltIn'>) => void;
removeProvider: (id: string) => void;
healthStatus: Accessor<HealthStatus>;
healthError: Accessor<string>;
availableModels: Accessor<string[]>;
checkHealth: () => void;

// --- Template context ---
templateName: Accessor<string>;
templateIcon: Accessor<string>;
Expand Down Expand Up @@ -156,7 +181,7 @@ export interface EditorStore {
clearHistory: () => void;

// --- Settings ---
setApiKey: (key: string) => Promise<boolean>;
setApiKey: (key: string) => void;
}

/**
Expand Down Expand Up @@ -235,9 +260,42 @@ export function EditorStoreProvider(props: ParentProps) {
const [isOpen, setIsOpen] = createSignal(false);
const [isStreaming, setIsStreaming] = createSignal(false);
const [streamingContent, setStreamingContent] = createSignal('');
const [apiKey, setApiKeySignal] = createSignal('');

const apiKeyConfigured = () => apiKey().length > 0;
// --- Provider state (replaces single apiKey) ---
const [providersSignal, setProvidersSignal] = createSignal<AiProvider[]>(loadProviders());
const [activeProviderIdSignal, setActiveProviderIdSignal] = createSignal(getActiveProviderId());

const activeProvider = createMemo(() => providersSignal().find((p) => p.id === activeProviderIdSignal()));
const apiKeyConfigured = () => isProviderReady(activeProvider());

// --- Health check state ---
const [healthStatus, setHealthStatus] = createSignal<HealthStatus>('unknown');
const [healthError, setHealthError] = createSignal('');
const [availableModels, setAvailableModels] = createSignal<string[]>([]);

function checkHealth() {
const provider = activeProvider();
if (!provider) return;
setHealthStatus('checking');
setHealthError('');
checkProviderHealth(provider).then((result) => {
setHealthStatus(result.status);
setHealthError(result.error ?? '');
setAvailableModels(result.models ?? []);
});
}

// Re-check health when the active provider changes
createEffect(() => {
const p = activeProvider();
if (p && isProviderReady(p)) {
checkHealth();
} else {
setHealthStatus('unknown');
setHealthError('');
setAvailableModels([]);
}
});

// --- Session management ---
const [sessions, setSessions] = createSignal<ChatSessionRecord[]>([]);
Expand Down Expand Up @@ -741,20 +799,60 @@ export function EditorStoreProvider(props: ParentProps) {
);

// ----------------------------------------------------------------
// API key management (persisted to AgentSettings)
// Provider management (persisted to localStorage)
// ----------------------------------------------------------------
// Returns the write rather than dropping it, so a schema's `onError`/`onFinally` can fire and a
// caller can await. `updateAgentSettings` toasts a failure of its own; this is the other channel.
function setApiKey(key: string): Promise<boolean> {
setApiKeySignal(key);
return datasetStore.updateAgentSettings({ claudeApiKey: key });

function setActiveProviderAction(id: string) {
setActiveProviderIdSignal(id);
setActiveProviderId(id);
}

function updateProvider(id: string, changes: Partial<AiProvider>) {
setProvidersSignal((prev) => {
const next = prev.map((p) => (p.id === id ? { ...p, ...changes } : p));
saveProviders(next);
return next;
});
}

// Load persisted API key when agentSettings become available
function addProvider(provider: Omit<AiProvider, 'isBuiltIn'>) {
const newProvider: AiProvider = { ...provider, isBuiltIn: false };
setProvidersSignal((prev) => {
const next = [...prev, newProvider];
saveProviders(next);
return next;
});
}

function removeProvider(id: string) {
setProvidersSignal((prev) => {
const next = prev.filter((p) => p.id !== id || p.isBuiltIn);
saveProviders(next);
return next;
});
// If the removed provider was active, fall back to anthropic
if (activeProviderIdSignal() === id) {
setActiveProviderAction('anthropic');
}
}

/** Legacy: set the active provider's API key. Kept for backward compat with SessionPort. */
function setApiKey(key: string) {
const current = activeProvider();
if (current) {
updateProvider(current.id, { apiKey: key });
}
// Also persist to AgentSettings for legacy migration
datasetStore.updateAgentSettings({ claudeApiKey: key });
}

// Migrate from legacy claudeApiKey on first load
createEffect(() => {
const settings = datasetStore.agentSettings();
if (settings?.claudeApiKey) {
setApiKeySignal(settings.claudeApiKey);
const migrated = migrateFromClaudeApiKey(settings.claudeApiKey);
setProvidersSignal(migrated);
setActiveProviderIdSignal(getActiveProviderId());
}
});

Expand Down Expand Up @@ -849,7 +947,7 @@ export function EditorStoreProvider(props: ParentProps) {
setStreamingContent('<span class="shimmer">*Thinking...*</span>');

try {
await sendViaClaude(text);
await sendViaProvider(text);
} catch (err) {
console.error('[EditorStore] sendMessage caught error:', err);
const errorText = err instanceof Error ? err.message : 'Unknown error';
Expand Down Expand Up @@ -878,10 +976,10 @@ export function EditorStoreProvider(props: ParentProps) {
}

// ----------------------------------------------------------------
// Claude API path (client + streaming live in shared/ai/aiInfra)
// Provider API path (client + streaming live in shared/ai/aiInfra)
// ----------------------------------------------------------------

async function sendViaClaude(text: string) {
async function sendViaProvider(text: string) {
const claudeMessages: Array<{ role: string; content: unknown }> = buildClaudeMessages(text);

// Create a placeholder assistant message — shows streaming content as tokens arrive
Expand Down Expand Up @@ -917,10 +1015,13 @@ export function EditorStoreProvider(props: ParentProps) {
};

for (let turn = 0; turn <= maxContinuations; turn++) {
const provider = activeProvider();
if (!provider) throw new Error('No AI provider selected');

let streamResult;
try {
streamResult = await sendClaudeRequest(
apiKey(),
streamResult = await sendProviderRequest(
provider,
claudeMessages,
(accumulated) => {
const sep = allTextContent && accumulated ? '\n\n' : '';
Expand All @@ -933,7 +1034,7 @@ export function EditorStoreProvider(props: ParentProps) {
},
);
} catch (err) {
console.error(`[EditorStore] Turn ${turn}: sendClaudeRequest threw`, err);
console.error(`[EditorStore] Turn ${turn}: sendProviderRequest threw`, err);
throw err;
}
const { textContent, toolCalls, stopReason } = streamResult;
Expand Down Expand Up @@ -1166,9 +1267,10 @@ export function EditorStoreProvider(props: ParentProps) {
}

/**
* Build Claude messages array from chat history.
* Build conversation messages array from chat history (Anthropic format).
* The currentSchema is included in the latest user message so the AI
* always sees the current template state.
* always sees the current template state. The provider dispatcher
* converts to OpenAI format when needed.
*/
function buildClaudeMessages(latestText: string): Array<{ role: string; content: unknown }> {
const history: Array<{ role: string; content: unknown }> = [];
Expand Down Expand Up @@ -1316,6 +1418,19 @@ export function EditorStoreProvider(props: ParentProps) {
streamingContent,
apiKeyConfigured,

// Provider management
providers: providersSignal,
activeProvider,
activeProviderId: activeProviderIdSignal,
setActiveProvider: setActiveProviderAction,
updateProvider,
addProvider,
removeProvider,
healthStatus,
healthError,
availableModels,
checkHealth,

// Template context
templateName,
templateIcon,
Expand Down
Loading
Loading