-
Notifications
You must be signed in to change notification settings - Fork 0
feat: LLM coaching with Groq + settings UI + code cleanup #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
baf2ceb
00faf1d
e51b52a
636e171
0868281
1b3cf27
49de449
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| -----BEGIN CERTIFICATE----- | ||
| MIIEIDCCAwgCCQDJC+QAdVx4UDANBgkqhkiG9w0BAQUFADCB0TELMAkGA1UEBhMC | ||
| VVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFTATBgNVBAcTDFNhbnRhIE1vbmljYTET | ||
| MBEGA1UEChMKUmlvdCBHYW1lczEdMBsGA1UECxMUTG9MIEdhbWUgRW5naW5lZXJp | ||
| bmcxMzAxBgNVBAMTKkxvTCBHYW1lIEVuZ2luZWVyaW5nIENlcnRpZmljYXRlIEF1 | ||
| dGhvcml0eTEtMCsGCSqGSIb3DQEJARYeZ2FtZXRlY2hub2xvZ2llc0ByaW90Z2Ft | ||
| ZXMuY29tMB4XDTEzMTIwNDAwNDgzOVoXDTQzMTEyNzAwNDgzOVowgdExCzAJBgNV | ||
| BAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRUwEwYDVQQHEwxTYW50YSBNb25p | ||
| Y2ExEzARBgNVBAoTClJpb3QgR2FtZXMxHTAbBgNVBAsTFExvTCBHYW1lIEVuZ2lu | ||
| ZWVyaW5nMTMwMQYDVQQDEypMb0wgR2FtZSBFbmdpbmVlcmluZyBDZXJ0aWZpY2F0 | ||
| ZSBBdXRob3JpdHkxLTArBgkqhkiG9w0BCQEWHmdhbWV0ZWNobm9sb2dpZXNAcmlv | ||
| dGdhbWVzLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKoJemF/ | ||
| 6PNG3GRJGbjzImTdOo1OJRDI7noRwJgDqkaJFkwv0X8aPUGbZSUzUO23cQcCgpYj | ||
| 21ygzKu5dtCN2EcQVVpNtyPuM2V4eEGr1woodzALtufL3Nlyh6g5jKKuDIfeUBHv | ||
| JNyQf2h3Uha16lnrXmz9o9wsX/jf+jUAljBJqsMeACOpXfuZy+YKUCxSPOZaYTLC | ||
| y+0GQfiT431pJHBQlrXAUwzOmaJPQ7M6mLfsnpHibSkxUfMfHROaYCZ/sbWKl3lr | ||
| ZA9DbwaKKfS1Iw0ucAeDudyuqb4JntGU/W0aboKA0c3YB02mxAM4oDnqseuKV/CX | ||
| 8SQAiaXnYotuNXMCAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAf3KPmddqEqqC8iLs | ||
| lcd0euC4F5+USp9YsrZ3WuOzHqVxTtX3hR1scdlDXNvrsebQZUqwGdZGMS16ln3k | ||
| WObw7BbhU89tDNCN7Lt/IjT4MGRYRE+TmRc5EeIXxHkQ78bQqbmAI3GsW+7kJsoO | ||
| q3DdeE+M+BUJrhWorsAQCgUyZO166SAtKXKLIcxa+ddC49NvMQPJyzm3V+2b1roP | ||
| SvD2WV8gRYUnGmy/N0+u6ANq5EsbhZ548zZc+BI4upsWChTLyxt2RxR7+uGlS1+5 | ||
| EcGfKZ+g024k/J32XP4hdho7WYAS2xMiV83CfLR/MNi8oSMaVQTdKD8cpgiWJk3L | ||
| XWehWA== | ||
| -----END CERTIFICATE----- |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| import Groq from 'groq-sdk'; | ||
| import type { GameSnapshot } from '../riot.types.js'; | ||
| import type { GamePhase } from './phases.js'; | ||
|
|
||
| const GROQ_TIMEOUT_MS = 2500; | ||
| const MAX_WORDS = 15; | ||
|
|
||
| let groq: Groq | null = null; | ||
| let apiKey: string | null = null; | ||
|
|
||
| const recentPhrases: string[] = []; | ||
| const MAX_RECENT = 6; | ||
|
|
||
| export function setGroqApiKey(key: string): void { | ||
| apiKey = key; | ||
| groq = null; | ||
| } | ||
|
|
||
| export function clearGroqApiKey(): void { | ||
| apiKey = null; | ||
| groq = null; | ||
| } | ||
|
|
||
| export function resetCoachHistory(): void { | ||
| recentPhrases.length = 0; | ||
| } | ||
|
|
||
| function getClient(): Groq | null { | ||
| if (groq) return groq; | ||
| if (!apiKey) return null; | ||
| groq = new Groq({ apiKey }); | ||
| return groq; | ||
| } | ||
|
|
||
| function getPlayerContext(snapshot: GameSnapshot): string { | ||
| const { activePlayer, allPlayers } = snapshot; | ||
| const me = allPlayers.find((p) => p.riotId === activePlayer.riotId); | ||
| if (!me) return ''; | ||
|
|
||
| const enemyLaner = allPlayers.find( | ||
| (p) => p.team !== me.team && p.position === me.position, | ||
| ); | ||
|
|
||
| const parts = [ | ||
| `Champion: ${me.championName}`, | ||
| `Role: ${me.position}`, | ||
| `Level: ${activePlayer.level}`, | ||
| `KDA: ${me.scores.kills}/${me.scores.deaths}/${me.scores.assists}`, | ||
| ]; | ||
|
|
||
| if (enemyLaner) { | ||
| parts.push(`Lane opponent: ${enemyLaner.championName}`); | ||
| } | ||
|
|
||
| return parts.join(', '); | ||
| } | ||
|
|
||
| const SYSTEM_PROMPT = `Rephrase a League of Legends coaching reminder. Output ONLY the rephrased line. | ||
|
|
||
| RULES: | ||
| - 1 short sentence. Max 15 words. | ||
| - Same meaning. Do NOT add advice, strategy, or facts. | ||
| - Use the champion name instead of "you". | ||
| - Sound like a coach on comms: punchy, clipped, zero filler. | ||
| - No quotes, no preamble, no explanation.`; | ||
|
|
||
| export async function rephrasePrompt( | ||
| basePrompt: string, | ||
| snapshot: GameSnapshot, | ||
| phase: GamePhase, | ||
| ): Promise<string | null> { | ||
| const client = getClient(); | ||
| if (!client) return null; | ||
|
|
||
| const playerContext = getPlayerContext(snapshot); | ||
| const minutes = Math.floor(snapshot.gameData.gameTime / 60); | ||
| const seconds = String(Math.floor(snapshot.gameData.gameTime % 60)).padStart( | ||
| 2, | ||
| '0', | ||
| ); | ||
|
|
||
| const recentBlock = | ||
| recentPhrases.length > 0 | ||
| ? `\nRecent callouts (phrase it differently from these):\n${recentPhrases.map((p) => `- "${p}"`).join('\n')}` | ||
| : ''; | ||
|
|
||
| try { | ||
| const response = await client.chat.completions.create( | ||
| { | ||
| model: 'llama-3.3-70b-versatile', | ||
| messages: [ | ||
| { role: 'system', content: SYSTEM_PROMPT }, | ||
| { | ||
| role: 'user', | ||
| content: `Game time: ${minutes}:${seconds} (${phase.replace('_', ' ')})\n${playerContext}\n\nRephrase this: "${basePrompt}"${recentBlock}`, | ||
| }, | ||
| ], | ||
| max_tokens: 35, | ||
| temperature: 0.7, | ||
| }, | ||
| { timeout: GROQ_TIMEOUT_MS }, | ||
| ); | ||
|
|
||
| const text = response.choices[0]?.message?.content?.trim() ?? null; | ||
| if (!text) return null; | ||
|
|
||
| // Reject if output exceeds word limit or contains multiple sentences | ||
| const wordCount = text.split(/\s+/).length; | ||
| const hasMultipleSentences = /[.!?]\s/.test(text); | ||
| if (wordCount > MAX_WORDS || hasMultipleSentences) return null; | ||
|
|
||
| recentPhrases.unshift(text); | ||
| if (recentPhrases.length > MAX_RECENT) { | ||
| recentPhrases.pop(); | ||
| } | ||
| return text; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,13 +3,15 @@ import { | |||||||||||||||||||||||||||||||||||||||
| BrowserWindow, | ||||||||||||||||||||||||||||||||||||||||
| globalShortcut, | ||||||||||||||||||||||||||||||||||||||||
| ipcMain, | ||||||||||||||||||||||||||||||||||||||||
| safeStorage, | ||||||||||||||||||||||||||||||||||||||||
| screen, | ||||||||||||||||||||||||||||||||||||||||
| utilityProcess, | ||||||||||||||||||||||||||||||||||||||||
| } from 'electron'; | ||||||||||||||||||||||||||||||||||||||||
| import Store from 'electron-store'; | ||||||||||||||||||||||||||||||||||||||||
| import { setGroqApiKey, clearGroqApiKey } from './coach.js'; | ||||||||||||||||||||||||||||||||||||||||
| import path from 'node:path'; | ||||||||||||||||||||||||||||||||||||||||
| import { fileURLToPath } from 'node:url'; | ||||||||||||||||||||||||||||||||||||||||
| import type { Position } from '../types.js'; | ||||||||||||||||||||||||||||||||||||||||
| import type { AppStore } from '../types.js'; | ||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||
| cycleOutputMode, | ||||||||||||||||||||||||||||||||||||||||
| handleServerMessage, | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -21,7 +23,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |||||||||||||||||||||||||||||||||||||||
| const rootDir = path.resolve(__dirname, '..', '..'); | ||||||||||||||||||||||||||||||||||||||||
| const preloadPath = path.join(rootDir, 'dist', 'preload', 'preload.js'); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const store = new Store<Position>({ | ||||||||||||||||||||||||||||||||||||||||
| const store = new Store<AppStore>({ | ||||||||||||||||||||||||||||||||||||||||
| defaults: { | ||||||||||||||||||||||||||||||||||||||||
| x: 0, | ||||||||||||||||||||||||||||||||||||||||
| y: 0, | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -31,7 +33,7 @@ const store = new Store<Position>({ | |||||||||||||||||||||||||||||||||||||||
| const createHubWindow = () => { | ||||||||||||||||||||||||||||||||||||||||
| const win = new BrowserWindow({ | ||||||||||||||||||||||||||||||||||||||||
| width: 380, | ||||||||||||||||||||||||||||||||||||||||
| height: 320, | ||||||||||||||||||||||||||||||||||||||||
| height: 420, | ||||||||||||||||||||||||||||||||||||||||
| frame: false, | ||||||||||||||||||||||||||||||||||||||||
| resizable: false, | ||||||||||||||||||||||||||||||||||||||||
| transparent: true, | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -117,6 +119,52 @@ app.whenReady().then(() => { | |||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| ipcMain.handle('get-version', () => app.getVersion()); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| ipcMain.handle('has-api-key', () => { | ||||||||||||||||||||||||||||||||||||||||
| const encrypted = store.get('groqApiKey') as string | undefined; | ||||||||||||||||||||||||||||||||||||||||
| if (!encrypted) return false; | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| if (!safeStorage.isEncryptionAvailable()) return false; | ||||||||||||||||||||||||||||||||||||||||
| safeStorage.decryptString(Buffer.from(encrypted, 'base64')); | ||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| ipcMain.handle('set-api-key', (_event, key: string) => { | ||||||||||||||||||||||||||||||||||||||||
| if (key) { | ||||||||||||||||||||||||||||||||||||||||
| if (!safeStorage.isEncryptionAvailable()) { | ||||||||||||||||||||||||||||||||||||||||
| throw new Error('Encryption unavailable on this system'); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| const encrypted = safeStorage.encryptString(key).toString('base64'); | ||||||||||||||||||||||||||||||||||||||||
| store.set('groqApiKey', encrypted); | ||||||||||||||||||||||||||||||||||||||||
| setGroqApiKey(key); | ||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||
| throw new Error('Failed to encrypt API key'); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||
| store.delete('groqApiKey'); | ||||||||||||||||||||||||||||||||||||||||
| clearGroqApiKey(); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+134
to
+150
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clearing the stored key does not clear the active key in memory. Line 131 deletes from storage, but the previously set runtime key remains active because Proposed fix ipcMain.handle('set-api-key', (_event, key: string) => {
if (key) {
const encrypted = safeStorage.encryptString(key).toString('base64');
store.set('groqApiKey', encrypted);
setGroqApiKey(key);
} else {
store.delete('groqApiKey');
+ setGroqApiKey('');
}
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // Load saved key on startup | ||||||||||||||||||||||||||||||||||||||||
| const savedKey = store.get('groqApiKey') as string | undefined; | ||||||||||||||||||||||||||||||||||||||||
| if (savedKey) { | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| if (safeStorage.isEncryptionAvailable()) { | ||||||||||||||||||||||||||||||||||||||||
| const decrypted = safeStorage.decryptString( | ||||||||||||||||||||||||||||||||||||||||
| Buffer.from(savedKey, 'base64'), | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
| setGroqApiKey(decrypted); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||
| // corrupted or unreadable key — remove it so the user can re-enter | ||||||||||||||||||||||||||||||||||||||||
| store.delete('groqApiKey'); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| ipcMain.on('set-position', (_event, pos: { dx: number; dy: number }) => { | ||||||||||||||||||||||||||||||||||||||||
| const [currentX, currentY] = overlay.getPosition() as [number, number]; | ||||||||||||||||||||||||||||||||||||||||
| overlay.setPosition(currentX + pos.dx, currentY + pos.dy); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Enforce output constraints before returning model text.
Model output is trusted as-is. If it violates the 1-sentence/15-word constraint or adds extra content, it still gets surfaced to users. Validate locally and fall back to
nullon invalid output.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents