Skip to content

Commit 5a795d2

Browse files
committed
added web-llm as llm provider
1 parent 9dfbf68 commit 5a795d2

File tree

11 files changed

+192
-38
lines changed

11 files changed

+192
-38
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@ffmpeg/ffmpeg": "^0.12.10",
2828
"@ffmpeg/util": "^0.12.1",
2929
"@material-tailwind/react": "^2.1.9",
30+
"@mlc-ai/web-llm": "^0.2.73",
3031
"@plasmohq/messaging": "^0.6.2",
3132
"@plasmohq/storage": "^1.9.3",
3233
"@react-hooks-library/core": "^0.5.2",

pnpm-lock.yaml

+16
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/contents/index/mounter.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ function createApp(roomId: string, plasmo: PlasmoSpec, info: StreamInfo): App {
173173
onClick: () => this.start()
174174
},
175175
position: 'top-left',
176-
duration: 6000000,
176+
duration: Infinity,
177177
dismissible: false
178178
})
179179
return

src/llms/cloudflare-ai.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export default class CloudFlareAI implements LLMProviders {
4141
console.warn('Cloudflare AI session is not supported')
4242
return {
4343
...this,
44-
[Symbol.dispose]: () => { }
44+
[Symbol.asyncDispose]: async () => { }
4545
}
4646
}
4747

src/llms/gemini-nano.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export default class GeminiNano implements LLMProviders {
1919
return session.prompt(chat)
2020
} finally {
2121
console.debug('[gemini nano] done')
22-
session[Symbol.dispose]()
22+
await session[Symbol.asyncDispose]()
2323
}
2424
}
2525

@@ -33,7 +33,7 @@ export default class GeminiNano implements LLMProviders {
3333
}
3434
} finally {
3535
console.debug('[gemini nano] done')
36-
session[Symbol.dispose]()
36+
await session[Symbol.asyncDispose]()
3737
}
3838
}
3939

@@ -82,7 +82,7 @@ class GeminiAssistant implements Session<LLMProviders> {
8282
}
8383
}
8484

85-
[Symbol.dispose](): void {
85+
async [Symbol.asyncDispose]() {
8686
this.assistant.destroy()
8787
}
8888
}
@@ -105,7 +105,7 @@ class GeminiSummarizer implements Session<LLMProviders> {
105105
}
106106
}
107107

