mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
refactor: organize model definitions and flavor capabilities into dedicated modules (#400)
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { getFlavorLabel, isKnownFlavor } from '@hapi/protocol'
|
||||
import type { Session } from '../sync/syncEngine'
|
||||
|
||||
export function getSessionName(session: Session): string {
|
||||
@@ -12,10 +13,6 @@ export function getSessionName(session: Session): string {
|
||||
|
||||
export function getAgentName(session: Session): string {
|
||||
const flavor = session.metadata?.flavor
|
||||
if (flavor === 'claude') return 'Claude'
|
||||
if (flavor === 'codex') return 'Codex'
|
||||
if (flavor === 'cursor') return 'Cursor'
|
||||
if (flavor === 'gemini') return 'Gemini'
|
||||
if (flavor === 'opencode') return 'OpenCode'
|
||||
return 'Agent'
|
||||
if (!flavor || !isKnownFlavor(flavor)) return 'Agent'
|
||||
return getFlavorLabel(flavor)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
Capabilities,
|
||||
getFlavorLabel,
|
||||
hasCapability,
|
||||
isKnownFlavor,
|
||||
supportsEffort,
|
||||
supportsModelChange,
|
||||
} from './flavors'
|
||||
|
||||
describe('hasCapability', () => {
|
||||
test('claude supports model-change', () => {
|
||||
expect(hasCapability('claude', Capabilities.ModelChange)).toBe(true)
|
||||
})
|
||||
|
||||
test('claude supports effort', () => {
|
||||
expect(hasCapability('claude', Capabilities.Effort)).toBe(true)
|
||||
})
|
||||
|
||||
test('gemini supports model-change but not effort', () => {
|
||||
expect(hasCapability('gemini', Capabilities.ModelChange)).toBe(true)
|
||||
expect(hasCapability('gemini', Capabilities.Effort)).toBe(false)
|
||||
})
|
||||
|
||||
test('codex has no capabilities', () => {
|
||||
expect(hasCapability('codex', Capabilities.ModelChange)).toBe(false)
|
||||
expect(hasCapability('codex', Capabilities.Effort)).toBe(false)
|
||||
})
|
||||
|
||||
test('cursor has no capabilities', () => {
|
||||
expect(hasCapability('cursor', Capabilities.ModelChange)).toBe(false)
|
||||
expect(hasCapability('cursor', Capabilities.Effort)).toBe(false)
|
||||
})
|
||||
|
||||
test('opencode has no capabilities', () => {
|
||||
expect(hasCapability('opencode', Capabilities.ModelChange)).toBe(false)
|
||||
expect(hasCapability('opencode', Capabilities.Effort)).toBe(false)
|
||||
})
|
||||
|
||||
test('unknown flavor returns false', () => {
|
||||
expect(hasCapability('unknown-flavor', Capabilities.ModelChange)).toBe(false)
|
||||
})
|
||||
|
||||
test('null/undefined flavor returns false', () => {
|
||||
expect(hasCapability(null, Capabilities.ModelChange)).toBe(false)
|
||||
expect(hasCapability(undefined, Capabilities.ModelChange)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFlavorLabel', () => {
|
||||
test('known flavors return display names', () => {
|
||||
expect(getFlavorLabel('claude')).toBe('Claude')
|
||||
expect(getFlavorLabel('gemini')).toBe('Gemini')
|
||||
expect(getFlavorLabel('codex')).toBe('Codex')
|
||||
expect(getFlavorLabel('cursor')).toBe('Cursor')
|
||||
expect(getFlavorLabel('opencode')).toBe('OpenCode')
|
||||
})
|
||||
|
||||
test('unknown flavor returns Unknown', () => {
|
||||
expect(getFlavorLabel('some-new-cli')).toBe('Unknown')
|
||||
})
|
||||
|
||||
test('null/undefined returns Unknown', () => {
|
||||
expect(getFlavorLabel(null)).toBe('Unknown')
|
||||
expect(getFlavorLabel(undefined)).toBe('Unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isKnownFlavor', () => {
|
||||
test('returns true for registered flavors', () => {
|
||||
expect(isKnownFlavor('claude')).toBe(true)
|
||||
expect(isKnownFlavor('gemini')).toBe(true)
|
||||
expect(isKnownFlavor('codex')).toBe(true)
|
||||
expect(isKnownFlavor('cursor')).toBe(true)
|
||||
expect(isKnownFlavor('opencode')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for unknown/null/undefined', () => {
|
||||
expect(isKnownFlavor('foo')).toBe(false)
|
||||
expect(isKnownFlavor(null)).toBe(false)
|
||||
expect(isKnownFlavor(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('convenience functions', () => {
|
||||
test('supportsModelChange matches hasCapability', () => {
|
||||
expect(supportsModelChange('claude')).toBe(true)
|
||||
expect(supportsModelChange('gemini')).toBe(true)
|
||||
expect(supportsModelChange('cursor')).toBe(false)
|
||||
expect(supportsModelChange(null)).toBe(false)
|
||||
})
|
||||
|
||||
test('supportsEffort matches hasCapability', () => {
|
||||
expect(supportsEffort('claude')).toBe(true)
|
||||
expect(supportsEffort('codex')).toBe(false)
|
||||
expect(supportsEffort('gemini')).toBe(false)
|
||||
expect(supportsEffort(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { AgentFlavor } from './modes'
|
||||
|
||||
// --- Capability constants (prevent literal scattering) ---
|
||||
export const Capabilities = {
|
||||
ModelChange: 'model-change',
|
||||
Effort: 'effort',
|
||||
} as const
|
||||
|
||||
export type Capability = typeof Capabilities[keyof typeof Capabilities]
|
||||
|
||||
// --- Per-flavor capability sets ---
|
||||
const FLAVOR_CAPS: Record<AgentFlavor, ReadonlySet<Capability>> = {
|
||||
claude: new Set([Capabilities.ModelChange, Capabilities.Effort]),
|
||||
gemini: new Set([Capabilities.ModelChange]),
|
||||
codex: new Set([]),
|
||||
cursor: new Set([]),
|
||||
opencode: new Set([]),
|
||||
}
|
||||
|
||||
// --- Flavor display names ---
|
||||
const FLAVOR_LABELS: Record<AgentFlavor, string> = {
|
||||
claude: 'Claude',
|
||||
gemini: 'Gemini',
|
||||
codex: 'Codex',
|
||||
cursor: 'Cursor',
|
||||
opencode: 'OpenCode',
|
||||
}
|
||||
|
||||
// --- Query functions ---
|
||||
export function isKnownFlavor(flavor: string | null | undefined): flavor is AgentFlavor {
|
||||
return typeof flavor === 'string' && Object.hasOwn(FLAVOR_CAPS, flavor)
|
||||
}
|
||||
|
||||
export function hasCapability(flavor: string | null | undefined, cap: Capability): boolean {
|
||||
if (!isKnownFlavor(flavor)) return false
|
||||
return FLAVOR_CAPS[flavor].has(cap)
|
||||
}
|
||||
|
||||
export function getFlavorLabel(flavor: string | null | undefined): string {
|
||||
if (!isKnownFlavor(flavor)) return 'Unknown'
|
||||
return FLAVOR_LABELS[flavor]
|
||||
}
|
||||
|
||||
// --- Convenience functions ---
|
||||
export function supportsModelChange(flavor: string | null | undefined): boolean {
|
||||
return hasCapability(flavor, Capabilities.ModelChange)
|
||||
}
|
||||
|
||||
export function supportsEffort(flavor: string | null | undefined): boolean {
|
||||
return hasCapability(flavor, Capabilities.Effort)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './messages'
|
||||
export * from './flavors'
|
||||
export * from './models'
|
||||
export * from './modes'
|
||||
export * from './socket'
|
||||
export * from './sessionSummary'
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
CLAUDE_MODEL_PRESETS,
|
||||
CLAUDE_MODEL_LABELS,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
GEMINI_MODEL_LABELS,
|
||||
GEMINI_MODEL_PRESETS,
|
||||
getClaudeModelLabel,
|
||||
isClaudeModelPreset,
|
||||
} from './models'
|
||||
|
||||
describe('isClaudeModelPreset', () => {
|
||||
test('accepts valid presets', () => {
|
||||
for (const preset of CLAUDE_MODEL_PRESETS) {
|
||||
expect(isClaudeModelPreset(preset)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects unknown model string', () => {
|
||||
expect(isClaudeModelPreset('haiku')).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects null and undefined', () => {
|
||||
expect(isClaudeModelPreset(null)).toBe(false)
|
||||
expect(isClaudeModelPreset(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getClaudeModelLabel', () => {
|
||||
test('returns label for known presets', () => {
|
||||
expect(getClaudeModelLabel('sonnet')).toBe('Sonnet')
|
||||
expect(getClaudeModelLabel('opus')).toBe('Opus')
|
||||
expect(getClaudeModelLabel('opus[1m]')).toBe('Opus 1M')
|
||||
})
|
||||
|
||||
test('trims whitespace before lookup', () => {
|
||||
expect(getClaudeModelLabel(' sonnet ')).toBe('Sonnet')
|
||||
})
|
||||
|
||||
test('returns null for unknown model', () => {
|
||||
expect(getClaudeModelLabel('haiku')).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null for empty/whitespace-only string', () => {
|
||||
expect(getClaudeModelLabel('')).toBeNull()
|
||||
expect(getClaudeModelLabel(' ')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('model constants consistency', () => {
|
||||
test('every CLAUDE_MODEL_PRESET has a label', () => {
|
||||
for (const preset of CLAUDE_MODEL_PRESETS) {
|
||||
expect(CLAUDE_MODEL_LABELS[preset]).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
test('every GEMINI_MODEL_PRESET has a label', () => {
|
||||
for (const preset of GEMINI_MODEL_PRESETS) {
|
||||
expect(GEMINI_MODEL_LABELS[preset]).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
test('DEFAULT_GEMINI_MODEL is a valid preset', () => {
|
||||
expect(GEMINI_MODEL_PRESETS).toContain(DEFAULT_GEMINI_MODEL)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
export const CLAUDE_MODEL_LABELS = {
|
||||
sonnet: 'Sonnet',
|
||||
'sonnet[1m]': 'Sonnet 1M',
|
||||
opus: 'Opus',
|
||||
'opus[1m]': 'Opus 1M'
|
||||
} as const
|
||||
|
||||
export type ClaudeModelPreset = keyof typeof CLAUDE_MODEL_LABELS
|
||||
export const CLAUDE_MODEL_PRESETS = Object.keys(CLAUDE_MODEL_LABELS) as ClaudeModelPreset[]
|
||||
|
||||
export const GEMINI_MODEL_LABELS = {
|
||||
'gemini-3.1-pro-preview': 'Gemini 3.1 Pro Preview',
|
||||
'gemini-3-flash-preview': 'Gemini 3 Flash Preview',
|
||||
'gemini-2.5-pro': 'Gemini 2.5 Pro',
|
||||
'gemini-2.5-flash': 'Gemini 2.5 Flash',
|
||||
'gemini-2.5-flash-lite': 'Gemini 2.5 Flash Lite',
|
||||
} as const
|
||||
|
||||
export type GeminiModelPreset = keyof typeof GEMINI_MODEL_LABELS
|
||||
export const GEMINI_MODEL_PRESETS = Object.keys(GEMINI_MODEL_LABELS) as GeminiModelPreset[]
|
||||
export const DEFAULT_GEMINI_MODEL: GeminiModelPreset = 'gemini-2.5-pro'
|
||||
|
||||
export function isClaudeModelPreset(model: string | null | undefined): model is ClaudeModelPreset {
|
||||
return typeof model === 'string' && Object.hasOwn(CLAUDE_MODEL_LABELS, model)
|
||||
}
|
||||
|
||||
export function getClaudeModelLabel(model: string): string | null {
|
||||
const trimmedModel = model.trim()
|
||||
if (!trimmedModel) {
|
||||
return null
|
||||
}
|
||||
|
||||
return CLAUDE_MODEL_LABELS[trimmedModel as ClaudeModelPreset] ?? null
|
||||
}
|
||||
@@ -35,23 +35,8 @@ export const PERMISSION_MODES = [
|
||||
] as const
|
||||
export type PermissionMode = typeof PERMISSION_MODES[number]
|
||||
|
||||
export const CLAUDE_MODEL_PRESETS = ['sonnet', 'sonnet[1m]', 'opus', 'opus[1m]'] as const
|
||||
export type ClaudeModelPreset = typeof CLAUDE_MODEL_PRESETS[number]
|
||||
|
||||
export type AgentFlavor = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor'
|
||||
|
||||
export const GEMINI_MODEL_LABELS = {
|
||||
'gemini-3.1-pro-preview': 'Gemini 3.1 Pro Preview',
|
||||
'gemini-3-flash-preview': 'Gemini 3 Flash Preview',
|
||||
'gemini-2.5-pro': 'Gemini 2.5 Pro',
|
||||
'gemini-2.5-flash': 'Gemini 2.5 Flash',
|
||||
'gemini-2.5-flash-lite': 'Gemini 2.5 Flash Lite',
|
||||
} as const
|
||||
|
||||
export type GeminiModelPreset = keyof typeof GEMINI_MODEL_LABELS
|
||||
export const GEMINI_MODEL_PRESETS = Object.keys(GEMINI_MODEL_LABELS) as GeminiModelPreset[]
|
||||
export const DEFAULT_GEMINI_MODEL: GeminiModelPreset = 'gemini-2.5-pro'
|
||||
|
||||
export const PERMISSION_MODE_LABELS: Record<PermissionMode, string> = {
|
||||
default: 'Default',
|
||||
acceptEdits: 'Accept Edits',
|
||||
@@ -87,31 +72,11 @@ export type CodexCollaborationModeOption = {
|
||||
label: string
|
||||
}
|
||||
|
||||
export const CLAUDE_MODEL_LABELS: Record<ClaudeModelPreset, string> = {
|
||||
sonnet: 'Sonnet',
|
||||
'sonnet[1m]': 'Sonnet 1M',
|
||||
opus: 'Opus',
|
||||
'opus[1m]': 'Opus 1M'
|
||||
}
|
||||
|
||||
export const CODEX_COLLABORATION_MODE_LABELS: Record<CodexCollaborationMode, string> = {
|
||||
default: 'Default',
|
||||
plan: 'Plan'
|
||||
}
|
||||
|
||||
export function isClaudeModelPreset(model: string | null | undefined): model is ClaudeModelPreset {
|
||||
return typeof model === 'string' && CLAUDE_MODEL_PRESETS.includes(model as ClaudeModelPreset)
|
||||
}
|
||||
|
||||
export function getClaudeModelLabel(model: string): string | null {
|
||||
const trimmedModel = model.trim()
|
||||
if (!trimmedModel) {
|
||||
return null
|
||||
}
|
||||
|
||||
return CLAUDE_MODEL_LABELS[trimmedModel as ClaudeModelPreset] ?? null
|
||||
}
|
||||
|
||||
export function getPermissionModeLabel(mode: PermissionMode): string {
|
||||
return PERMISSION_MODE_LABELS[mode]
|
||||
}
|
||||
|
||||
+2
-1
@@ -27,8 +27,9 @@ export type {
|
||||
CursorPermissionMode,
|
||||
GeminiPermissionMode,
|
||||
OpencodePermissionMode,
|
||||
ClaudeModelPreset,
|
||||
PermissionMode,
|
||||
PermissionModeOption,
|
||||
PermissionModeTone
|
||||
} from './modes'
|
||||
|
||||
export type { ClaudeModelPreset, GeminiModelPreset } from './models'
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useActiveSuggestions } from '@/hooks/useActiveSuggestions'
|
||||
import { applySuggestion } from '@/utils/applySuggestion'
|
||||
import { usePlatform } from '@/hooks/usePlatform'
|
||||
import { usePWAInstall } from '@/hooks/usePWAInstall'
|
||||
import { isClaudeFlavor, supportsModelChange } from '@/lib/agentFlavorUtils'
|
||||
import { supportsEffort, supportsModelChange } from '@hapi/protocol'
|
||||
import { markSkillUsed } from '@/lib/recent-skills'
|
||||
import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay'
|
||||
import { Autocomplete } from '@/components/ChatInput/Autocomplete'
|
||||
@@ -440,7 +440,7 @@ export function HappyComposer(props: {
|
||||
const showCollaborationSettings = Boolean(onCollaborationModeChange && collaborationModeOptions.length > 0)
|
||||
const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0)
|
||||
const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor))
|
||||
const showEffortSettings = Boolean(onEffortChange && isClaudeFlavor(agentFlavor))
|
||||
const showEffortSettings = Boolean(onEffortChange && supportsEffort(agentFlavor))
|
||||
const showSettingsButton = Boolean(showCollaborationSettings || showPermissionSettings || showModelSettings || showEffortSettings)
|
||||
const showAbortButton = true
|
||||
const voiceEnabled = Boolean(onVoiceToggle)
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
// Re-export from shared package for backwards compatibility
|
||||
export { isKnownFlavor, supportsModelChange, supportsEffort } from '@hapi/protocol'
|
||||
|
||||
// Flavor-family helper not yet in shared — keep here until next migration
|
||||
export function isCodexFamilyFlavor(flavor?: string | null): boolean {
|
||||
return flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode'
|
||||
}
|
||||
|
||||
export function isClaudeFlavor(flavor?: string | null): boolean {
|
||||
return flavor === 'claude'
|
||||
}
|
||||
|
||||
export function isCursorFlavor(flavor?: string | null): boolean {
|
||||
return flavor === 'cursor'
|
||||
}
|
||||
|
||||
export function isKnownFlavor(flavor?: string | null): boolean {
|
||||
return isClaudeFlavor(flavor) || isCodexFamilyFlavor(flavor) || isCursorFlavor(flavor)
|
||||
}
|
||||
|
||||
export function supportsModelChange(flavor?: string | null): boolean {
|
||||
return flavor === 'claude' || flavor === 'gemini'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user