Skip to content

Commit 13a5c16

Browse files
authored
Add model selection and inputHeaderSlot for BiChat fast mode
* Add per-turn model selection and inputHeaderSlot for BiChat fast mode - Add model field to SendMessageOptions and wire through MessageTransport - Add model/setModel to ChatMachine state, selectors, and session snapshot - Add inputHeaderSlot prop to ChatSession for custom content above input - Add models array to Extensions.llm type for backend-provided model list - Show model badge on assistant messages in debug mode * Move ModelSelector component into SDK bichat package Reusable segmented toggle for per-turn model selection with Cmd+Shift+M keyboard shortcut. Uses Phosphor icons (Lightning, Brain). * Fix lint: add semicolons, curly braces, and useMemo for models * Harden BiChat session recovery
1 parent 3465b58 commit 13a5c16

13 files changed

Lines changed: 461 additions & 9 deletions

File tree

internal/controller/controller.go

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,10 @@ func (c *Controller) RegisterRoutes(router *mux.Router) {
8383
if c.devAssets != nil || config.Assets.FS != nil {
8484
c.registerAssetRoutes(router, fullAssetsPath)
8585
}
86+
excludedPrefixes := appRouteExcludedPrefixes(c.applet.BasePath(), config)
8687
pathRouter := router.PathPrefix(c.applet.BasePath()).Subrouter()
8788
c.applyMiddleware(pathRouter, config.Middleware)
88-
c.registerAppRoutes(pathRouter, config.RoutePatterns)
89+
c.registerAppRoutes(pathRouter, config.RoutePatterns, excludedPrefixes)
8990

9091
for _, host := range config.Hosts {
9192
host = strings.TrimSpace(host)
@@ -97,7 +98,7 @@ func (c *Controller) RegisterRoutes(router *mux.Router) {
9798
c.registerAssetRoutes(hostRouter, config.Assets.BasePath)
9899
}
99100
c.applyMiddleware(hostRouter, config.Middleware)
100-
c.registerAppRoutes(hostRouter, config.RoutePatterns)
101+
c.registerAppRoutes(hostRouter, config.RoutePatterns, excludedPrefixes)
101102
}
102103
}
103104

@@ -107,7 +108,7 @@ func (c *Controller) applyMiddleware(router *mux.Router, middleware []mux.Middle
107108
}
108109
}
109110

