Skip to content

Commit 26aa4a5

Browse files
authored
Merge pull request #16 from CoolSpring8/built-in-ai
feat: integrate built-in AI provider support
2 parents d256b7e + 2d3bc51 commit 26aa4a5

8 files changed

Lines changed: 412 additions & 59 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ A tiny browser‑based chat UI packaged as a single static HTML file. It is inte
1111
## Configure API
1212

1313
1. Click the Settings icon in the header.
14-
2. Enter your OpenAI‑compatible base URL (e.g. `https://api.openai.com/v1`) and API key, then click "Save".
15-
3. Click "Sync from API" to fetch model list.
14+
2. Choose a provider:
15+
- **OpenAI‑Compatible**: enter a base URL (e.g. `https://api.openai.com/v1`) and API key, then click "Save". Click "Sync from API" to fetch models.
16+
- **Built-in AI (Chrome/Edge)**: no API key required. The app will check availability and, if needed, download the built-in model with progress feedback.
1617

1718
## Usage Tips
1819

bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"@ai-sdk/openai-compatible": "^1.0.27",
1313
"@ai-sdk/react": "^2.0.98",
1414
"@base-ui-components/react": "^1.0.0-beta.4",
15+
"@built-in-ai/core": "^2.0.1",
1516
"@mantine/core": "^7.8.1",
1617
"@mantine/hooks": "^7.8.1",
1718
"@xyflow/react": "^12.9.1",

src/App.tsx

Lines changed: 122 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { builtInAI } from "@built-in-ai/core";
12
import { Textarea, UnstyledButton } from "@mantine/core";
23
import { useDisclosure } from "@mantine/hooks";
34
import { type ModelMessage, streamText } from "ai";
@@ -25,11 +26,17 @@ import SettingsModal from "./components/SettingsModal";
2526
import TextCompletionView from "./components/TextCompletionView";
2627
import type { ConversationSnapshot } from "./tree/types";
2728
import { useConversationTree } from "./tree/useConversationTree";
28-
import type { AppView, ModelInfo } from "./types";
29+
import type {
30+
AppView,
31+
BuiltInAvailability,
32+
ModelInfo,
33+
ProviderKind,
34+
} from "./types";
2935

3036
const baseURLKey = "iaslate_baseURL";
3137
const apiKeyKey = "iaslate_apiKey";
3238
const modelsKey = "iaslate_models";
39+
const providerKindKey = "iaslate_provider_kind";
3340

3441
const defaultSystemPrompt = "You are a helpful assistant.";
3542

