33import { useState , useEffect , useRef } from 'react' ;
44import { useRouter } from 'next/navigation' ;
55
6- type Tab = 'schedules' | 'teams' ;
6+ type Tab = 'schedules' | 'teams' | 'llm' ;
77
88const CADENCE_PRESETS = [
99 { label : 'Every hour' , cron : '0 * * * *' } ,
@@ -64,6 +64,7 @@ export default function SettingsPage() {
6464 { ( [
6565 { id : 'schedules' as Tab , label : 'Schedules' , icon : '🕐' } ,
6666 { id : 'teams' as Tab , label : 'Teams' , icon : '👥' } ,
67+ { id : 'llm' as Tab , label : 'LLM Settings' , icon : '🤖' } ,
6768 ] ) . map ( tab => (
6869 < button
6970 key = { tab . id }
@@ -83,6 +84,7 @@ export default function SettingsPage() {
8384 { /* Tab Content */ }
8485 { activeTab === 'schedules' && < SchedulesTab /> }
8586 { activeTab === 'teams' && selectedOrg && < TeamsTab org = { selectedOrg } /> }
87+ { activeTab === 'llm' && < LlmSettingsTab /> }
8688 </ div >
8789 ) ;
8890}
@@ -716,3 +718,161 @@ function TeamsTab({ org }: { org: string }) {
716718 </ div >
717719 ) ;
718720}
721+
722+ /* ── LLM Settings Tab ── */
723+ const PROVIDER_INFO : Record < string , { name : string ; docs : string ; envVars : string [ ] } > = {
724+ openai : {
725+ name : 'OpenAI' ,
726+ docs : 'https://platform.openai.com/api-keys' ,
727+ envVars : [ 'LLM_PROVIDER=openai' , 'LLM_API_KEY=sk-...' , 'LLM_MODEL=gpt-4o' ] ,
728+ } ,
729+ anthropic : {
730+ name : 'Anthropic' ,
731+ docs : 'https://console.anthropic.com/settings/keys' ,
732+ envVars : [ 'LLM_PROVIDER=anthropic' , 'LLM_API_KEY=sk-ant-...' , 'LLM_MODEL=claude-sonnet-4-20250514' ] ,
733+ } ,
734+ 'openai-compatible' : {
735+ name : 'OpenAI-Compatible (Ollama, vLLM, Azure)' ,
736+ docs : '' ,
737+ envVars : [ 'LLM_PROVIDER=openai-compatible' , 'LLM_BASE_URL=http://localhost:11434/v1' , 'LLM_MODEL=llama3' , 'LLM_API_KEY=not-needed' ] ,
738+ } ,
739+ smartling : {
740+ name : 'Smartling AI Proxy' ,
741+ docs : '' ,
742+ envVars : [ 'LLM_PROVIDER=smartling' , 'SMARTLING_BASE_URL=https://api.smartling.com' , 'SMARTLING_ACCOUNT_UID=...' , 'SMARTLING_USER_IDENTIFIER=...' , 'SMARTLING_USER_SECRET=...' , 'LLM_MODEL=anthropic/claude-sonnet-4-20250514' ] ,
743+ } ,
744+ } ;
745+
746+ function LlmSettingsTab ( ) {
747+ const [ config , setConfig ] = useState < any > ( null ) ;
748+ const [ loading , setLoading ] = useState ( true ) ;
749+ const [ testing , setTesting ] = useState ( false ) ;
750+ const [ testResult , setTestResult ] = useState < any > ( null ) ;
751+
752+ useEffect ( ( ) => {
753+ fetch ( '/api/llm-config' ) . then ( r => r . json ( ) ) . then ( setConfig ) . catch ( ( ) => { } ) . finally ( ( ) => setLoading ( false ) ) ;
754+ } , [ ] ) ;
755+
756+ async function testConnection ( ) {
757+ setTesting ( true ) ;
758+ setTestResult ( null ) ;
759+ try {
760+ const res = await fetch ( '/api/llm-config' , { method : 'POST' } ) ;
761+ const data = await res . json ( ) ;
762+ setTestResult ( data ) ;
763+ } catch {
764+ setTestResult ( { success : false , error : 'Network error' } ) ;
765+ }
766+ setTesting ( false ) ;
767+ }
768+
769+ if ( loading ) return < div className = "text-gray-500 text-sm py-8" > Loading configuration...</ div > ;
770+ if ( ! config ) return < div className = "text-red-400 text-sm py-8" > Failed to load configuration.</ div > ;
771+
772+ const info = PROVIDER_INFO [ config . provider ] || PROVIDER_INFO [ 'openai' ] ;
773+
774+ return (
775+ < div >
776+ < p className = "text-sm text-gray-400 mb-6" > Current LLM configuration (read-only). Edit < code className = "text-xs bg-gray-800 px-1.5 py-0.5 rounded" > .env.local</ code > to change settings.</ p >
777+
778+ { /* Status */ }
779+ < div className = { `rounded-xl p-4 mb-6 flex items-center gap-3 ${ config . ready ? 'bg-green-500/10 border border-green-500/20' : 'bg-amber-500/10 border border-amber-500/20' } ` } >
780+ < span className = "text-xl" > { config . ready ? '✅' : '⚠️' } </ span >
781+ < div >
782+ < p className = { `text-sm font-semibold ${ config . ready ? 'text-green-400' : 'text-amber-400' } ` } >
783+ { config . ready ? 'LLM is configured and ready' : 'Configuration incomplete' }
784+ </ p >
785+ { ! config . ready && (
786+ < p className = "text-xs text-gray-500 mt-0.5" >
787+ Missing: { config . missing . map ( ( v : string ) => < code key = { v } className = "bg-gray-800 px-1 py-0.5 rounded text-amber-300 mx-0.5" > { v } </ code > ) }
788+ </ p >
789+ ) }
790+ </ div >
791+ </ div >
792+
793+ { /* Config details */ }
794+ < div className = "bg-gray-900 rounded-xl p-5 mb-6" >
795+ < div className = "grid grid-cols-3 gap-4" >
796+ < ConfigRow label = "Provider" value = { info . name } />
797+ < ConfigRow label = "Model" value = { config . model } />
798+ < ConfigRow label = "Concurrency" value = { String ( config . concurrency ) } />
799+ </ div >
800+ </ div >
801+
802+ { /* Test connection */ }
803+ < div className = "bg-gray-900 rounded-xl p-5 mb-6" >
804+ < div className = "flex items-center justify-between mb-3" >
805+ < p className = "text-xs text-gray-500 uppercase tracking-wider font-semibold" > Connection Test</ p >
806+ < button
807+ onClick = { testConnection }
808+ disabled = { testing || ! config . ready }
809+ className = "px-3 py-1.5 text-xs font-medium bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg transition-colors flex items-center gap-2"
810+ >
811+ { testing && (
812+ < svg className = "w-3.5 h-3.5 animate-spin" fill = "none" viewBox = "0 0 24 24" >
813+ < circle className = "opacity-25" cx = "12" cy = "12" r = "10" stroke = "currentColor" strokeWidth = "4" />
814+ < path className = "opacity-75" fill = "currentColor" d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
815+ </ svg >
816+ ) }
817+ { testing ? 'Testing...' : 'Test Connection' }
818+ </ button >
819+ </ div >
820+ { testResult && (
821+ < div className = { `rounded-lg p-3 text-sm ${ testResult . success ? 'bg-green-500/10 border border-green-500/20' : 'bg-red-500/10 border border-red-500/20' } ` } >
822+ { testResult . success ? (
823+ < div className = "flex items-center gap-2 flex-wrap" >
824+ < span className = "text-green-400 font-semibold" > Success</ span >
825+ < span className = "text-gray-500" > ·</ span >
826+ < span className = "text-gray-400" > Model: { testResult . model } </ span >
827+ < span className = "text-gray-500" > ·</ span >
828+ < span className = "text-gray-400" > { testResult . latencyMs } ms</ span >
829+ < span className = "text-gray-500" > ·</ span >
830+ < span className = "text-gray-500 font-mono text-xs" > "{ testResult . response } "</ span >
831+ </ div >
832+ ) : (
833+ < div >
834+ < span className = "text-red-400 font-semibold" > Failed</ span >
835+ < span className = "text-gray-500 ml-2" > ({ testResult . latencyMs } ms)</ span >
836+ < p className = "text-xs text-red-300/70 mt-1 font-mono" > { testResult . error } </ p >
837+ </ div >
838+ ) }
839+ </ div >
840+ ) }
841+ { ! testResult && ! testing && (
842+ < p className = "text-xs text-gray-600" > Sends a simple test message to verify the LLM connection is working.</ p >
843+ ) }
844+ </ div >
845+
846+ { /* Setup instructions */ }
847+ { ! config . ready && (
848+ < div className = "bg-gray-900 rounded-xl p-5" >
849+ < p className = "text-xs text-gray-500 uppercase tracking-wider font-semibold mb-3" > Setup Instructions</ p >
850+ < p className = "text-sm text-gray-400 mb-3" >
851+ Add the following to your < code className = "text-xs bg-gray-800 px-1.5 py-0.5 rounded" > .env.local</ code > file:
852+ </ p >
853+ < pre className = "bg-gray-950 rounded-lg p-4 text-xs text-gray-300 font-mono leading-relaxed overflow-x-auto" >
854+ { info . envVars . join ( '\n' ) }
855+ </ pre >
856+ { info . docs && (
857+ < p className = "text-xs text-gray-500 mt-3" >
858+ Get your API key: < a href = { info . docs } target = "_blank" rel = "noopener noreferrer" className = "text-blue-400 hover:text-blue-300" > { info . docs } </ a >
859+ </ p >
860+ ) }
861+ </ div >
862+ ) }
863+ </ div >
864+ ) ;
865+ }
866+
867+ function ConfigRow ( { label, value, status } : { label : string ; value : string ; status ?: 'ok' | 'missing' } ) {
868+ return (
869+ < div >
870+ < p className = "text-xs text-gray-500 mb-0.5" > { label } </ p >
871+ < div className = "flex items-center gap-2" >
872+ < p className = "text-sm text-white font-mono" > { value } </ p >
873+ { status === 'ok' && < span className = "w-1.5 h-1.5 rounded-full bg-green-400" /> }
874+ { status === 'missing' && < span className = "w-1.5 h-1.5 rounded-full bg-amber-400" /> }
875+ </ div >
876+ </ div >
877+ ) ;
878+ }
0 commit comments