Skip to content

Commit 7308943

Browse files
feat: model picker. (#25)
1 parent 69fbded commit 7308943

9 files changed

Lines changed: 335 additions & 54 deletions

File tree

playwright/app.spec.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect, test } from '@playwright/test'
22
import type { Page } from '@playwright/test'
3+
import { defaultGitHubChatModel } from '../src/modules/github-api.js'
34

45
const webServerMode = process.env.PLAYWRIGHT_WEB_SERVER_MODE ?? 'dev'
56
const appEntryPath = webServerMode === 'preview' ? '/index.html' : '/src/index.html'
@@ -12,6 +13,8 @@ type ChatRequestMessage = {
1213
type ChatRequestBody = {
1314
metadata?: unknown
1415
messages?: ChatRequestMessage[]
16+
model?: string
17+
stream?: boolean
1518
}
1619

1720
const waitForAppReady = async (page: Page, path = appEntryPath) => {
@@ -256,6 +259,7 @@ test('AI chat prefers streaming responses when available', async ({ page }) => {
256259
)
257260

258261
expect(streamRequestBody?.metadata).toBeUndefined()
262+
expect(streamRequestBody?.model).toBe(defaultGitHubChatModel)
259263
const systemMessage = streamRequestBody?.messages?.find(
260264
(message: ChatRequestMessage) => message.role === 'system',
261265
)
@@ -335,9 +339,14 @@ test('AI chat falls back to non-streaming response when streaming fails', async
335339
}) => {
336340
let streamAttemptCount = 0
337341
let fallbackAttemptCount = 0
342+
const attemptedModels: string[] = []
338343

339344
await page.route('https://models.github.ai/inference/chat/completions', async route => {
340-
const body = route.request().postDataJSON() as { stream?: boolean } | null
345+
const body = route.request().postDataJSON() as ChatRequestBody | null
346+
if (typeof body?.model === 'string') {
347+
attemptedModels.push(body.model)
348+
}
349+
341350
if (body?.stream) {
342351
streamAttemptCount += 1
343352
await route.fulfill({
@@ -373,6 +382,10 @@ test('AI chat falls back to non-streaming response when streaming fails', async
373382
await connectByotWithSingleRepo(page)
374383
await ensureAiChatDrawerOpen(page)
375384

385+
const selectedModel = 'openai/gpt-5-mini'
386+
await page.locator('#ai-chat-model').selectOption(selectedModel)
387+
await expect(page.locator('#ai-chat-model')).toHaveValue(selectedModel)
388+
376389
await page.locator('#ai-chat-prompt').fill('Use fallback path.')
377390
await page.locator('#ai-chat-send').click()
378391

@@ -383,6 +396,8 @@ test('AI chat falls back to non-streaming response when streaming fails', async
383396
)
384397
expect(streamAttemptCount).toBeGreaterThan(0)
385398
expect(fallbackAttemptCount).toBeGreaterThan(0)
399+
expect(attemptedModels.length).toBeGreaterThan(0)
400+
expect(attemptedModels.every(model => model === selectedModel)).toBe(true)
386401
})
387402

388403
test('BYOT remembers selected repository across reloads', async ({ page }) => {
@@ -433,7 +448,7 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => {
433448
test('renders default playground preview', async ({ page }) => {
434449
await waitForInitialRender(page)
435450

436-
await page.getByLabel('ShadowRoot (open)').uncheck()
451+
await page.getByLabel('ShadowRoot').uncheck()
437452
await expect(page.locator('#status')).toHaveText('Rendered')
438453
await expectPreviewHasRenderedContent(page)
439454
})
@@ -668,7 +683,7 @@ test('renders in react mode with css modules', async ({ page }) => {
668683
await ensurePanelToolsVisible(page, 'component')
669684
await ensurePanelToolsVisible(page, 'styles')
670685

671-
await page.getByLabel('ShadowRoot (open)').uncheck()
686+
await page.getByLabel('ShadowRoot').uncheck()
672687
await page.locator('#render-mode').selectOption('react')
673688
await page.locator('#style-mode').selectOption('module')
674689
await expect(page.locator('#status')).toHaveText('Rendered')
@@ -678,7 +693,7 @@ test('renders in react mode with css modules', async ({ page }) => {
678693
test('transpiles TypeScript annotations in component source', async ({ page }) => {
679694
await waitForInitialRender(page)
680695

681-
await page.getByLabel('ShadowRoot (open)').uncheck()
696+
await page.getByLabel('ShadowRoot').uncheck()
682697
await setComponentEditorSource(
683698
page,
684699
[
@@ -762,7 +777,7 @@ test('react mode executes default React import without TDZ runtime failure', asy
762777

763778
await ensurePanelToolsVisible(page, 'component')
764779

765-
await page.getByLabel('ShadowRoot (open)').uncheck()
780+
await page.getByLabel('ShadowRoot').uncheck()
766781
await page.locator('#render-mode').selectOption('react')
767782
await setComponentEditorSource(
768783
page,
@@ -854,7 +869,7 @@ test('renders with less style mode', async ({ page }) => {
854869

855870
await ensurePanelToolsVisible(page, 'styles')
856871

857-
await page.getByLabel('ShadowRoot (open)').uncheck()
872+
await page.getByLabel('ShadowRoot').uncheck()
858873
await page.locator('#style-mode').selectOption('less')
859874
await expect(page.locator('#status')).toHaveText('Rendered')
860875
await expectPreviewHasRenderedContent(page)
@@ -865,7 +880,7 @@ test('renders with sass style mode', async ({ page }) => {
865880

866881
await ensurePanelToolsVisible(page, 'styles')
867882

868-
await page.getByLabel('ShadowRoot (open)').uncheck()
883+
await page.getByLabel('ShadowRoot').uncheck()
869884
await page.locator('#style-mode').selectOption('sass')
870885
await expect(page.locator('#status')).toHaveText('Rendered')
871886
await expectPreviewHasRenderedContent(page)

src/app.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const aiChatDrawer = document.getElementById('ai-chat-drawer')
3030
const aiChatClose = document.getElementById('ai-chat-close')
3131
const aiChatClear = document.getElementById('ai-chat-clear')
3232
const aiChatPrompt = document.getElementById('ai-chat-prompt')
33+
const aiChatModel = document.getElementById('ai-chat-model')
3334
const aiChatIncludeEditors = document.getElementById('ai-chat-include-editors')
3435
const aiChatSend = document.getElementById('ai-chat-send')
3536
const aiChatStatus = document.getElementById('ai-chat-status')
@@ -477,6 +478,7 @@ const githubAiContextState = {
477478
let chatDrawerController = {
478479
setOpen: () => {},
479480
setSelectedRepository: () => {},
481+
setToken: () => {},
480482
dispose: () => {},
481483
}
482484

@@ -509,6 +511,7 @@ const byotControls = createGitHubByotControls({
509511
onTokenChange: token => {
510512
githubAiContextState.token = token
511513
syncAiChatTokenVisibility(token)
514+
chatDrawerController.setToken(token)
512515
},
513516
setStatus,
514517
})
@@ -527,6 +530,7 @@ chatDrawerController = createGitHubChatDrawer({
527530
drawer: aiChatDrawer,
528531
closeButton: aiChatClose,
529532
promptInput: aiChatPrompt,
533+
modelSelect: aiChatModel,
530534
includeEditorsContextToggle: aiChatIncludeEditors,
531535
sendButton: aiChatSend,
532536
clearButton: aiChatClear,

src/index.html

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ <h1>
3737
class="app-grid-ai-controls"
3838
id="github-ai-controls"
3939
role="group"
40-
aria-label="GitHub AI controls"
40+
aria-label="GitHub controls"
4141
hidden
4242
>
4343
<div class="github-token-control-wrap">
@@ -161,7 +161,7 @@ <h1>
161161
aria-controls="github-ai-controls"
162162
hidden
163163
>
164-
Agent
164+
GitHub
165165
</button>
166166
</div>
167167

@@ -455,12 +455,12 @@ <h2>Preview</h2>
455455
</label>
456456
<label class="toggle">
457457
<input id="shadow-toggle" type="checkbox" checked />
458-
ShadowRoot (open)
458+
ShadowRoot
459459
</label>
460460
<button
461461
class="hint-icon shadow-hint"
462462
type="button"
463-
aria-label="About ShadowRoot mode"
463+
aria-label="About preview isolation mode"
464464
data-tooltip="Turning ShadowRoot off renders the preview in light DOM, so @knighted/develop styles can affect preview output."
465465
>
466466
i
@@ -544,9 +544,6 @@ <h2>AI Chat</h2>
544544
</div>
545545

546546
<div class="ai-chat-drawer__meta">
547-
<p class="ai-chat-drawer__repo" id="ai-chat-repository">
548-
No repository selected
549-
</p>
550547
<p class="ai-chat-drawer__status" id="ai-chat-status" data-level="neutral">
551548
Idle
552549
</p>
@@ -560,9 +557,16 @@ <h2>AI Chat</h2>
560557
class="ai-chat-prompt"
561558
id="ai-chat-prompt"
562559
rows="4"
563-
placeholder="Ask about your selected repository context"
560+
placeholder="Ask for help developing your component and styles"
564561
></textarea>
565562

563+
<label class="ai-chat-model-picker" for="ai-chat-model">
564+
<span class="sr-only">Model</span>
565+
<select id="ai-chat-model" aria-label="Chat model" disabled>
566+
<option value="openai/gpt-4.1-mini" selected>openai/gpt-4.1-mini</option>
567+
</select>
568+
</label>
569+
566570
<label class="ai-chat-context-toggle" for="ai-chat-include-editors">
567571
<input type="checkbox" id="ai-chat-include-editors" checked />
568572
Send JSX + CSS editor context

src/modules/github-api.js

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,53 @@
11
const githubApiBaseUrl = 'https://api.github.com'
22
const githubModelsApiUrl = 'https://models.github.ai/inference/chat/completions'
33

4+
export const defaultGitHubChatModel = 'openai/gpt-4.1-mini'
5+
6+
/* Local model options avoid browser CORS failures when calling catalog endpoints directly. */
7+
export const githubChatModelOptions = [
8+
'openai/gpt-4.1-mini',
9+
'openai/gpt-4.1',
10+
'openai/gpt-4.1-nano',
11+
'openai/gpt-4o',
12+
'openai/gpt-4o-mini',
13+
'openai/gpt-5',
14+
'openai/gpt-5-chat',
15+
'openai/gpt-5-mini',
16+
'openai/gpt-5-nano',
17+
'openai/o1',
18+
'openai/o1-mini',
19+
'openai/o1-preview',
20+
'openai/o3',
21+
'openai/o3-mini',
22+
'openai/o4-mini',
23+
'ai21-labs/ai21-jamba-1.5-large',
24+
'cohere/cohere-command-a',
25+
'cohere/cohere-command-r-08-2024',
26+
'cohere/cohere-command-r-plus-08-2024',
27+
'xai/grok-3',
28+
'xai/grok-3-mini',
29+
'deepseek/deepseek-r1',
30+
'deepseek/deepseek-r1-0528',
31+
'deepseek/deepseek-v3-0324',
32+
'meta/llama-3.2-11b-vision-instruct',
33+
'meta/llama-3.2-90b-vision-instruct',
34+
'meta/llama-3.3-70b-instruct',
35+
'meta/llama-4-maverick-17b-128e-instruct-fp8',
36+
'meta/llama-4-scout-17b-16e-instruct',
37+
'meta/meta-llama-3.1-405b-instruct',
38+
'meta/meta-llama-3.1-8b-instruct',
39+
'mistral-ai/codestral-2501',
40+
'mistral-ai/ministral-3b',
41+
'mistral-ai/mistral-medium-2505',
42+
'mistral-ai/mistral-small-2503',
43+
'microsoft/mai-ds-r1',
44+
'microsoft/phi-4',
45+
'microsoft/phi-4-mini-instruct',
46+
'microsoft/phi-4-mini-reasoning',
47+
'microsoft/phi-4-multimodal-instruct',
48+
'microsoft/phi-4-reasoning',
49+
]
50+
451
const parseNextPageUrlFromLinkHeader = linkHeader => {
552
if (typeof linkHeader !== 'string' || !linkHeader.trim()) {
653
return null
@@ -352,7 +399,7 @@ export const streamGitHubChatCompletion = async ({
352399
messages,
353400
signal,
354401
onToken,
355-
model = 'openai/gpt-4.1-mini',
402+
model = defaultGitHubChatModel,
356403
}) => {
357404
if (typeof token !== 'string' || token.trim().length === 0) {
358405
throw new Error('A GitHub token is required to start a chat request.')
@@ -385,6 +432,7 @@ export const streamGitHubChatCompletion = async ({
385432
const reader = response.body.getReader()
386433
let buffered = ''
387434
let combined = ''
435+
let responseModel = ''
388436

389437
while (true) {
390438
// eslint-disable-next-line no-await-in-loop
@@ -403,6 +451,10 @@ export const streamGitHubChatCompletion = async ({
403451
continue
404452
}
405453

454+
if (!responseModel && typeof body.model === 'string') {
455+
responseModel = body.model
456+
}
457+
406458
const chunk = extractStreamingDeltaText(body)
407459
if (!chunk) {
408460
continue
@@ -415,6 +467,9 @@ export const streamGitHubChatCompletion = async ({
415467

416468
if (buffered.trim()) {
417469
const body = parseSseDataLine(buffered)
470+
if (body && !responseModel && typeof body.model === 'string') {
471+
responseModel = body.model
472+
}
418473
const chunk = body ? extractStreamingDeltaText(body) : ''
419474
if (chunk) {
420475
combined += chunk
@@ -428,6 +483,7 @@ export const streamGitHubChatCompletion = async ({
428483

429484
return {
430485
content: combined,
486+
model: responseModel || model,
431487
rateLimit: parseRateMetadata({ headers: response.headers, body: null }),
432488
}
433489
}
@@ -436,7 +492,7 @@ export const requestGitHubChatCompletion = async ({
436492
token,
437493
messages,
438494
signal,
439-
model = 'openai/gpt-4.1-mini',
495+
model = defaultGitHubChatModel,
440496
}) => {
441497
if (typeof token !== 'string' || token.trim().length === 0) {
442498
throw new Error('A GitHub token is required to start a chat request.')
@@ -470,6 +526,7 @@ export const requestGitHubChatCompletion = async ({
470526

471527
return {
472528
content,
529+
model: typeof body?.model === 'string' && body.model ? body.model : model,
473530
rateLimit: parseRateMetadata({ headers: response.headers, body }),
474531
}
475532
}

0 commit comments

Comments
 (0)