Skip to content

Commit 519550f

Browse files
committed
feat: offline/self-hosted large language model support (#11)
1 parent 1a45c76 commit 519550f

File tree

5 files changed

+120
-7
lines changed

5 files changed

+120
-7
lines changed

src/background/apis/custom-api.mjs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// custom api version
2+
3+
// There is a lot of duplicated code here, but it is very easy to refactor.
4+
// The current state is mainly convenient for making targeted changes at any time,
5+
// and it has not yet had a negative impact on maintenance.
6+
// If necessary, I will refactor.
7+
8+
import { getUserConfig, maxResponseTokenLength, Models } from '../../config/index.mjs'
9+
import { fetchSSE } from '../../utils/fetch-sse'
10+
import { getConversationPairs } from '../../utils/get-conversation-pairs'
11+
import { isEmpty } from 'lodash-es'
12+
13+
const getCustomApiPromptBase = async () => {
14+
return `I am a helpful, creative, clever, and very friendly assistant. I am familiar with various languages in the world.`
15+
}
16+
17+
/**
18+
* @param {Browser.Runtime.Port} port
19+
* @param {string} question
20+
* @param {Session} session
21+
* @param {string} apiKey
22+
* @param {string} modelName
23+
*/
24+
export async function generateAnswersWithCustomApi(port, question, session, apiKey, modelName) {
25+
const controller = new AbortController()
26+
const stopListener = (msg) => {
27+
if (msg.stop) {
28+
console.debug('stop generating')
29+
port.postMessage({ done: true })
30+
controller.abort()
31+
port.onMessage.removeListener(stopListener)
32+
}
33+
}
34+
port.onMessage.addListener(stopListener)
35+
port.onDisconnect.addListener(() => {
36+
console.debug('port disconnected')
37+
controller.abort()
38+
})
39+
40+
const prompt = getConversationPairs(session.conversationRecords, true)
41+
prompt.unshift({ role: 'system', content: await getCustomApiPromptBase() })
42+
prompt.push({ role: 'user', content: question })
43+
const apiUrl = (await getUserConfig()).customModelApiUrl
44+
45+
let answer = ''
46+
await fetchSSE(apiUrl, {
47+
method: 'POST',
48+
signal: controller.signal,
49+
headers: {
50+
'Content-Type': 'application/json',
51+
Authorization: `Bearer ${apiKey}`,
52+
},
53+
body: JSON.stringify({
54+
messages: prompt,
55+
model: Models[modelName].value,
56+
stream: true,
57+
max_tokens: maxResponseTokenLength,
58+
}),
59+
onMessage(message) {
60+
console.debug('sse message', message)
61+
if (message === '[DONE]') {
62+
session.conversationRecords.push({ question: question, answer: answer })
63+
console.debug('conversation history', { content: session.conversationRecords })
64+
port.postMessage({ answer: null, done: true, session: session })
65+
return
66+
}
67+
let data
68+
try {
69+
data = JSON.parse(message)
70+
} catch (error) {
71+
console.debug('json error', error)
72+
return
73+
}
74+
if (data.response) answer = data.response
75+
port.postMessage({ answer: answer, done: false, session: null })
76+
},
77+
async onStart() {},
78+
async onEnd() {
79+
port.onMessage.removeListener(stopListener)
80+
},
81+
async onError(resp) {
82+
if (resp instanceof Error) throw resp
83+
port.onMessage.removeListener(stopListener)
84+
if (resp.status === 403) {
85+
throw new Error('CLOUDFLARE')
86+
}
87+
const error = await resp.json().catch(() => ({}))
88+
throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`)
89+
},
90+
})
91+
}

src/background/index.mjs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import {
66
generateAnswersWithChatgptApi,
77
generateAnswersWithGptCompletionApi,
88
} from './apis/openai-api'
9+
import { generateAnswersWithCustomApi } from './apis/custom-api.mjs'
910
import {
1011
chatgptApiModelKeys,
1112
chatgptWebModelKeys,
13+
customApiModelKeys,
1214
defaultConfig,
1315
getUserConfig,
1416
gptApiModelKeys,
15-
isUsingApiKey,
1617
} from '../config/index.mjs'
1718
import { isSafari } from '../utils/is-safari'
1819
import { isFirefox } from '../utils/is-firefox'
@@ -55,9 +56,6 @@ Browser.runtime.onConnect.addListener((port) => {
5556
const session = msg.session
5657
if (!session) return
5758
const config = await getUserConfig()
58-
if (session.useApiKey == null) {
59-
session.useApiKey = isUsingApiKey(config)
60-
}
6159

6260
try {
6361
if (chatgptWebModelKeys.includes(config.modelName)) {
@@ -83,6 +81,14 @@ Browser.runtime.onConnect.addListener((port) => {
8381
config.apiKey,
8482
config.modelName,
8583
)
84+
} else if (customApiModelKeys.includes(config.modelName)) {
85+
await generateAnswersWithCustomApi(
86+
port,
87+
session.question,
88+
session,
89+
config.apiKey,
90+
config.modelName,
91+
)
8692
}
8793
} catch (err) {
8894
console.error(err)

src/config/index.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ export const Models = {
1717
chatgptApi4_8k: { value: 'gpt-4', desc: 'ChatGPT (GPT-4-8k)' },
1818
chatgptApi4_32k: { value: 'gpt-4-32k', desc: 'ChatGPT (GPT-4-32k)' },
1919
gptApiDavinci: { value: 'text-davinci-003', desc: 'GPT-3.5' },
20+
chatglm6bInt4: { value: 'chatglm-6b-int4', desc: 'ChatGLM-6B-Int4' },
2021
}
2122

2223
export const chatgptWebModelKeys = ['chatgptFree35', 'chatgptPlus4']
2324
export const gptApiModelKeys = ['gptApiDavinci']
2425
export const chatgptApiModelKeys = ['chatgptApi35', 'chatgptApi4_8k', 'chatgptApi4_32k']
26+
export const customApiModelKeys = ['chatglm6bInt4']
2527

2628
export const TriggerMode = {
2729
always: 'Always',
@@ -59,6 +61,7 @@ export const defaultConfig = {
5961
customChatGptWebApiUrl: 'https://chat.openai.com',
6062
customChatGptWebApiPath: '/backend-api/conversation',
6163
customOpenAiApiUrl: 'https://api.openai.com',
64+
customModelApiUrl: 'http://localhost:8000/chat/completions',
6265
siteRegex: 'match nothing',
6366
userSiteRegexOnly: false,
6467
inputQuery: '',
@@ -123,6 +126,10 @@ export function isUsingApiKey(config) {
123126
)
124127
}
125128

129+
export function isUsingCustomModel(config) {
130+
return customApiModelKeys.includes(config.modelName)
131+
}
132+
126133
/**
127134
* get user config from local storage
128135
* @returns {Promise<UserConfig>}

src/popup/Popup.jsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
defaultConfig,
99
Models,
1010
isUsingApiKey,
11+
isUsingCustomModel,
1112
} from '../config/index.mjs'
1213
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'
1314
import 'react-tabs/style/react-tabs.css'
@@ -122,6 +123,17 @@ function GeneralPart({ config, updateConfig }) {
122123
</span>
123124
)}
124125
</span>
126+
{isUsingCustomModel(config) && (
127+
<input
128+
type="text"
129+
value={config.customModelApiUrl}
130+
placeholder="Custom Model API Url"
131+
onChange={(e) => {
132+
const value = e.target.value
133+
updateConfig({ customModelApiUrl: value })
134+
}}
135+
/>
136+
)}
125137
</label>
126138
<label>
127139
<legend>Preferred Language</legend>

src/utils/init-session.mjs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
* @property {string|null} messageId - chatGPT web mode
66
* @property {string|null} parentMessageId - chatGPT web mode
77
* @property {Object[]|null} conversationRecords
8-
* @property {bool|null} useApiKey
98
*/
109
/**
1110
* @param {Session} session
@@ -17,14 +16,12 @@ export function initSession({
1716
messageId = null,
1817
parentMessageId = null,
1918
conversationRecords = [],
20-
useApiKey = null,
2119
} = {}) {
2220
return {
2321
question,
2422
conversationId,
2523
messageId,
2624
parentMessageId,
2725
conversationRecords,
28-
useApiKey,
2926
}
3027
}

0 commit comments

Comments
 (0)