@@ -38,6 +45,10 @@ const App = () => {
3845
const [apiKey, setAPIKey] = useState("");
3946
const [models, setModels] = useImmer<ModelInfo[]>([]);
4047
const [activeModel, setActiveModel] = useImmer<string | null>(null);
48+
const [providerKind, setProviderKind] =
49+
useState<ProviderKind>("openai-compatible");
50+
const [builtInAvailability, setBuiltInAvailability] =
51+
useState<BuiltInAvailability>("unknown");
4152

4253
const openAIProvider = useMemo(
4354
() =>
@@ -48,16 +59,54 @@ const App = () => {
4859
[apiKey, baseURL],
4960
);
5061

62+
const builtInStatusText = useMemo(() => {
63+
if (providerKind !== "built-in") {
64+
return undefined;
65+
}
66+
switch (builtInAvailability) {
67+
case "downloading":
68+
return "Built-in AI downloading...";
69+
case "available":
70+
return "Built-in AI ready";
71+
case "downloadable":
72+
return "Download model in Settings";
73+
case "unavailable":
74+
return "Built-in AI unavailable";
75+
default:
76+
return "Built-in AI";
77+
}
78+
}, [builtInAvailability, providerKind]);
79+
80+
const getBuiltInChatModel = useCallback(() => {
81+
// Return a fresh model so each send can create a new session with the latest system prompt.
82+
return builtInAI();
83+
}, []);
84+
85+
const refreshBuiltInAvailability = useCallback(async () => {
86+
try {
87+
const availability = await builtInAI().availability();
88+
setBuiltInAvailability(availability as BuiltInAvailability);
89+
} catch (error) {
90+
console.error(error);
91+
setBuiltInAvailability("unavailable");
92+
}
93+
}, []);
94+
5195
const syncModels = useCallback(
5296
async ({
5397
baseURLOverride,
5498
apiKeyOverride,
5599
silent = false,
100+
force = false,
56101
}: {
57102
baseURLOverride?: string;
58103
apiKeyOverride?: string;
59104
silent?: boolean;
105+
force?: boolean;
60106
} = {}) => {
107+
if (!force && providerKind !== "openai-compatible") {
108+
return [];
109+
}
61110
const targetBaseURL = (baseURLOverride ?? baseURL).trim();
62111
const targetAPIKey = apiKeyOverride ?? apiKey;
63112

@@ -97,11 +146,15 @@ const App = () => {
97146
return [];
98147
}
99148
},
100-
[activeModel, apiKey, baseURL, setActiveModel, setModels],
149+
[activeModel, apiKey, baseURL, providerKind, setActiveModel, setModels],
101150
);
102151

103152
useEffect(() => {
104153
(async () => {
154+
const storedProvider = await get<ProviderKind>(providerKindKey);
155+
if (storedProvider) {
156+
setProviderKind(storedProvider);
157+
}
105158
const storedBaseURL = await get<string>(baseURLKey);
106159
if (storedBaseURL) {
107160
setBaseURL(storedBaseURL);
@@ -129,6 +182,16 @@ const App = () => {
129182
})();
130183
}, [setActiveModel, setModels, syncModels]);
131184

185+
useEffect(() => {
186+
void refreshBuiltInAvailability();
187+
}, [refreshBuiltInAvailability]);
188+
189+
useEffect(() => {
190+
if (providerKind === "built-in") {
191+
setActiveModel(null);
192+
}
193+
}, [providerKind, setActiveModel]);
194+
132195
const [isGenerating, setIsGenerating] = useState(false);
133196
const [prompt, setPrompt] = useImmer("");
134197
const [textContent, setTextContent] = useImmer("");
@@ -241,6 +304,12 @@ const App = () => {
241304
};
242305
}, [view]);
243306

307+
useEffect(() => {
308+
if (view === "text" && providerKind === "built-in") {
309+
toast.error("Built-in AI supports chat only");
310+
}
311+
}, [providerKind, view]);
312+
244313
const streamControllersRef = useRef<Record<string, AbortController>>({});
245314
const latestAssistantIdRef = useRef<string | undefined>(undefined);
246315
const fileInputRef = useRef<HTMLInputElement | null>(null);
@@ -377,6 +446,10 @@ const App = () => {
377446
if (isTextGenerating) {
378447
return;
379448
}
449+
if (providerKind !== "openai-compatible") {
450+
toast.error("Built-in AI supports chat only");
451+
return;
452+
}
380453
if (!activeModel) {
381454
toast.error("Select a model before predicting");
382455
return;
@@ -390,7 +463,7 @@ const App = () => {
390463
setIsTextGenerating(true);
391464
try {
392465
const stream = streamText({
393-
model: openAIProvider.completionModel(activeModel),
466+
model: openAIProvider!.completionModel(activeModel!),
394467
prompt: textContent,
395468
temperature: 0.3,
396469
abortSignal: abortController.signal,
@@ -426,14 +499,21 @@ const App = () => {
426499
};
427500

428501
const handleSend = async () => {
429-
if (!activeModel) {
430-
toast.error("Select a model before sending");
431-
return;
432-
}
433-
if (!openAIProvider) {
434-
toast.error("Set an API base URL before sending");
502+
const usingOpenAI = providerKind === "openai-compatible";
503+
if (usingOpenAI) {
504+
if (!activeModel) {
505+
toast.error("Select a model before sending");
506+
return;
507+
}
508+
if (!openAIProvider) {
509+
toast.error("Set an API base URL before sending");
510+
return;
511+
}
512+
} else if (builtInAvailability !== "available") {
513+
toast.error("Download the built-in model in Settings before chatting");
435514
return;
436515
}
516+
const builtInModel = usingOpenAI ? null : getBuiltInChatModel();
437517
const trimmedPrompt = prompt.trim();
438518
let resolvedParentId = activeTail() ?? activeTargetId;
439519
if (!resolvedParentId) {
@@ -457,7 +537,9 @@ const App = () => {
457537
}));
458538
try {
459539
const stream = streamText({
460-
model: openAIProvider.chatModel(activeModel),
540+
model: usingOpenAI
541+
? openAIProvider!.chatModel(activeModel!)
542+
: builtInModel!,
461543
messages: contextMessages,
462544
temperature: 0.3,
463545
abortSignal: abortController.signal,
@@ -530,19 +612,28 @@ const App = () => {
530612
const handleSettingsSave = async ({
531613
baseURL: nextBaseURL,
532614
apiKey: nextAPIKey,
615+
providerKind: nextProviderKind,
533616
}: {
534617
baseURL: string;
535618
apiKey: string;
619+
providerKind: ProviderKind;
536620
}) => {
621+
setProviderKind(nextProviderKind);
622+
await set(providerKindKey, nextProviderKind);
537623
setBaseURL(nextBaseURL);
538624
await set(baseURLKey, nextBaseURL);
539625
setAPIKey(nextAPIKey);
540626
await set(apiKeyKey, nextAPIKey);
541-
await syncModels({
542-
baseURLOverride: nextBaseURL,
543-
apiKeyOverride: nextAPIKey,
544-
silent: true,
545-
});
627+
if (nextProviderKind === "openai-compatible") {
628+
await syncModels({
629+
baseURLOverride: nextBaseURL,
630+
apiKeyOverride: nextAPIKey,
631+
silent: true,
632+
force: true,
633+
});
634+
} else {
635+
setActiveModel(null);
636+
}
546637
onSettingsClose();
547638
};
548639

@@ -560,6 +651,13 @@ const App = () => {
560651
models={models}
561652
activeModel={activeModel}
562653
onModelChange={setActiveModel}
654+
modelSelectorDisabled={providerKind !== "openai-compatible"}
655+
modelPlaceholder={
656+
providerKind === "openai-compatible"
657+
? "Select a model"
658+
: "Built-in AI (no model list)"
659+
}
660+
modelStatus={builtInStatusText}
563661
view={view}
564662
onViewChange={setView}
565663
onClear={handleClearConversation}
@@ -685,6 +783,12 @@ const App = () => {
685783
<TextCompletionView
686784
value={textContent}
687785
isGenerating={isTextGenerating}
786+
isPredictDisabled={providerKind !== "openai-compatible"}
787+
disabledReason={
788+
providerKind !== "openai-compatible"
789+
? "Built-in AI supports chat only"
790+
: undefined
791+
}
688792
onChange={(value) => {
689793
setTextContent(value);
690794
}}
@@ -696,6 +800,9 @@ const App = () => {
696800
open={isSettingsOpen}
697801
baseURL={baseURL}
698802
apiKey={apiKey}
803+
providerKind={providerKind}
804+
builtInAvailability={builtInAvailability}
805+
onBuiltInAvailabilityChange={setBuiltInAvailability}
699806
onClose={onSettingsClose}
700807
onSave={handleSettingsSave}
701808
onSyncModels={handleSyncModels}

src/components/Header.tsx

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ interface HeaderProps {
55
models: ModelInfo[];
66
activeModel: string | null;
77
onModelChange: (value: string | null) => void;
8+
modelSelectorDisabled?: boolean;
9+
modelPlaceholder?: string;
10+
modelStatus?: string;
811
view: AppView;
912
onViewChange: (value: AppView) => void;
1013
onClear: () => void;
@@ -27,6 +30,9 @@ const Header = ({
2730
models,
2831
activeModel,
2932
onModelChange,
33+
modelSelectorDisabled = false,
34+
modelPlaceholder,
35+
modelStatus,
3036
view,
3137
onViewChange,
3238
onClear,
@@ -37,17 +43,24 @@ const Header = ({
3743
<div className="flex items-center px-4 py-2">
3844
<div className="flex items-center gap-2">
3945
<h1 className="text-xl font-bold font-mono">iaslate</h1>
40-
<Select
41-
className="w-64"
42-
data={models.map((model) => ({
43-
value: model.id,
44-
label: model.name || model.id,
45-
}))}
46-
value={activeModel}
47-
onChange={onModelChange}
48-
placeholder="Select a model"
49-
aria-label="Select a model"
50-
/>
46+
{modelSelectorDisabled ? (
47+
<div className="flex h-[2.25rem] w-64 items-center rounded-md border border-solid border-slate-300 bg-white px-3 text-sm leading-[1.1] text-slate-900 shadow-xs dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">
48+
{modelStatus ?? "Model selection disabled"}
49+
</div>
50+
) : (
51+
<Select
52+
className="w-64"
53+
data={models.map((model) => ({
54+
value: model.id,
55+
label: model.name || model.id,
56+
}))}
57+
value={activeModel}
58+
onChange={onModelChange}
59+
placeholder={modelPlaceholder ?? "Select a model"}
60+
disabled={modelSelectorDisabled}
61+
aria-label="Select a model"
62+
/>
63+
)}
5164
</div>
5265
<div className="ml-4">
5366
<SegmentedControl

0 commit comments

Comments
 (0)