ruflo/docs/adr/ADR-028-OPENAI-GPT5-INTEGRATION-COPY-BUTTON.md
Proposed
2026-02-26
The chat system currently only supports Google Gemini models. Users want the ability to switch to OpenAI GPT-5 as an alternative reasoning model. Additionally, users cannot copy full message content from assistant responses -- only code blocks have a copy button.
GeminiService -- useChat.ts references it directly via geminiServiceRef, making it impossible to swap providers without rewriting the hook.CodeBlock components have a copy button. Full assistant message content (workflow steps, FAQ answers, etc.) cannot be copied without manual text selection, which is often blocked by CSS or touch event handling.gpt-5) is the latest flagship reasoning model, offering strong instruction following and function calling.gpt-5-mini provides a cost-effective alternative with comparable quality for most workflow/FAQ queries.IAIProvider InterfaceCreate src/services/AIProvider.ts with a provider-agnostic interface:
export type AIProviderType = 'gemini' | 'openai';
export interface IAIProvider {
initialize(apiKey?: string): Promise<boolean>;
isInitialized(): boolean;
generate(request: GenerationRequest): Promise<GenerationResponse>;
generateStream(request: GenerationRequest, options?: StreamOptions): AsyncGenerator<StreamChunk>;
generateWithWorkflowContext(request: GenerationRequest, context: WorkflowContext | null): Promise<GenerationResponse>;
generateStreamWithWorkflowContext(request: GenerationRequest, context: WorkflowContext | null, options?: StreamOptions): AsyncGenerator<StreamChunk>;
getAvailableFunctions(): FunctionDeclaration[];
getModelInfo(): { name: string; provider: AIProviderType; maxTokens: number };
updateConfig(config: Record<string, unknown>): void;
}
Both GeminiService and a new OpenAIService will implement this interface. No behavior changes to existing Gemini code paths.
Extract from GeminiService.ts into src/services/sharedFunctions.ts:
getAvailableFunctions() (5 function declarations: run_simulation, get_forecast, query_database, manage_case, search_workflow) -- used identically by both providers.buildWorkflowEnhancedPrompt() -- workflow context injection logic (ADR-024) reused by both providers.This avoids duplicating ~180 lines of function declarations and prompt building across two service files.
OpenAIService ImplementationCreate src/services/OpenAIService.ts using the openai npm package (v4+):
| Concern | Approach |
|---|---|
| Client | new OpenAI({ apiKey, dangerouslyAllowBrowser: true }) -- same client-side key pattern as current Gemini setup |
| Model | gpt-5-mini default (cost-effective), gpt-5 available |
| System prompt | Native role: 'system' message (cleaner than Gemini's user-message workaround) |
| Function calling | Wrap FunctionDeclaration[] into OpenAI tools format: { type: 'function', function: { name, description, parameters } } |
| Streaming | Standard stream: true on chat completions; accumulate tool_calls deltas before parsing |
| Workflow context | Uses shared buildWorkflowEnhancedPrompt() injected as system message |
| Rate limiting | Same exponential backoff retry pattern as GeminiService.withRetry() |
| Grounding | Not supported (Gemini-only feature); falls back to standard generation |
AIProviderManager SingletonCreate src/services/AIProviderManager.ts:
class AIProviderManager {
private providers: Map<AIProviderType, IAIProvider>;
private activeProvider: AIProviderType;
getProvider(): IAIProvider;
switchProvider(type: AIProviderType, model?: string): Promise<void>;
getActiveProviderType(): AIProviderType;
}
Handles lazy initialization -- providers are only created/initialized when first selected.
useChat.ts ChangesReplace geminiServiceRef with aiProviderRef pointing to AIProviderManager.getProvider(). ~15 call sites change from geminiServiceRef.current.X() to aiProviderRef.current.X():
isInitialized(), generate(), generateStream(), generateWithWorkflowContext(), generateStreamWithWorkflowContext(), getAvailableFunctions(), getModelInfo()New exports: currentProvider: AIProviderType, currentModel: string, switchProvider(type, model).
Add an AI Model dropdown to the Settings accordion in SideMenu.tsx, after the Dark Mode toggle:
AI Model
┌─────────────────────┐
│ Gemini 2.5 Flash ▼ │ ← HeroUI Select dropdown
├─────────────────────┤
│ Gemini 2.5 Flash │ Fast, cost-effective (default)
│ Gemini 2.5 Pro │ Best Gemini reasoning
│ GPT-5 mini │ OpenAI GPT-5 mini
│ GPT-5 │ OpenAI GPT-5 flagship
└─────────────────────┘
Selection persisted to localStorage('ai-model-preference') so it survives page refresh. The SideMenuProps interface gains:
currentModel?: string;
onModelChange?: (providerType: AIProviderType, model: string) => void;
Both ChatDashboard and DevChatDashboard pass currentProvider, currentModel, and switchProvider from useChat down to SideMenu. The metrics panel model display updates from hardcoded "Gemini 2.5" to currentModel.
Add a copy icon button to every assistant message bubble in MessageBubble.tsx:
┌──────────────────────────────────┐
│ Great question -- here's... │
│ │
│ 1. First step... │
│ 2. Second step... │
│ │ [clipboard icon]
└──────────────────────────────────┘
Implementation:
Copy / Check icons (already imported in MessageBubble.tsx)navigator.clipboard.writeText(message.content) -- same pattern as existing CodeBlock copymd:opacity-0 md:group-hover:opacity-100)The OpenAI API key must NOT be hardcoded in the Dockerfile (unlike the Gemini key which currently is -- a separate concern).
# Create secret
echo -n "sk-..." | gcloud secrets create openai-api-key --data-file=- --project=new-project-473022
# Grant Cloud Build service account access
gcloud secrets add-iam-policy-binding openai-api-key \
--member="serviceAccount:[email protected]" \
--role="roles/secretmanager.secretAccessor" \
--project=new-project-473022
cloudbuild.yaml ChangesavailableSecrets:
secretManager:
- versionName: projects/new-project-473022/secrets/openai-api-key/versions/latest
env: 'OPENAI_API_KEY'
steps:
- name: 'gcr.io/cloud-builders/docker'
args:
- 'build'
- '--build-arg'
- 'OPENAI_API_KEY=$$OPENAI_API_KEY'
# ... existing args
secretEnv: ['OPENAI_API_KEY']
# After existing Gemini env vars
ARG OPENAI_API_KEY
ENV VITE_OPENAI_API_KEY=${OPENAI_API_KEY}
package.json Changes"dependencies": {
"openai": "^4.78.0"
}
| File | Purpose | ~Lines |
|---|---|---|
src/services/AIProvider.ts | IAIProvider interface + AIProviderType | ~40 |
src/services/OpenAIService.ts | OpenAI GPT-5 implementation | ~450 |
src/services/AIProviderManager.ts | Provider switching singleton | ~70 |
src/services/sharedFunctions.ts | Extracted function declarations + workflow prompt builder | ~180 |
| File | Change |
|---|---|
src/types/chat.ts | Add AIProviderType, OpenAIConfig types |
src/services/GeminiService.ts | implements IAIProvider, import shared functions |
src/hooks/useChat.ts | geminiServiceRef -> aiProviderRef, expose switchProvider |
src/components/SideMenu.tsx | Add AI Model selector dropdown, new props |
src/components/MessageBubble.tsx | Add copy button on assistant messages |
src/App.tsx | Wire provider state/switch to SideMenu, update model display |
Dockerfile | Add ARG OPENAI_API_KEY, ENV VITE_OPENAI_API_KEY |
cloudbuild.yaml | Add availableSecrets + --build-arg for OpenAI key |
package.json | Add openai dependency |
IAIProvider makes adding future providers (Claude, etc.) trivial.generateWithGrounding) won't work with OpenAI. The UI should indicate this.openai package adds ~50KB gzipped to the client bundle.| Provider | Model | Use Case | Context Window |
|---|---|---|---|
gemini-2.5-flash | Fast, cost-effective (default) | 1M tokens | |
gemini-2.5-pro | Best Gemini reasoning | 1M tokens | |
| OpenAI | gpt-5-mini | GPT-5 mini reasoning | 1M tokens |
| OpenAI | gpt-5 | GPT-5 flagship | 1M tokens |
npm run build succeeds with no type errors