110-
func (c *Controller) registerAppRoutes(router *mux.Router, routePatterns []string) {
111+
func (c *Controller) registerAppRoutes(router *mux.Router, routePatterns []string, excludedPrefixes []string) {
111112
for _, p := range routePatterns {
112113
p = strings.TrimSpace(p)
113114
if p == "" {
@@ -120,5 +121,94 @@ func (c *Controller) registerAppRoutes(router *mux.Router, routePatterns []strin
120121
}
121122
router.HandleFunc("", c.RenderApp).Methods(http.MethodGet, http.MethodHead)
122123
router.HandleFunc("/", c.RenderApp).Methods(http.MethodGet, http.MethodHead)
123-
router.PathPrefix("/").HandlerFunc(c.RenderApp).Methods(http.MethodGet, http.MethodHead)
124+
router.MatcherFunc(func(r *http.Request, _ *mux.RouteMatch) bool {
125+
return shouldRenderAppRoute(r.URL.Path, excludedPrefixes)
126+
}).HandlerFunc(c.RenderApp).Methods(http.MethodGet, http.MethodHead)
127+
}
128+
129+
func appRouteExcludedPrefixes(basePath string, config api.Config) []string {
130+
candidates := []string{
131+
config.Assets.BasePath,
132+
config.Endpoints.GraphQL,
133+
config.Endpoints.Stream,
134+
config.Endpoints.REST,
135+
}
136+
if config.RPC != nil {
137+
candidates = append(candidates, config.RPC.Path)
138+
}
139+
140+
seen := make(map[string]struct{}, len(candidates)*2)
141+
excluded := make([]string, 0, len(candidates)*2)
142+
for _, candidate := range candidates {
143+
for _, prefix := range normalizeExcludedRoutePrefixes(basePath, candidate) {
144+
if prefix == "" {
145+
continue
146+
}
147+
if _, exists := seen[prefix]; exists {
148+
continue
149+
}
150+
seen[prefix] = struct{}{}
151+
excluded = append(excluded, prefix)
152+
}
153+
}
154+
155+
return excluded
156+
}
157+
158+
func normalizeExcludedRoutePrefixes(basePath, raw string) []string {
159+
raw = strings.TrimSpace(raw)
160+
if raw == "" {
161+
return nil
162+
}
163+
if !strings.HasPrefix(raw, "/") {
164+
raw = "/" + raw
165+
}
166+
167+
basePath = strings.TrimSpace(basePath)
168+
if basePath == "" {
169+
basePath = "/"
170+
}
171+
if !strings.HasPrefix(basePath, "/") {
172+
basePath = "/" + basePath
173+
}
174+
basePath = path.Clean(basePath)
175+
absolute := path.Clean(raw)
176+
177+
prefixes := make([]string, 0, 2)
178+
prefixes = append(prefixes, absolute)
179+
180+
if absolute == basePath {
181+
prefixes = append(prefixes, "/")
182+
return prefixes
183+
}
184+
185+
if strings.HasPrefix(absolute, basePath+"/") {
186+
relative := strings.TrimPrefix(absolute, basePath)
187+
if relative == "" {
188+
relative = "/"
189+
}
190+
prefixes = append(prefixes, path.Clean(relative))
191+
}
192+
193+
return prefixes
194+
}
195+
196+
func shouldRenderAppRoute(requestPath string, excludedPrefixes []string) bool {
197+
cleanPath := path.Clean("/" + strings.TrimPrefix(strings.TrimSpace(requestPath), "/"))
198+
for _, prefix := range excludedPrefixes {
199+
if pathHasPrefix(cleanPath, prefix) {
200+
return false
201+
}
202+
}
203+
return true
204+
}
205+
206+
func pathHasPrefix(requestPath, prefix string) bool {
207+
if prefix == "" {
208+
return false
209+
}
210+
if prefix == "/" {
211+
return requestPath == "/"
212+
}
213+
return requestPath == prefix || strings.HasPrefix(requestPath, prefix+"/")
124214
}

internal/controller/controller_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
"testing/fstest"
1515

16+
"github.com/google/uuid"
1617
"github.com/gorilla/mux"
1718
"github.com/iota-uz/applets/internal/api"
1819
"github.com/stretchr/testify/assert"
@@ -132,6 +133,82 @@ func TestAppletController_DoesNotMountPerAppletRPCRoute(t *testing.T) {
132133
assert.Equal(t, http.StatusMethodNotAllowed, w.Code)
133134
}
134135

136+
func TestAppletController_ShellCatchAll_SkipsConfiguredAPIEndpoints(t *testing.T) {
137+
t.Parallel()
138+
139+
a := &testApplet{
140+
name: "t",
141+
basePath: "/t",
142+
config: api.Config{
143+
WindowGlobal: "__T__",
144+
Shell: api.ShellConfig{Mode: api.ShellModeStandalone},
145+
Endpoints: api.EndpointConfig{
146+
Stream: "/t/stream",
147+
REST: "/t/rest",
148+
},
149+
Assets: api.AssetConfig{
150+
FS: fstest.MapFS{"manifest.json": {Data: []byte(`{"index.html":{"file":"a.js","isEntry":true}}`)}, "a.js": {Data: []byte("console.log('ok')")}},
151+
BasePath: "/assets",
152+
ManifestPath: "manifest.json",
153+
Entrypoint: "index.html",
154+
},
155+
RPC: &api.RPCConfig{Path: "/t/rpc"},
156+
},
157+
}
158+
159+
c, err := New(a, nil, api.DefaultSessionConfig, nil, nil, &testHostServices{})
160+
require.NoError(t, err)
161+
162+
r := mux.NewRouter()
163+
c.RegisterRoutes(r)
164+
165+
r.HandleFunc("/t/stream/status", func(w http.ResponseWriter, _ *http.Request) {
166+
w.Header().Set("Content-Type", "application/json")
167+
_, _ = w.Write([]byte(`{"active":true}`))
168+
}).Methods(http.MethodGet)
169+
r.HandleFunc("/t/rpc", func(w http.ResponseWriter, _ *http.Request) {
170+
_, _ = w.Write([]byte("rpc"))
171+
}).Methods(http.MethodGet)
172+
r.HandleFunc("/t/rest/health", func(w http.ResponseWriter, _ *http.Request) {
173+
_, _ = w.Write([]byte("rest"))
174+
}).Methods(http.MethodGet)
175+
176+
for _, tc := range []struct {
177+
name string
178+
target string
179+
wantBody string
180+
wantContains string
181+
}{
182+
{name: "stream", target: "/t/stream/status", wantBody: `{"active":true}`},
183+
{name: "rpc", target: "/t/rpc", wantBody: "rpc"},
184+
{name: "rest", target: "/t/rest/health", wantBody: "rest"},
185+
{name: "session route still renders app", target: "/t/session/123", wantContains: "__T__"},
186+
} {
187+
t.Run(tc.name, func(t *testing.T) {
188+
w := httptest.NewRecorder()
189+
req := httptest.NewRequest(http.MethodGet, tc.target, nil)
190+
req = req.WithContext(context.WithValue(
191+
context.WithValue(req.Context(), testUserKey, &mockUser{
192+
id: 1,
193+
email: "t@example.com",
194+
firstName: "Test",
195+
lastName: "User",
196+
}),
197+
testTenantIDKey,
198+
uuid.New(),
199+
))
200+
r.ServeHTTP(w, req)
201+
202+
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
203+
if tc.wantBody != "" {
204+
assert.Equal(t, tc.wantBody, w.Body.String())
205+
return
206+
}
207+
assert.Contains(t, w.Body.String(), tc.wantContains)
208+
})
209+
}
210+
}
211+
135212
func TestAppletController_RPC(t *testing.T) {
136213
t.Parallel()
137214

ui/src/bichat/components/AssistantMessage.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,12 @@ export function AssistantMessage({
681681
<span className={classes.timestamp}>{timestamp}</span>
682682
)}
683683

684+
{showDebug && turn.debug?.attempts?.[0]?.model && (
685+
<span className="inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-[10px] font-medium leading-none text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-800">
686+
{turn.debug.attempts[0].model}
687+
</span>
688+
)}
689+
684690
<button
685691
onClick={handleCopyClick}
686692
className={`cursor-pointer ${classes.actionButton} ${

ui/src/bichat/components/ChatSession.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { SessionArtifactsPanel } from './SessionArtifactsPanel';
2929
import { SessionMembersModal } from './SessionMembersModal';
3030
import Alert from './Alert';
3131
import { StreamError } from './StreamError';
32+
import { isOpenQuestionStatus } from '../machine/hitlLifecycle';
3233

3334
interface ChatSessionProps {
3435
dataSource: ChatDataSource
@@ -56,6 +57,8 @@ interface ChatSessionProps {
5657
logoSlot?: ReactNode
5758
/** Custom action buttons for the header */
5859
actionsSlot?: ReactNode
60+
/** Custom content rendered above the message input (e.g., model selector) */
61+
inputHeaderSlot?: ReactNode
5962
/** Callback when user navigates back */
6063
onBack?: () => void
6164
/** Custom verbs for the typing indicator (e.g. ['Thinking', 'Analyzing', ...]) */
@@ -85,6 +88,7 @@ function ChatSessionCore({
8588
welcomeSlot,
8689
logoSlot,
8790
actionsSlot,
91+
inputHeaderSlot,
8892
onBack,
8993
thinkingVerbs,
9094
onSessionRestored,
@@ -119,6 +123,7 @@ function ChatSessionCore({
119123
isCompacting,
120124
retryLastMessage,
121125
clearStreamError,
126+
pendingQuestion,
122127
} = useChatMessaging();
123128
const {
124129
inputError,
@@ -135,6 +140,7 @@ function ChatSessionCore({
135140
const isArchived = session?.status === 'archived';
136141
const accessReadOnly = session?.access ? !session.access.canWrite : false;
137142
const effectiveReadOnly = Boolean(readOnly ?? isReadOnly) || isArchived || accessReadOnly;
143+
const composerDisabled = isOpenQuestionStatus(pendingQuestion?.status);
138144
const [restoring, setRestoring] = useState(false);
139145
const handleRestore = useCallback(async () => {
140146
if (!session?.id) {return;}
@@ -405,7 +411,7 @@ function ChatSessionCore({
405411
<div className="flex flex-1 items-center justify-center px-4 py-8">
406412
<div className="w-full max-w-5xl">
407413
{welcomeSlot || (
408-
<WelcomeContent onPromptSelect={handlePromptSelect} disabled={loading} />
414+
<WelcomeContent onPromptSelect={handlePromptSelect} disabled={loading || composerDisabled} />
409415
)}
410416
{streamError && (
411417
<div className="px-6 pt-4">
@@ -417,6 +423,7 @@ function ChatSessionCore({
417423
/>
418424
</div>
419425
)}
426+
{!effectiveReadOnly && inputHeaderSlot}
420427
{!effectiveReadOnly && (
421428
<MessageInput
422429
message={message}
@@ -437,6 +444,7 @@ function ChatSessionCore({
437444
onCancelStreaming={cancel}
438445
containerClassName="pt-6 px-6"
439446
formClassName="mx-auto"
447+
disabled={composerDisabled}
440448
reasoningEffortOptions={reasoningEffortOptions}
441449
reasoningEffort={reasoningEffort}
442450
onReasoningEffortChange={setReasoningEffort}
@@ -483,6 +491,7 @@ function ChatSessionCore({
483491
/>
484492
</div>
485493
)}
494+
{!effectiveReadOnly && inputHeaderSlot}
486495
{!effectiveReadOnly && (
487496
<MessageInput
488497
message={message}
@@ -501,6 +510,7 @@ function ChatSessionCore({
501510
onRemoveQueueItem={removeQueueItem}
502511
onUpdateQueueItem={updateQueueItem}
503512
onCancelStreaming={cancel}
513+
disabled={composerDisabled}
504514
reasoningEffortOptions={reasoningEffortOptions}
505515
reasoningEffort={reasoningEffort}
506516
onReasoningEffortChange={setReasoningEffort}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { useEffect, useCallback, useMemo } from 'react';
2+
import { Lightning, Brain } from '@phosphor-icons/react';
3+
import { useChatSession } from '../context/ChatContext';
4+
import { useIotaContext } from '../context/IotaContext';
5+
6+
interface ModelEntry {
7+
id: string
8+
label: string
9+
default?: boolean
10+
}
11+
12+
export function ModelSelector() {
13+
const { model, setModel } = useChatSession();
14+
const context = useIotaContext();
15+
16+
const models: ModelEntry[] = useMemo(
17+
() => context.extensions?.llm?.models ?? [],
18+
[context.extensions?.llm?.models],
19+
);
20+
21+
const defaultModel = models.find((m) => m.default) ?? models[0];
22+
const currentModel = model ?? defaultModel?.id;
23+
24+
// Set default model on mount
25+
useEffect(() => {
26+
if (!model && defaultModel) {
27+
setModel(defaultModel.id);
28+
}
29+
}, [model, defaultModel, setModel]);
30+
31+
// Keyboard shortcut: Cmd+Shift+M to rotate
32+
const rotateModel = useCallback(() => {
33+
const currentIndex = models.findIndex((m) => m.id === currentModel);
34+
const nextIndex = (currentIndex + 1) % models.length;
35+
setModel(models[nextIndex].id);
36+
}, [currentModel, models, setModel]);
37+
38+
useEffect(() => {
39+
const handler = (e: KeyboardEvent) => {
40+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'm') {
41+
e.preventDefault();
42+
rotateModel();
43+
}
44+
};
45+
document.addEventListener('keydown', handler);
46+
return () => document.removeEventListener('keydown', handler);
47+
}, [rotateModel]);
48+
49+
// Don't render if less than 2 models
50+
if (models.length < 2) {return null;}
51+
52+
return (
53+
<div className="flex items-center justify-between px-4 pt-3 pb-1">
54+
<div className="inline-flex rounded-lg bg-gray-100 p-0.5 dark:bg-gray-800">
55+
{models.map((m) => {
56+
const isActive = m.id === currentModel;
57+
const isFast = m.label === 'Fast';
58+
return (
59+
<button
60+
key={m.id}
61+
type="button"
62+
onClick={() => setModel(m.id)}
63+
className={`
64+
flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium transition-all duration-150
65+
${
66+
isActive
67+
? isFast
68+
? 'bg-white text-amber-600 shadow-sm dark:bg-gray-700 dark:text-amber-400'
69+
: 'bg-white text-blue-600 shadow-sm dark:bg-gray-700 dark:text-blue-400'
70+
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
71+
}
72+
`}
73+
>
74+
{isFast ? <Lightning size={13} weight="fill" /> : <Brain size={13} weight="fill" />}
75+
<span>{m.label}</span>
76+
</button>
77+
);
78+
})}
79+
</div>
80+
<span className="hidden select-none text-[10px] text-gray-400 sm:block dark:text-gray-500">
81+
{navigator.platform.includes('Mac') ? '\u2318' : 'Ctrl'}{'\u21E7'}M
82+
</span>
83+
</div>
84+
);
85+
}

0 commit comments

Comments
 (0)