108-
[Symbol.dispose](): void {
108+
async [Symbol.asyncDispose]() {
109109
this.summarizer.destroy()
110110
}
111111

src/llms/index.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { SettingSchema as LLMSchema } from '~options/fragments/llm'
33
import cloudflare from './cloudflare-ai'
44
import nano from './gemini-nano'
55
import worker from './remote-worker'
6+
import webllm from './web-llm'
67

78
export interface LLMProviders {
89
cumulative: boolean
@@ -12,12 +13,13 @@ export interface LLMProviders {
1213
asSession(): Promise<Session<LLMProviders>>
1314
}
1415

15-
export type Session<T> = Disposable & Omit<T, 'asSession' | 'validate' | 'cumulative'>
16+
export type Session<T> = AsyncDisposable & Omit<T, 'asSession' | 'validate' | 'cumulative'>
1617

1718
const llms = {
1819
cloudflare,
1920
nano,
20-
worker
21+
worker,
22+
webllm
2123
}
2224

2325
export type LLMs = typeof llms

src/llms/models.ts

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { LLMTypes } from "~llms"
2+
3+
export type ModelList = {
4+
providers: LLMTypes[]
5+
models: string[]
6+
}
7+
8+
const models: ModelList[] = [
9+
{
10+
providers: ['worker', 'cloudflare'],
11+
models: [
12+
'@cf/qwen/qwen1.5-14b-chat-awq',
13+
'@cf/qwen/qwen1.5-7b-chat-awq',
14+
'@cf/qwen/qwen1.5-1.8b-chat',
15+
'@hf/google/gemma-7b-it',
16+
'@hf/nousresearch/hermes-2-pro-mistral-7b'
17+
]
18+
},
19+
{
20+
providers: [ 'webllm' ],
21+
models: [
22+
'Qwen2-7B-Instruct-q4f32_1-MLC',
23+
'Llama-3.1-8B-Instruct-q4f32_1-MLC',
24+
'Qwen2.5-14B-Instruct-q4f16_1-MLC',
25+
'gemma-2-9b-it-q4f16_1-MLC',
26+
'Qwen2.5-3B-Instruct-q0f16-MLC'
27+
]
28+
}
29+
]
30+
31+
32+
export default models

src/llms/remote-worker.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export default class RemoteWorker implements LLMProviders {
5454
console.warn('Remote worker session is not supported')
5555
return {
5656
...this,
57-
[Symbol.dispose]: () => { }
57+
[Symbol.asyncDispose]: async () => { }
5858
}
5959
}
6060

src/llms/web-llm.ts

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { CreateMLCEngine, MLCEngine } from "@mlc-ai/web-llm";
2+
import type { LLMProviders, Session } from "~llms";
3+
import type { SettingSchema } from "~options/fragments/llm";
4+
5+
6+
export default class WebLLM implements LLMProviders {
7+
8+
private static readonly DEFAULT_MODEL: string = 'Qwen2-7B-Instruct-q4f32_1-MLC'
9+
10+
private readonly model: string
11+
private readonly engine: MLCEngine
12+
13+
constructor(settings: SettingSchema) {
14+
this.model = settings.model || WebLLM.DEFAULT_MODEL
15+
this.engine = new MLCEngine()
16+
}
17+
18+
cumulative: boolean = true
19+
20+
async validate(): Promise<void> {
21+
await this.engine.reload(this.model)
22+
}
23+
24+
async prompt(chat: string): Promise<string> {
25+
const session = await this.asSession()
26+
try {
27+
console.debug('[web-llm] prompting: ', chat)
28+
return session.prompt(chat)
29+
} finally {
30+
console.debug('[web-llm] done')
31+
await session[Symbol.asyncDispose]()
32+
}
33+
}
34+
35+
async *promptStream(chat: string): AsyncGenerator<string> {
36+
const session = await this.asSession()
37+
try {
38+
console.debug('[web-llm] prompting stream: ', chat)
39+
const res = session.promptStream(chat)
40+
for await (const chunk of res) {
41+
yield chunk
42+
}
43+
} finally {
44+
console.debug('[web-llm] done')
45+
await session[Symbol.asyncDispose]()
46+
}
47+
}
48+
49+
async asSession(): Promise<Session<LLMProviders>> {
50+
const engine = await CreateMLCEngine(this.model, {
51+
initProgressCallback: (progress) => {
52+
console.log('初始化进度:', progress)
53+
}
54+
})
55+
return {
56+
async prompt(chat: string) {
57+
await engine.interruptGenerate()
58+
const c = await engine.completions.create({
59+
prompt: chat,
60+
max_tokens: 512,
61+
temperature: 0.2,
62+
})
63+
return c.choices[0]?.text ?? engine.getMessage()
64+
},
65+
async *promptStream(chat: string): AsyncGenerator<string> {
66+
await engine.interruptGenerate()
67+
const chunks = await engine.completions.create({
68+
prompt: chat,
69+
max_tokens: 512,
70+
temperature: 0.2,
71+
stream: true
72+
})
73+
for await (const chunk of chunks) {
74+
yield chunk.choices[0]?.text|| "";
75+
if (chunk.usage) {
76+
console.debug('Usage:', chunk.usage)
77+
}
78+
}
79+
},
80+
[Symbol.asyncDispose]: engine.unload
81+
}
82+
}
83+
84+
}

src/options/components/Selector.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type SelectorProps<T> = {
1515
disabled?: boolean
1616
options: SelectorOption<T>[]
1717
className?: string
18+
emptyValue?: string
1819
}
1920

2021

@@ -37,7 +38,7 @@ function Selector<T = any>(props: SelectorProps<T>): JSX.Element {
3738
<label className="text-sm ml-1 font-medium text-gray-900 dark:text-white">{props.label}</label>
3839
<div ref={dropdownRef} className={`mt-2 ${props.disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`} onClick={() => !props.disabled && setOpen(!isOpen)}>
3940
<div className={`inline-flex justify-between h-full w-full rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 text-sm font-medium text-gray-700 dark:text-white ${props.disabled ? 'opacity-50 bg-transparent' : 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 dark:focus:ring-gray-500`}>
40-
{props.options.find((option) => option.value === props.value)?.label ?? String(props.value)}
41+
{props.options.find((option) => option.value === props.value)?.label || String(props.value || (props.emptyValue || '请选择'))}
4142
<svg className="-mr-1 ml-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
4243
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
4344
</svg>

src/options/fragments/llm.tsx

+46-28
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Button, Input, Tooltip, Typography } from "@material-tailwind/react"
2-
import { Fragment, useState, type ChangeEvent, type ReactNode } from "react"
2+
import { Fragment, useMemo, useState, type ChangeEvent, type ReactNode } from "react"
33
import { toast } from "sonner/dist"
44
import type { StateProxy } from "~hooks/binding"
55
import type { LLMTypes } from "~llms"
66
import createLLMProvider from "~llms"
7+
import models from "~llms/models"
78
import Selector from "~options/components/Selector"
89

910
export type SettingSchema = {
@@ -57,31 +58,53 @@ function LLMSettings({ state, useHandler }: StateProxy<SettingSchema>): JSX.Elem
5758
const [validating, setValidating] = useState(false)
5859
const handler = useHandler<ChangeEvent<HTMLInputElement>, string>((e) => e.target.value)
5960

61+
const selectableModels = useMemo(
62+
() => models
63+
.filter(({ providers }) => providers.includes(state.provider))
64+
.flatMap(({ models }) => models)
65+
.map(model => ({ label: model, value: model })),
66+
[state.provider]
67+
)
68+
69+
const onSwitchProvider = (provider: LLMTypes) => {
70+
state.provider = provider
71+
state.model = undefined // reset model
72+
if (provider === 'webllm') {
73+
toast.info('使用 WEBLLM 时,请确保你的电脑拥有足够的算力以供 AI 运行。')
74+
}
75+
}
76+
6077
const onValidate = async () => {
6178
setValidating(true)
62-
try {
63-
const provider = createLLMProvider(state)
64-
await provider.validate()
65-
toast.success('配置可用!')
66-
} catch (e) {
67-
toast.error('配置不可用: ' + e.message)
68-
} finally {
69-
setValidating(false)
70-
}
79+
const provider = createLLMProvider(state)
80+
const validation = provider.validate()
81+
toast.dismiss()
82+
toast.promise(validation, {
83+
loading: '正在验证配置...',
84+
success: '配置可用!',
85+
error: err => '配置不可用: ' + (err.message ?? err),
86+
position: 'bottom-center',
87+
duration: Infinity,
88+
finally: () => setValidating(false)
89+
})
7190
}
7291

92+
console.log('provider: ', state.provider)
93+
console.log('model: ', state.model)
94+
7395
return (
7496
<Fragment>
7597
<Selector<typeof state.provider>
7698
className="col-span-2"
7799
data-testid="ai-provider"
78100
label="技术提供"
79101
value={state.provider}
80-
onChange={e => state.provider = e}
102+
onChange={onSwitchProvider}
81103
options={[
82-
{ label: 'Cloudflare AI', value: 'cloudflare' },
83-
{ label: '公共服务器', value: 'worker' },
84-
{ label: 'Chrome 浏览器内置 AI', value: 'nano' }
104+
{ label: 'Cloudflare AI (云)', value: 'cloudflare' },
105+
{ label: '公共服务器 (云)', value: 'worker' },
106+
{ label: 'Chrome 浏览器内置 AI (本地)', value: 'nano' },
107+
{ label: 'Web LLM (本地)', value: 'webllm' }
85108
]}
86109
/>
87110
{state.provider === 'cloudflare' && (
@@ -110,27 +133,22 @@ function LLMSettings({ state, useHandler }: StateProxy<SettingSchema>): JSX.Elem
110133
/>
111134
</Fragment>
112135
)}
113-
{['cloudflare', 'worker'].includes(state.provider) && (
136+
{state.provider === 'nano' && (
137+
<Hints>
138+
<Typography className="underline" as="a" href="https://juejin.cn/post/7401036139384143910" target="_blank">点击此处</Typography>
139+
查看如何启用 Chrome 浏览器内置 AI
140+
</Hints>
141+
)}
142+
{selectableModels.length > 0 && (
114143
<Selector<string>
115144
data-testid="ai-model"
116145
label="模型提供"
117146
value={state.model}
118147
onChange={e => state.model = e}
119-
options={[
120-
{ label: '@cf/qwen/qwen1.5-14b-chat-awq', value: '@cf/qwen/qwen1.5-14b-chat-awq' },
121-
{ label: '@cf/qwen/qwen1.5-7b-chat-awq', value: '@cf/qwen/qwen1.5-7b-chat-awq' },
122-
{ label: '@cf/qwen/qwen1.5-1.8b-chat', value: '@cf/qwen/qwen1.5-1.8b-chat' },
123-
{ label: '@hf/google/gemma-7b-it', value: '@hf/google/gemma-7b-it' },
124-
{ label: '@hf/nousresearch/hermes-2-pro-mistral-7b', value: '@hf/nousresearch/hermes-2-pro-mistral-7b' }
125-
]}
148+
options={selectableModels}
149+
emptyValue="默认"
126150
/>
127151
)}
128-
{state.provider === 'nano' && (
129-
<Hints>
130-
<Typography className="underline" as="a" href="https://juejin.cn/post/7401036139384143910" target="_blank">点击此处</Typography>
131-
查看如何启用 Chrome 浏览器内置 AI
132-
</Hints>
133-
)}
134152
<div className="col-span-2">
135153
<Button disabled={validating} onClick={onValidate} color="blue" size="lg" className="group flex items-center justify-center gap-3 text-[1rem] hover:shadow-lg">
136154
验证是否可用

0 commit comments

Comments
 (0)