mirror of
https://github.com/ollama/ollama-js.git
synced 2026-07-01 11:16:25 -04:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c92b18d40 | |||
| f23d7eeb6d | |||
| ef411aa67e | |||
| c8f3fb3b43 | |||
| f7827ba69c | |||
| 133f3623a1 | |||
| a667d4d651 | |||
| c3b668c453 | |||
| 75baea068e | |||
| 603df9fe59 | |||
| b4acbee8a0 | |||
| 5b54730c8b | |||
| 5a132f678d | |||
| 3b8db716b8 | |||
| 9dc9716ece | |||
| de292ee84f | |||
| 5f33c960f2 | |||
| a6689ac591 | |||
| 0ce6961552 | |||
| 0dd23f9fe1 | |||
| cfb069eaf2 | |||
| 495cef8628 | |||
| ab1e7a8ea3 | |||
| 5267b5632a | |||
| be27a947a2 | |||
| 60bfed19ef | |||
| c84c15bb2f | |||
| 45d2e0197e | |||
| 603457c2aa | |||
| c9793cc644 | |||
| f38831b896 | |||
| b6a8868dd5 | |||
| 83682a7990 | |||
| 6a4bfe3ab0 | |||
| e4f9c8f948 | |||
| 28fb3e6d03 | |||
| b24558558f |
@@ -13,7 +13,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
node-version: '20'
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
|
||||
@@ -2,7 +2,6 @@ node_modules
|
||||
build
|
||||
.docs
|
||||
.coverage
|
||||
node_modules
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
.vscode
|
||||
|
||||
@@ -21,7 +21,9 @@ console.log(response.message.content)
|
||||
```
|
||||
|
||||
### Browser Usage
|
||||
|
||||
To use the library without node, import the browser module.
|
||||
|
||||
```javascript
|
||||
import ollama from 'ollama/browser'
|
||||
```
|
||||
@@ -34,7 +36,78 @@ Response streaming can be enabled by setting `stream: true`, modifying function
|
||||
import ollama from 'ollama'
|
||||
|
||||
const message = { role: 'user', content: 'Why is the sky blue?' }
|
||||
const response = await ollama.chat({ model: 'llama3.1', messages: [message], stream: true })
|
||||
const response = await ollama.chat({
|
||||
model: 'llama3.1',
|
||||
messages: [message],
|
||||
stream: true,
|
||||
})
|
||||
for await (const part of response) {
|
||||
process.stdout.write(part.message.content)
|
||||
}
|
||||
```
|
||||
|
||||
## Cloud Models
|
||||
|
||||
Run larger models by offloading to Ollama’s cloud while keeping your local workflow.
|
||||
|
||||
[You can see models currently available on Ollama's cloud here.](https://ollama.com/search?c=cloud)
|
||||
|
||||
### Run via local Ollama
|
||||
|
||||
1) Sign in (one-time):
|
||||
|
||||
```
|
||||
ollama signin
|
||||
```
|
||||
|
||||
2) Pull a cloud model:
|
||||
|
||||
```
|
||||
ollama pull gpt-oss:120b-cloud
|
||||
```
|
||||
|
||||
3) Use as usual (offloads automatically):
|
||||
|
||||
```javascript
|
||||
import { Ollama } from 'ollama'
|
||||
|
||||
const ollama = new Ollama()
|
||||
const response = await ollama.chat({
|
||||
model: 'gpt-oss:120b-cloud',
|
||||
messages: [{ role: 'user', content: 'Explain quantum computing' }],
|
||||
stream: true,
|
||||
})
|
||||
for await (const part of response) {
|
||||
process.stdout.write(part.message.content)
|
||||
}
|
||||
```
|
||||
|
||||
### Cloud API (ollama.com)
|
||||
|
||||
Access cloud models directly by pointing the client at `https://ollama.com`.
|
||||
|
||||
1) Create an [API key](https://ollama.com/settings/keys), then set the `OLLAMA_API_KEY` environment variable:
|
||||
|
||||
```
|
||||
export OLLAMA_API_KEY=your_api_key
|
||||
```
|
||||
|
||||
2) Generate a response via the cloud API:
|
||||
|
||||
```javascript
|
||||
import { Ollama } from 'ollama'
|
||||
|
||||
const ollama = new Ollama({
|
||||
host: 'https://ollama.com',
|
||||
headers: { Authorization: 'Bearer ' + process.env.OLLAMA_API_KEY },
|
||||
})
|
||||
|
||||
const response = await ollama.chat({
|
||||
model: 'gpt-oss:120b',
|
||||
messages: [{ role: 'user', content: 'Explain quantum computing' }],
|
||||
stream: true,
|
||||
})
|
||||
|
||||
for await (const part of response) {
|
||||
process.stdout.write(part.message.content)
|
||||
}
|
||||
@@ -57,8 +130,12 @@ ollama.chat(request)
|
||||
- `role` `<string>`: The role of the message sender ('user', 'system', or 'assistant').
|
||||
- `content` `<string>`: The content of the message.
|
||||
- `images` `<Uint8Array[] | string[]>`: (Optional) Images to be included in the message, either as Uint8Array or base64 encoded strings.
|
||||
- `tool_name` `<string>`: (Optional) Add the name of the tool that was executed to inform the model of the result
|
||||
- `format` `<string>`: (Optional) Set the expected format of the response (`json`).
|
||||
- `stream` `<boolean>`: (Optional) When true an `AsyncGenerator` is returned.
|
||||
- `think` `<boolean | "high" | "medium" | "low">`: (Optional) Enable model thinking. Use `true`/`false` or specify a level. Requires model support.
|
||||
- `logprobs` `<boolean>`: (Optional) Return log probabilities for tokens. Requires model support.
|
||||
- `top_logprobs` `<number>`: (Optional) Number of top log probabilities to return per token when `logprobs` is enabled.
|
||||
- `keep_alive` `<string | number>`: (Optional) How long to keep the model loaded. A number (seconds) or a string with a duration unit suffix ("300ms", "1.5h", "2h45m", etc.)
|
||||
- `tools` `<Tool[]>`: (Optional) A list of tool calls the model may make.
|
||||
- `options` `<Options>`: (Optional) Options to configure the runtime.
|
||||
@@ -81,7 +158,13 @@ ollama.generate(request)
|
||||
- `images` `<Uint8Array[] | string[]>`: (Optional) Images to be included, either as Uint8Array or base64 encoded strings.
|
||||
- `format` `<string>`: (Optional) Set the expected format of the response (`json`).
|
||||
- `stream` `<boolean>`: (Optional) When true an `AsyncGenerator` is returned.
|
||||
- `think` `<boolean | "high" | "medium" | "low">`: (Optional) Enable model thinking. Use `true`/`false` or specify a level. Requires model support.
|
||||
- `logprobs` `<boolean>`: (Optional) Return log probabilities for tokens. Requires model support.
|
||||
- `top_logprobs` `<number>`: (Optional) Number of top log probabilities to return per token when `logprobs` is enabled.
|
||||
- `keep_alive` `<string | number>`: (Optional) How long to keep the model loaded. A number (seconds) or a string with a duration unit suffix ("300ms", "1.5h", "2h45m", etc.)
|
||||
- `width` `<number>`: (Optional, Experimental) Width of the generated image in pixels. For image generation models only.
|
||||
- `height` `<number>`: (Optional, Experimental) Height of the generated image in pixels. For image generation models only.
|
||||
- `steps` `<number>`: (Optional, Experimental) Number of diffusion steps. For image generation models only.
|
||||
- `options` `<Options>`: (Optional) Options to configure the runtime.
|
||||
- Returns: `<GenerateResponse>`
|
||||
|
||||
@@ -186,6 +269,28 @@ ollama.embed(request)
|
||||
- `options` `<Options>`: (Optional) Options to configure the runtime.
|
||||
- Returns: `<EmbedResponse>`
|
||||
|
||||
### web search
|
||||
- Web search capability requires an Ollama account. [Sign up on ollama.com](https://ollama.com/signup)
|
||||
- Create an API key by visiting https://ollama.com/settings/keys
|
||||
```javascript
|
||||
ollama.webSearch(request)
|
||||
```
|
||||
|
||||
- `request` `<Object>`: The search request parameters.
|
||||
- `query` `<string>`: The search query string.
|
||||
- `max_results` `<number>`: (Optional) Maximum results to return (default 5, max 10).
|
||||
- Returns: `<SearchResponse>`
|
||||
|
||||
### web fetch
|
||||
|
||||
```javascript
|
||||
ollama.webFetch(request)
|
||||
```
|
||||
|
||||
- `request` `<Object>`: The fetch request parameters.
|
||||
- `url` `<string>`: The URL to fetch.
|
||||
- Returns: `<FetchResponse>`
|
||||
|
||||
### ps
|
||||
|
||||
```javascript
|
||||
@@ -194,6 +299,14 @@ ollama.ps()
|
||||
|
||||
- Returns: `<ListResponse>`
|
||||
|
||||
### version
|
||||
|
||||
```javascript
|
||||
ollama.version()
|
||||
```
|
||||
|
||||
- Returns: `<VersionResponse>`
|
||||
|
||||
### abort
|
||||
|
||||
```javascript
|
||||
@@ -203,7 +316,7 @@ ollama.abort()
|
||||
This method will abort **all** streamed generations currently running with the client instance.
|
||||
If there is a need to manage streams with timeouts, it is recommended to have one Ollama client per stream.
|
||||
|
||||
All asynchronous threads listening to streams (typically the ```for await (const part of response)```) will throw an ```AbortError``` exception. See [examples/abort/abort-all-requests.ts](examples/abort/abort-all-requests.ts) for an example.
|
||||
All asynchronous threads listening to streams (typically the `for await (const part of response)`) will throw an `AbortError` exception. See [examples/abort/abort-all-requests.ts](examples/abort/abort-all-requests.ts) for an example.
|
||||
|
||||
## Custom client
|
||||
|
||||
@@ -211,6 +324,7 @@ A custom client can be created with the following fields:
|
||||
|
||||
- `host` `<string>`: (Optional) The Ollama host address. Default: `"http://127.0.0.1:11434"`.
|
||||
- `fetch` `<Object>`: (Optional) The fetch library used to make requests to the Ollama host.
|
||||
- `headers` `<Object>`: (Optional) Custom headers to include with every request.
|
||||
|
||||
```javascript
|
||||
import { Ollama } from 'ollama'
|
||||
@@ -222,6 +336,23 @@ const response = await ollama.chat({
|
||||
})
|
||||
```
|
||||
|
||||
## Custom Headers
|
||||
|
||||
You can set custom headers that will be included with every request:
|
||||
|
||||
```javascript
|
||||
import { Ollama } from 'ollama'
|
||||
|
||||
const ollama = new Ollama({
|
||||
host: 'http://127.0.0.1:11434',
|
||||
headers: {
|
||||
Authorization: 'Bearer <api key>',
|
||||
'X-Custom-Header': 'custom-value',
|
||||
'User-Agent': 'MyApp/1.0',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project files run:
|
||||
|
||||
+7
-1
@@ -1,10 +1,16 @@
|
||||
## Examples
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Note: Ensure that `npm build` has been run before running the examples.
|
||||
> Note: Ensure that `npm run build` has been run before running the examples.
|
||||
|
||||
To run the examples run:
|
||||
|
||||
```sh
|
||||
npx tsx <folder-name>/<file-name>.ts
|
||||
```
|
||||
|
||||
### Image Generation (Experimental)
|
||||
|
||||
> **Note:** Image generation is experimental and currently only available on macOS.
|
||||
|
||||
- [image-generation/image-generation.ts](image-generation/image-generation.ts)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Image generation is experimental and currently only available on macOS
|
||||
|
||||
import ollama from 'ollama'
|
||||
import { writeFileSync } from 'fs'
|
||||
|
||||
async function main() {
|
||||
const prompt = 'a sunset over mountains'
|
||||
console.log(`Prompt: ${prompt}`)
|
||||
|
||||
const response = await ollama.generate({
|
||||
model: 'x/z-image-turbo',
|
||||
prompt,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
for await (const part of response) {
|
||||
if (part.image) {
|
||||
// Final response contains the image
|
||||
const imageBuffer = Buffer.from(part.image, 'base64')
|
||||
writeFileSync('output.png', imageBuffer)
|
||||
console.log('\nImage saved to output.png')
|
||||
} else if (part.total) {
|
||||
// Progress update
|
||||
process.stdout.write(`\rProgress: ${part.completed ?? 0}/${part.total}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Ollama, Logprob } from 'ollama';
|
||||
|
||||
function printLogprobs(entries: Logprob[], label: string) {
|
||||
console.log(`\n${label}:`)
|
||||
for (const entry of entries) {
|
||||
console.log(` token=${entry.token.padEnd(12)} logprob=${entry.logprob.toFixed(3)}`)
|
||||
for (const alt of entry.top_logprobs ?? []) {
|
||||
console.log(` alt -> ${alt.token.padEnd(12)} (${alt.logprob.toFixed(3)})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const client = new Ollama()
|
||||
console.log(`Using model: gemma3`)
|
||||
|
||||
const chatResponse = await client.chat({
|
||||
model: 'gemma3',
|
||||
messages: [{ role: 'user', content: 'Say hello in one word.' }],
|
||||
logprobs: true,
|
||||
top_logprobs: 3,
|
||||
})
|
||||
console.log('Chat response:', chatResponse.message.content)
|
||||
printLogprobs(chatResponse.logprobs ?? [], 'chat logprobs')
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import ollama from 'ollama'
|
||||
|
||||
async function main() {
|
||||
const response = await ollama.chat({
|
||||
model: 'deepseek-r1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'What is 10 + 23',
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
think: true,
|
||||
})
|
||||
|
||||
console.log('Thinking:\n========\n\n' + response.message.thinking)
|
||||
console.log('\nResponse:\n========\n\n' + response.message.content + '\n\n')
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
import ollama from 'ollama'
|
||||
|
||||
function printHeading(text: string) {
|
||||
console.log(text)
|
||||
console.log('='.repeat(text.length))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const messages = [{ role: 'user', content: 'What is 10 + 23?' }]
|
||||
|
||||
// gpt-oss supports 'low', 'medium', 'high'
|
||||
const thinkingLevels = ['low', 'medium', 'high'] as const
|
||||
|
||||
for (const [index, level] of thinkingLevels.entries()) {
|
||||
const response = await ollama.chat({
|
||||
model: 'gpt-oss:20b',
|
||||
messages,
|
||||
think: level,
|
||||
})
|
||||
|
||||
printHeading(`Thinking (${level})`)
|
||||
console.log(response.message.thinking ?? '')
|
||||
console.log('\n')
|
||||
|
||||
printHeading('Response')
|
||||
console.log(response.message.content)
|
||||
console.log('\n')
|
||||
|
||||
if (index < thinkingLevels.length - 1) {
|
||||
console.log('-'.repeat(20))
|
||||
console.log('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
import ollama from 'ollama'
|
||||
|
||||
async function main() {
|
||||
const response = await ollama.chat({
|
||||
model: 'deepseek-r1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'What is 10 + 23',
|
||||
},
|
||||
],
|
||||
stream: true,
|
||||
think: true,
|
||||
})
|
||||
|
||||
let startedThinking = false
|
||||
let finishedThinking = false
|
||||
|
||||
for await (const chunk of response) {
|
||||
if (chunk.message.thinking && !startedThinking) {
|
||||
startedThinking = true
|
||||
process.stdout.write('Thinking:\n========\n\n')
|
||||
} else if (chunk.message.content && startedThinking && !finishedThinking) {
|
||||
finishedThinking = true
|
||||
process.stdout.write('\n\nResponse:\n========\n\n')
|
||||
}
|
||||
|
||||
if (chunk.message.thinking) {
|
||||
process.stdout.write(chunk.message.thinking)
|
||||
} else if (chunk.message.content) {
|
||||
process.stdout.write(chunk.message.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,142 @@
|
||||
import ollama, { Ollama } from 'ollama';
|
||||
|
||||
// Mock weather functions
|
||||
function getTemperature(args: { city: string }): string {
|
||||
const validCities = ['London', 'Paris', 'New York', 'Tokyo', 'Sydney'];
|
||||
|
||||
if (!validCities.includes(args.city)) {
|
||||
return 'Unknown city';
|
||||
}
|
||||
|
||||
return `${Math.floor(Math.random() * 36)} degrees Celsius`;
|
||||
}
|
||||
|
||||
function getConditions(args: { city: string }): string {
|
||||
const validCities = ['London', 'Paris', 'New York', 'Tokyo', 'Sydney'];
|
||||
|
||||
if (!validCities.includes(args.city)) {
|
||||
return 'Unknown city';
|
||||
}
|
||||
|
||||
const conditions = ['sunny', 'cloudy', 'rainy', 'snowy'];
|
||||
return conditions[Math.floor(Math.random() * conditions.length)];
|
||||
}
|
||||
|
||||
// Tool definitions
|
||||
const getTemperatureTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'getTemperature',
|
||||
description: 'Get the temperature for a city in Celsius',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['city'],
|
||||
properties: {
|
||||
city: { type: 'string', description: 'The name of the city' }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getConditionsTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'getConditions',
|
||||
description: 'Get the weather conditions for a city',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['city'],
|
||||
properties: {
|
||||
city: { type: 'string', description: 'The name of the city' }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function run(model: string) {
|
||||
const cities = ['London', 'Paris', 'New York', 'Tokyo', 'Sydney'];
|
||||
const city = cities[Math.floor(Math.random() * cities.length)];
|
||||
const city2 = cities[Math.floor(Math.random() * cities.length)];
|
||||
|
||||
const messages = [{
|
||||
role: 'user',
|
||||
content: `What is the temperature in ${city}? and what are the weather conditions in ${city2}?`
|
||||
}];
|
||||
console.log('----- Prompt:', messages[0].content, '\n');
|
||||
|
||||
const ollama = new Ollama();
|
||||
const availableFunctions = {
|
||||
getTemperature,
|
||||
getConditions
|
||||
};
|
||||
|
||||
const response = await ollama.chat({
|
||||
model: model,
|
||||
messages: messages,
|
||||
tools: [getTemperatureTool, getConditionsTool],
|
||||
stream: true,
|
||||
think: true
|
||||
});
|
||||
|
||||
for await (const chunk of response) {
|
||||
if (chunk.message.thinking) {
|
||||
process.stdout.write(chunk.message.thinking);
|
||||
}
|
||||
if (chunk.message.content) {
|
||||
process.stdout.write(chunk.message.content);
|
||||
}
|
||||
if (chunk.message.tool_calls) {
|
||||
for (const tool of chunk.message.tool_calls) {
|
||||
const functionToCall = availableFunctions[tool.function.name];
|
||||
if (functionToCall) {
|
||||
console.log('\nCalling function:', tool.function.name, 'with arguments:', tool.function.arguments);
|
||||
const output = functionToCall(tool.function.arguments);
|
||||
console.log('> Function output:', output, '\n');
|
||||
|
||||
messages.push(chunk.message);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: output.toString(),
|
||||
tool_name: tool.function.name,
|
||||
});
|
||||
} else {
|
||||
console.log('Function', tool.function.name, 'not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('----- Sending result back to model \n');
|
||||
|
||||
if (messages.some(msg => msg.role === 'tool')) {
|
||||
const finalResponse = await ollama.chat({
|
||||
model: model,
|
||||
messages: messages,
|
||||
tools: [getTemperatureTool, getConditionsTool],
|
||||
stream: true,
|
||||
think: true
|
||||
});
|
||||
|
||||
let doneThinking = false;
|
||||
for await (const chunk of finalResponse) {
|
||||
if (chunk.message.thinking) {
|
||||
process.stdout.write(chunk.message.thinking);
|
||||
}
|
||||
if (chunk.message.content) {
|
||||
if (!doneThinking) {
|
||||
console.log('\n----- Final result:');
|
||||
doneThinking = true;
|
||||
}
|
||||
process.stdout.write(chunk.message.content);
|
||||
}
|
||||
if (chunk.message.tool_calls) {
|
||||
console.log('Model returned tool calls:');
|
||||
console.log(chunk.message.tool_calls);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('No tool calls returned');
|
||||
}
|
||||
}
|
||||
|
||||
run('qwen3').catch(console.error);
|
||||
@@ -0,0 +1,771 @@
|
||||
import type { SearchRequest, SearchResponse, FetchRequest, FetchResponse } from 'ollama'
|
||||
|
||||
interface Page {
|
||||
url: string
|
||||
title: string
|
||||
text: string
|
||||
lines: string[]
|
||||
links: Record<number, string>
|
||||
fetchedAt: Date
|
||||
}
|
||||
|
||||
interface BrowserStateData {
|
||||
pageStack: string[]
|
||||
viewTokens: number
|
||||
urlToPage: Record<string, Page>
|
||||
}
|
||||
|
||||
interface WebSearchResult {
|
||||
title?: string
|
||||
url?: string
|
||||
content: {
|
||||
fullText: string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Default number of tokens to show when calling displayPage
|
||||
const DEFAULT_VIEW_TOKENS = 1024
|
||||
|
||||
// Capped tool content length
|
||||
const CAPPED_TOOL_CONTENT_LEN = 8000
|
||||
|
||||
function capToolContent(text: string): string {
|
||||
if (!text) {
|
||||
return text
|
||||
}
|
||||
if (text.length <= CAPPED_TOOL_CONTENT_LEN) {
|
||||
return text
|
||||
}
|
||||
if (CAPPED_TOOL_CONTENT_LEN <= 1) {
|
||||
return text.substring(0, CAPPED_TOOL_CONTENT_LEN)
|
||||
}
|
||||
return text.substring(0, CAPPED_TOOL_CONTENT_LEN - 1) + '…'
|
||||
}
|
||||
|
||||
/**
|
||||
* The Browser tool provides web browsing capability.
|
||||
* The model uses the tool by usually doing a search first and then choosing to either open a page,
|
||||
* find a term in a page, or do another search.
|
||||
*
|
||||
* The tool optionally may open a URL directly - especially if one is passed in.
|
||||
*
|
||||
* Each action is saved into an append-only page stack to keep track of the history of the browsing session.
|
||||
* Each Execute() for a tool returns the full current state of the browser.
|
||||
*
|
||||
* A new Browser object is created per request - the state is managed within the class.
|
||||
*/
|
||||
|
||||
/**
|
||||
* BrowserState manages the browsing session state
|
||||
*/
|
||||
export class BrowserState {
|
||||
private data: BrowserStateData
|
||||
|
||||
constructor(initialState?: BrowserStateData) {
|
||||
this.data = initialState || {
|
||||
pageStack: [],
|
||||
viewTokens: DEFAULT_VIEW_TOKENS,
|
||||
urlToPage: {},
|
||||
}
|
||||
}
|
||||
|
||||
getData(): BrowserStateData {
|
||||
return this.data
|
||||
}
|
||||
|
||||
setData(data: BrowserStateData): void {
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
export class Browser {
|
||||
public state: BrowserState
|
||||
private searchClient?: {
|
||||
search: (request: SearchRequest) => Promise<SearchResponse>
|
||||
}
|
||||
private fetchClient?: {
|
||||
fetch: (request: FetchRequest) => Promise<FetchResponse>
|
||||
}
|
||||
|
||||
constructor(
|
||||
initialState?: BrowserStateData,
|
||||
client?: {
|
||||
search: (request: SearchRequest) => Promise<SearchResponse>
|
||||
fetch: (request: FetchRequest) => Promise<FetchResponse>
|
||||
},
|
||||
) {
|
||||
this.state = new BrowserState(initialState)
|
||||
if (client) {
|
||||
this.searchClient = client
|
||||
this.fetchClient = client
|
||||
}
|
||||
}
|
||||
|
||||
setClients(client: {
|
||||
search: (request: SearchRequest) => Promise<SearchResponse>
|
||||
fetch: (request: FetchRequest) => Promise<FetchResponse>
|
||||
}): void {
|
||||
this.searchClient = client
|
||||
this.fetchClient = client
|
||||
}
|
||||
|
||||
getState(): BrowserStateData {
|
||||
return this.state.getData()
|
||||
}
|
||||
|
||||
protected savePage(page: Page): void {
|
||||
const data = this.state.getData()
|
||||
data.urlToPage[page.url] = page
|
||||
data.pageStack.push(page.url)
|
||||
this.state.setData(data)
|
||||
}
|
||||
|
||||
protected getPageFromStack(url: string): Page {
|
||||
const data = this.state.getData()
|
||||
const page = data.urlToPage[url]
|
||||
if (!page) {
|
||||
throw new Error(`Page not found for url ${url}`)
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the end location for viewport based on token limits
|
||||
*/
|
||||
protected getEndLoc(
|
||||
loc: number,
|
||||
numLines: number,
|
||||
totalLines: number,
|
||||
lines: string[],
|
||||
): number {
|
||||
if (numLines <= 0) {
|
||||
const txt = this.joinLinesWithNumbers(lines.slice(loc))
|
||||
const data = this.state.getData()
|
||||
|
||||
if (txt.length > data.viewTokens) {
|
||||
|
||||
const maxCharsPerToken = 128
|
||||
|
||||
|
||||
const upperBound = Math.min((data.viewTokens + 1) * maxCharsPerToken, txt.length)
|
||||
const textToAnalyze = txt.substring(0, upperBound)
|
||||
|
||||
|
||||
const approxTokens = textToAnalyze.length / 4
|
||||
|
||||
if (approxTokens > data.viewTokens) {
|
||||
|
||||
const endIdx = Math.min(data.viewTokens * 4, txt.length)
|
||||
|
||||
|
||||
numLines = (txt.substring(0, endIdx).match(/\n/g) || []).length + 1
|
||||
} else {
|
||||
numLines = totalLines
|
||||
}
|
||||
} else {
|
||||
numLines = totalLines
|
||||
}
|
||||
}
|
||||
|
||||
return Math.min(loc + numLines, totalLines)
|
||||
}
|
||||
|
||||
|
||||
protected joinLinesWithNumbers(lines: string[]): string {
|
||||
let result = ''
|
||||
let hadZeroLine = false
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (i === 0) {
|
||||
result += 'L0:\n'
|
||||
hadZeroLine = true
|
||||
}
|
||||
if (hadZeroLine) {
|
||||
result += `L${i + 1}: ${lines[i]}\n`
|
||||
} else {
|
||||
result += `L${i}: ${lines[i]}\n`
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes markdown links and replaces them with the special format
|
||||
* Returns the processed text and a map of link IDs to URLs
|
||||
*/
|
||||
protected processMarkdownLinks(text: string): {
|
||||
processedText: string
|
||||
links: Record<number, string>
|
||||
} {
|
||||
const links: Record<number, string> = {}
|
||||
let linkID = 0
|
||||
|
||||
|
||||
const multiLinePattern = /\[([^\]]+)\]\s*\n\s*\(([^)]+)\)/g
|
||||
text = text.replace(multiLinePattern, (match) => {
|
||||
|
||||
let cleaned = match.replace(/\n/g, ' ')
|
||||
|
||||
cleaned = cleaned.replace(/\s+/g, ' ')
|
||||
return cleaned
|
||||
})
|
||||
|
||||
|
||||
const linkPattern = /\[([^\]]+)\]\(([^)]+)\)/g
|
||||
|
||||
const processedText = text.replace(linkPattern, (match, linkText, linkURL) => {
|
||||
const cleanLinkText = linkText.trim()
|
||||
const cleanLinkURL = linkURL.trim()
|
||||
|
||||
|
||||
let domain = cleanLinkURL
|
||||
try {
|
||||
const url = new URL(cleanLinkURL)
|
||||
if (url.host) {
|
||||
domain = url.host
|
||||
|
||||
domain = domain.replace(/^www\./, '')
|
||||
}
|
||||
} catch {
|
||||
|
||||
}
|
||||
|
||||
|
||||
const formatted = `【${linkID}†${cleanLinkText}†${domain}】`
|
||||
|
||||
|
||||
links[linkID] = cleanLinkURL
|
||||
linkID++
|
||||
|
||||
return formatted
|
||||
})
|
||||
|
||||
return { processedText, links }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps text lines to a specified width
|
||||
*/
|
||||
protected wrapLines(text: string, width = 80): string[] {
|
||||
if (width <= 0) {
|
||||
width = 80
|
||||
}
|
||||
|
||||
const lines = text.split('\n')
|
||||
const wrapped: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === '') {
|
||||
|
||||
wrapped.push('')
|
||||
} else if (line.length <= width) {
|
||||
wrapped.push(line)
|
||||
} else {
|
||||
|
||||
const words = line.split(/\s+/)
|
||||
if (words.length === 0) {
|
||||
|
||||
wrapped.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
let currentLine = ''
|
||||
for (const word of words) {
|
||||
|
||||
let testLine = currentLine
|
||||
if (testLine !== '') {
|
||||
testLine += ' '
|
||||
}
|
||||
testLine += word
|
||||
|
||||
if (testLine.length > width && currentLine !== '') {
|
||||
|
||||
wrapped.push(currentLine)
|
||||
currentLine = word
|
||||
} else {
|
||||
|
||||
if (currentLine !== '') {
|
||||
currentLine += ' '
|
||||
}
|
||||
currentLine += word
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (currentLine !== '') {
|
||||
wrapped.push(currentLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return wrapped
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats and returns the page display for the model
|
||||
*/
|
||||
protected displayPage(
|
||||
page: Page,
|
||||
cursor: number,
|
||||
loc: number,
|
||||
numLines: number,
|
||||
): string {
|
||||
let totalLines = page.lines.length
|
||||
|
||||
|
||||
if (totalLines === 0) {
|
||||
page.lines = ['']
|
||||
totalLines = 1
|
||||
}
|
||||
|
||||
|
||||
if (Number.isNaN(loc) || loc < 0) {
|
||||
loc = 0
|
||||
} else if (loc >= totalLines) {
|
||||
loc = Math.max(0, totalLines - 1)
|
||||
}
|
||||
|
||||
|
||||
const endLoc = this.getEndLoc(loc, numLines, totalLines, page.lines)
|
||||
|
||||
let display = `[${cursor}] ${page.title}`
|
||||
if (page.url) {
|
||||
display += `(${page.url})\n`
|
||||
} else {
|
||||
display += '\n'
|
||||
}
|
||||
display += `**viewing lines [${loc} - ${endLoc - 1}] of ${totalLines - 1}**\n\n`
|
||||
|
||||
let hadZeroLine = false
|
||||
for (let i = loc; i < endLoc; i++) {
|
||||
if (i === 0) {
|
||||
display += 'L0:\n'
|
||||
hadZeroLine = true
|
||||
}
|
||||
if (hadZeroLine) {
|
||||
display += `L${i + 1}: ${page.lines[i]}\n`
|
||||
} else {
|
||||
display += `L${i}: ${page.lines[i]}\n`
|
||||
}
|
||||
}
|
||||
|
||||
return display
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a search results page that contains all search results
|
||||
*/
|
||||
protected buildSearchResultsPageCollection(
|
||||
query: string,
|
||||
results: SearchResponse,
|
||||
): Page {
|
||||
const page: Page = {
|
||||
url: `search_results_${query}`,
|
||||
title: query,
|
||||
text: '',
|
||||
lines: [],
|
||||
links: {},
|
||||
fetchedAt: new Date(),
|
||||
}
|
||||
|
||||
let textBuilder = ''
|
||||
let linkIdx = 0
|
||||
|
||||
|
||||
textBuilder += '\n'
|
||||
textBuilder += 'URL: \n' // L1: URL: (empty for search)
|
||||
textBuilder += '# Search Results\n' // L2: # Search Results
|
||||
textBuilder += '\n' // L3: empty
|
||||
|
||||
for (const result of results.results as any[]) {
|
||||
// Derive domain from URL if available
|
||||
let domain = result.url || ''
|
||||
try {
|
||||
const url = new URL(domain)
|
||||
if (url.host) {
|
||||
domain = url.host.replace(/^www\./, '')
|
||||
}
|
||||
} catch {
|
||||
// leave domain as-is if parsing fails
|
||||
}
|
||||
|
||||
const title = result.title || `Result ${linkIdx}`
|
||||
const linkFormat = `* 【${linkIdx}†${title}†${domain}】`
|
||||
textBuilder += linkFormat
|
||||
|
||||
const rawSnippet = result.content || ''
|
||||
const capped = rawSnippet.length > 400 ? rawSnippet.substring(0, 400) + '…' : rawSnippet
|
||||
const cleaned = capped
|
||||
.replace(/\d{40,}/g, (m) => m.substring(0, 40) + '…')
|
||||
.replace(/\s{3,}/g, ' ')
|
||||
textBuilder += cleaned
|
||||
textBuilder += '\n'
|
||||
|
||||
if (result.url) {
|
||||
page.links[linkIdx] = result.url
|
||||
}
|
||||
linkIdx++
|
||||
}
|
||||
|
||||
page.text = textBuilder
|
||||
page.lines = this.wrapLines(page.text, 80)
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a search results page for individual result
|
||||
*/
|
||||
protected buildSearchResultsPage(result: WebSearchResult, linkIdx: number): Page {
|
||||
const page: Page = {
|
||||
url: result.url || `result_${linkIdx}`,
|
||||
title: result.title || `Result ${linkIdx}`,
|
||||
text: '',
|
||||
lines: [],
|
||||
links: {},
|
||||
fetchedAt: new Date(),
|
||||
}
|
||||
|
||||
let textBuilder = ''
|
||||
|
||||
|
||||
const linkFormat = `【${linkIdx}†${result.title || `Result ${linkIdx}`}】`
|
||||
textBuilder += linkFormat
|
||||
textBuilder += '\n'
|
||||
textBuilder += `URL: ${result.url || ''}\n`
|
||||
const numChars = Math.min(result.content.fullText.length, 300)
|
||||
textBuilder += result.content.fullText.substring(0, numChars)
|
||||
textBuilder += '\n\n'
|
||||
|
||||
if (!result.content.fullText && result.url) {
|
||||
page.links[linkIdx] = result.url
|
||||
}
|
||||
|
||||
if (result.content.fullText) {
|
||||
page.text = `URL: ${result.url || ''}\n${result.content.fullText}`
|
||||
const { processedText, links } = this.processMarkdownLinks(page.text)
|
||||
page.text = processedText
|
||||
page.links = links
|
||||
} else {
|
||||
page.text = textBuilder
|
||||
}
|
||||
|
||||
page.lines = this.wrapLines(page.text, 80)
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Page from fetch API results
|
||||
*/
|
||||
protected buildPageFromFetchResult(
|
||||
requestedURL: string,
|
||||
fetchResponse: FetchResponse,
|
||||
): Page {
|
||||
const page: Page = {
|
||||
url: requestedURL,
|
||||
title: requestedURL,
|
||||
text: '',
|
||||
lines: [],
|
||||
links: {},
|
||||
fetchedAt: new Date(),
|
||||
}
|
||||
|
||||
if (fetchResponse.content) {
|
||||
page.text = fetchResponse.content
|
||||
}
|
||||
if (fetchResponse.title) {
|
||||
page.title = fetchResponse.title
|
||||
}
|
||||
if (fetchResponse.url) {
|
||||
page.url = fetchResponse.url
|
||||
}
|
||||
|
||||
if (!page.text) {
|
||||
page.text = 'No content could be extracted from this page.'
|
||||
} else {
|
||||
page.text = `URL: ${page.url}\n${page.text}`
|
||||
}
|
||||
|
||||
const { processedText, links } = this.processMarkdownLinks(page.text)
|
||||
page.text = processedText
|
||||
page.links = links
|
||||
|
||||
|
||||
page.lines = this.wrapLines(page.text, 80)
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a find results page
|
||||
*/
|
||||
protected buildFindResultsPage(pattern: string, page: Page): Page {
|
||||
const findPage: Page = {
|
||||
url: `find_results_${pattern}`,
|
||||
title: `Find results for text: \`${pattern}\` in \`${page.title}\``,
|
||||
text: '',
|
||||
lines: [],
|
||||
links: {},
|
||||
fetchedAt: new Date(),
|
||||
}
|
||||
|
||||
let textBuilder = ''
|
||||
let matchIdx = 0
|
||||
const maxResults = 50
|
||||
const numShowLines = 4
|
||||
const patternLower = pattern.toLowerCase()
|
||||
|
||||
const resultChunks: string[] = []
|
||||
let lineIdx = 0
|
||||
|
||||
while (lineIdx < page.lines.length) {
|
||||
const line = page.lines[lineIdx]
|
||||
const lineLower = line.toLowerCase()
|
||||
|
||||
if (!lineLower.includes(patternLower)) {
|
||||
lineIdx++
|
||||
continue
|
||||
}
|
||||
|
||||
const endLine = Math.min(lineIdx + numShowLines, page.lines.length)
|
||||
|
||||
let snippetBuilder = ''
|
||||
for (let j = lineIdx; j < endLine; j++) {
|
||||
snippetBuilder += page.lines[j]
|
||||
if (j < endLine - 1) {
|
||||
snippetBuilder += '\n'
|
||||
}
|
||||
}
|
||||
const snippet = snippetBuilder
|
||||
|
||||
const linkFormat = `【${matchIdx}†match at L${lineIdx}】`
|
||||
const resultChunk = `${linkFormat}\n${snippet}`
|
||||
resultChunks.push(resultChunk)
|
||||
|
||||
if (resultChunks.length >= maxResults) {
|
||||
break
|
||||
}
|
||||
|
||||
matchIdx++
|
||||
lineIdx += numShowLines
|
||||
}
|
||||
|
||||
|
||||
if (resultChunks.length > 0) {
|
||||
textBuilder = resultChunks.join('\n\n')
|
||||
}
|
||||
|
||||
if (matchIdx === 0) {
|
||||
findPage.text = `No \`find\` results for pattern: \`${pattern}\``
|
||||
} else {
|
||||
findPage.text = textBuilder
|
||||
}
|
||||
|
||||
findPage.lines = this.wrapLines(findPage.text, 80)
|
||||
return findPage
|
||||
}
|
||||
|
||||
async search(args: {
|
||||
query: string
|
||||
topn?: number
|
||||
}): Promise<{ state: BrowserStateData; pageText: string }> {
|
||||
const { query, topn = 5 } = args
|
||||
if (!this.searchClient) {
|
||||
throw new Error('Search client not provided')
|
||||
}
|
||||
|
||||
const searchArgs: SearchRequest = {
|
||||
query,
|
||||
max_results: topn,
|
||||
}
|
||||
|
||||
const result = await this.searchClient.search(searchArgs)
|
||||
|
||||
const searchResultsPage = this.buildSearchResultsPageCollection(query, result)
|
||||
this.savePage(searchResultsPage)
|
||||
const cursor = this.getState().pageStack.length - 1
|
||||
|
||||
for (let i = 0; i < result.results.length; i++) {
|
||||
const searchResult = result.results[i] as any
|
||||
const webSearchResult: WebSearchResult = {
|
||||
title: searchResult.title || 'Search Result',
|
||||
url: searchResult.url || `result_${i}`,
|
||||
content: {
|
||||
fullText: searchResult.content || '',
|
||||
},
|
||||
}
|
||||
const resultPage = this.buildSearchResultsPage(webSearchResult, i + 1)
|
||||
const data = this.getState()
|
||||
data.urlToPage[resultPage.url] = resultPage
|
||||
this.state.setData(data)
|
||||
}
|
||||
|
||||
const pageText = this.displayPage(searchResultsPage, cursor, 0, -1)
|
||||
return { state: this.getState(), pageText: capToolContent(pageText) }
|
||||
}
|
||||
|
||||
async open(args: {
|
||||
id?: string | number
|
||||
cursor?: number
|
||||
loc?: number
|
||||
num_lines?: number
|
||||
}): Promise<{ state: BrowserStateData; pageText: string }> {
|
||||
if (!this.fetchClient) {
|
||||
throw new Error('fetch client not provided')
|
||||
}
|
||||
|
||||
let { cursor = -1 } = args
|
||||
const loc = args.loc ?? 0
|
||||
const num_lines = args.num_lines ?? -1
|
||||
|
||||
let page: Page | undefined
|
||||
const state = this.getState()
|
||||
|
||||
|
||||
if (typeof args.id === 'string') {
|
||||
const url = args.id
|
||||
|
||||
if (state.urlToPage[url]) {
|
||||
this.savePage(state.urlToPage[url])
|
||||
cursor = this.getState().pageStack.length - 1
|
||||
const pageText = this.displayPage(state.urlToPage[url], cursor, loc, num_lines)
|
||||
return { state: this.getState(), pageText: capToolContent(pageText) }
|
||||
}
|
||||
|
||||
const fetchResponse = await this.fetchClient.fetch({ url })
|
||||
const newPage = this.buildPageFromFetchResult(url, fetchResponse)
|
||||
|
||||
this.savePage(newPage)
|
||||
cursor = this.getState().pageStack.length - 1
|
||||
const pageText = this.displayPage(newPage, cursor, loc, num_lines)
|
||||
return { state: this.getState(), pageText: capToolContent(pageText) }
|
||||
}
|
||||
|
||||
if (cursor >= 0) {
|
||||
if (cursor >= state.pageStack.length) {
|
||||
cursor = Math.max(0, state.pageStack.length - 1)
|
||||
}
|
||||
page = this.getPageFromStack(state.pageStack[cursor])
|
||||
} else {
|
||||
if (state.pageStack.length !== 0) {
|
||||
const pageURL = state.pageStack[state.pageStack.length - 1]
|
||||
page = this.getPageFromStack(pageURL)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof args.id === 'number') {
|
||||
if (!page) {
|
||||
throw new Error('No current page to resolve link from')
|
||||
}
|
||||
|
||||
const idInt = args.id
|
||||
const pageURL = page.links[idInt]
|
||||
if (!pageURL) {
|
||||
const errorPage: Page = {
|
||||
url: `invalid_link_${idInt}`,
|
||||
title: `No link with id ${idInt} on \`${page.title}\``,
|
||||
text: '',
|
||||
lines: [],
|
||||
links: {},
|
||||
fetchedAt: new Date(),
|
||||
}
|
||||
|
||||
const availableIds = Object.keys(page.links)
|
||||
.map((k) => Number(k))
|
||||
.sort((a, b) => a - b)
|
||||
const availableList = availableIds.length > 0 ? availableIds.join(', ') : '(none)'
|
||||
|
||||
errorPage.text = [
|
||||
`Requested link id: ${idInt}`,
|
||||
`Current page: ${page.title}`,
|
||||
`Available link ids on this page: ${availableList}`,
|
||||
'',
|
||||
'Tips:',
|
||||
'- To scroll this page, call browser_open with { loc, num_lines } (no id).',
|
||||
'- To open a result from a search results page, pass the correct { cursor, id }.',
|
||||
].join('\n')
|
||||
errorPage.lines = this.wrapLines(errorPage.text, 80)
|
||||
|
||||
this.savePage(errorPage)
|
||||
cursor = this.getState().pageStack.length - 1
|
||||
const pageText = this.displayPage(errorPage, cursor, 0, -1)
|
||||
return { state: this.getState(), pageText: capToolContent(pageText) }
|
||||
}
|
||||
|
||||
let newPage = state.urlToPage[pageURL]
|
||||
if (!newPage) {
|
||||
console.log('[browser_open] fetching URL from link id:', pageURL)
|
||||
let fetchResponse: FetchResponse
|
||||
try {
|
||||
fetchResponse = await this.fetchClient.fetch({ url: pageURL })
|
||||
} catch (error) {
|
||||
// Create an error page when fetch fails
|
||||
const errorPage: Page = {
|
||||
url: pageURL,
|
||||
title: `Failed to fetch: ${pageURL}`,
|
||||
text: `This tool result wasn't accessible. Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
lines: [`This tool result wasn't accessible. Error: ${error instanceof Error ? error.message : 'Unknown error'}`],
|
||||
links: {},
|
||||
fetchedAt: new Date(),
|
||||
}
|
||||
this.savePage(errorPage)
|
||||
cursor = this.getState().pageStack.length - 1
|
||||
const pageText = this.displayPage(errorPage, cursor, 0, -1)
|
||||
return { state: this.getState(), pageText: capToolContent(pageText) }
|
||||
}
|
||||
newPage = this.buildPageFromFetchResult(pageURL, fetchResponse)
|
||||
}
|
||||
|
||||
this.savePage(newPage)
|
||||
cursor = this.getState().pageStack.length - 1
|
||||
const pageText = this.displayPage(newPage, cursor, loc, num_lines)
|
||||
return { state: this.getState(), pageText: capToolContent(pageText) }
|
||||
}
|
||||
|
||||
if (!page) {
|
||||
throw new Error('No current page to display')
|
||||
}
|
||||
|
||||
const currentState = this.getState()
|
||||
currentState.pageStack.push(page.url)
|
||||
this.state.setData(currentState)
|
||||
cursor = currentState.pageStack.length - 1
|
||||
|
||||
const pageText = this.displayPage(page, cursor, loc, num_lines)
|
||||
return { state: this.getState(), pageText: capToolContent(pageText) }
|
||||
}
|
||||
|
||||
async find(args: {
|
||||
pattern: string
|
||||
cursor?: number
|
||||
}): Promise<{ state: BrowserStateData; pageText: string }> {
|
||||
const { pattern } = args
|
||||
let { cursor = -1 } = args
|
||||
|
||||
let page: Page
|
||||
const state = this.getState()
|
||||
|
||||
if (cursor === -1) {
|
||||
if (state.pageStack.length === 0) {
|
||||
throw new Error('No pages to search in')
|
||||
}
|
||||
page = this.getPageFromStack(state.pageStack[state.pageStack.length - 1])
|
||||
cursor = state.pageStack.length - 1
|
||||
} else {
|
||||
if (cursor < 0 || cursor >= state.pageStack.length) {
|
||||
cursor = Math.max(0, Math.min(cursor, state.pageStack.length - 1))
|
||||
}
|
||||
page = this.getPageFromStack(state.pageStack[cursor])
|
||||
}
|
||||
|
||||
const findPage = this.buildFindResultsPage(pattern, page)
|
||||
this.savePage(findPage)
|
||||
const newCursor = this.getState().pageStack.length - 1
|
||||
|
||||
const pageText = this.displayPage(findPage, newCursor, 0, -1)
|
||||
return { state: this.getState(), pageText: capToolContent(pageText) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import ollama, { Ollama } from 'ollama'
|
||||
import type { Message } from 'ollama'
|
||||
import { Browser } from './gpt-oss-browser-tools-helpers'
|
||||
|
||||
async function main() {
|
||||
if (!process.env.OLLAMA_API_KEY) {
|
||||
throw new Error('Set OLLAMA_API_KEY to use browser tools')
|
||||
}
|
||||
|
||||
const client = new Ollama({
|
||||
headers: process.env.OLLAMA_API_KEY
|
||||
? { Authorization: `Bearer ${process.env.OLLAMA_API_KEY}` }
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const browser = new Browser(undefined, {
|
||||
search: (request) => client.webSearch(request as any),
|
||||
fetch: (request) => client.webFetch(request as any),
|
||||
})
|
||||
|
||||
// Tool schemas for the model
|
||||
const browserSearchTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'websearch',
|
||||
description: 'Performs a web search for the given query.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'The search query string.' },
|
||||
topn: { type: 'number', description: 'Max results to return (default 5).' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const browserOpenTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'browser_open',
|
||||
description: 'Open a search result or URL, or scroll the current page.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
description: 'Link id (number) from the results page, or a URL string to open',
|
||||
anyOf: [{ type: 'number' }, { type: 'string' }],
|
||||
},
|
||||
cursor: { type: 'number', description: 'Page index in the stack to operate on' },
|
||||
loc: { type: 'number', description: 'Start line to view from' },
|
||||
num_lines: { type: 'number', description: 'Number of lines to display (-1 for auto)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const browserFindTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'browser_find',
|
||||
description: 'Find a pattern within the currently open page.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pattern: { type: 'string', description: 'Text to search for in the page' },
|
||||
cursor: { type: 'number', description: 'Page index in the stack to search' },
|
||||
},
|
||||
required: ['pattern'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const availableTools = {
|
||||
websearch: async (args: { query: string; topn?: number }) => {
|
||||
const result = await browser.search(args)
|
||||
return result.pageText
|
||||
},
|
||||
browser_open: async (args: {
|
||||
id?: string | number
|
||||
cursor?: number
|
||||
loc?: number
|
||||
num_lines?: number
|
||||
}) => {
|
||||
const result = await browser.open(args)
|
||||
return result.pageText
|
||||
},
|
||||
browser_find: async (args: { pattern: string; cursor?: number }) => {
|
||||
const result = await browser.find(args)
|
||||
return result.pageText
|
||||
},
|
||||
}
|
||||
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'what is ollama new engine?',
|
||||
},
|
||||
]
|
||||
|
||||
console.log('Prompt:', messages.find((m) => m.role === 'user')?.content, '\n')
|
||||
|
||||
while (true) {
|
||||
const response = await client.chat({
|
||||
model: 'gpt-oss',
|
||||
messages: messages,
|
||||
tools: [browserSearchTool, browserOpenTool, browserFindTool],
|
||||
stream: true,
|
||||
think: true,
|
||||
})
|
||||
|
||||
let hadToolCalls = false
|
||||
let startedThinking = false
|
||||
let finishedThinking = false
|
||||
let content = ''
|
||||
let thinking = ''
|
||||
|
||||
for await (const chunk of response) {
|
||||
if (chunk.message.thinking && !startedThinking) {
|
||||
startedThinking = true
|
||||
process.stdout.write('Thinking:\n========\n\n')
|
||||
} else if (chunk.message.content && startedThinking && !finishedThinking) {
|
||||
finishedThinking = true
|
||||
process.stdout.write('\n\nResponse:\n========\n\n')
|
||||
}
|
||||
|
||||
if (chunk.message.thinking) {
|
||||
thinking += chunk.message.thinking
|
||||
process.stdout.write(chunk.message.thinking)
|
||||
}
|
||||
if (chunk.message.content) {
|
||||
content += chunk.message.content
|
||||
process.stdout.write(chunk.message.content)
|
||||
}
|
||||
if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: content,
|
||||
thinking: thinking,
|
||||
})
|
||||
|
||||
hadToolCalls = true
|
||||
for (const toolCall of chunk.message.tool_calls) {
|
||||
const functionToCall =
|
||||
availableTools[toolCall.function.name]
|
||||
if (functionToCall) {
|
||||
const args = toolCall.function.arguments as any
|
||||
console.log('\nCalling function:', toolCall.function.name, 'with arguments:', args)
|
||||
let output
|
||||
try {
|
||||
output = await functionToCall(args)
|
||||
} catch (error) {
|
||||
output = { error: error instanceof Error ? error.message : 'Unknown error' }
|
||||
}
|
||||
messages.push(chunk.message)
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: JSON.stringify(output),
|
||||
tool_name: toolCall.function.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hadToolCalls) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
Ollama,
|
||||
type Message,
|
||||
type WebSearchResponse,
|
||||
type WebFetchResponse,
|
||||
} from 'ollama'
|
||||
|
||||
async function main() {
|
||||
// Set enviornment variable OLLAMA_API_KEY=<YOUR>.<KEY>
|
||||
// or set the header manually
|
||||
// const client = new Ollama({
|
||||
// headers: { Authorization: `Bearer ${process.env.OLLAMA_API_KEY}` },
|
||||
// })
|
||||
const client = new Ollama()
|
||||
|
||||
// Tool schemas
|
||||
const webSearchTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'webSearch',
|
||||
description: 'Performs a web search for the given query.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query string.' },
|
||||
max_results: {
|
||||
type: 'number',
|
||||
description: 'The maximum number of results to return per query (default 3).',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const webFetchTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'webFetch',
|
||||
description: 'Fetches a single page by URL.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'A single URL to fetch.' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const availableTools = {
|
||||
webSearch: async (args: {
|
||||
query: string
|
||||
max_results?: number
|
||||
}): Promise<WebSearchResponse> => {
|
||||
const res = await client.webSearch(args)
|
||||
return res as WebSearchResponse
|
||||
},
|
||||
webFetch: async (args: { url: string }): Promise<WebFetchResponse> => {
|
||||
const res = await client.webFetch(args)
|
||||
return res as WebFetchResponse
|
||||
},
|
||||
}
|
||||
|
||||
const query = 'What is Ollama?'
|
||||
console.log('Prompt:', query, '\n')
|
||||
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: query,
|
||||
},
|
||||
]
|
||||
|
||||
while (true) {
|
||||
const response = await client.chat({
|
||||
model: 'qwen3',
|
||||
messages: messages,
|
||||
tools: [webSearchTool, webFetchTool],
|
||||
stream: true,
|
||||
think: true,
|
||||
})
|
||||
|
||||
let hadToolCalls = false
|
||||
var content = ''
|
||||
var thinking = ''
|
||||
for await (const chunk of response) {
|
||||
if (chunk.message.thinking) {
|
||||
thinking += chunk.message.thinking
|
||||
}
|
||||
if (chunk.message.content) {
|
||||
content += chunk.message.content
|
||||
process.stdout.write(chunk.message.content)
|
||||
}
|
||||
if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) {
|
||||
hadToolCalls = true
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: content,
|
||||
thinking: thinking,
|
||||
tool_calls: chunk.message.tool_calls,
|
||||
})
|
||||
// Execute tools and append tool results
|
||||
for (const toolCall of chunk.message.tool_calls) {
|
||||
const functionToCall = availableTools[toolCall.function.name]
|
||||
if (functionToCall) {
|
||||
const args = toolCall.function.arguments as any
|
||||
console.log(
|
||||
'\nCalling function:',
|
||||
toolCall.function.name,
|
||||
'with arguments:',
|
||||
args,
|
||||
)
|
||||
const output = await functionToCall(args)
|
||||
console.log('Function result:', JSON.stringify(output).slice(0, 200), '\n')
|
||||
|
||||
messages.push(chunk.message)
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: JSON.stringify(output).slice(0, 2000 * 4), // cap at ~2000 tokens
|
||||
tool_name: toolCall.function.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hadToolCalls) {
|
||||
process.stdout.write('\n')
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
@@ -24,6 +24,11 @@ import type {
|
||||
ShowRequest,
|
||||
ShowResponse,
|
||||
StatusResponse,
|
||||
WebSearchRequest,
|
||||
WebSearchResponse,
|
||||
WebFetchRequest,
|
||||
WebFetchResponse,
|
||||
VersionResponse,
|
||||
} from './interfaces.js'
|
||||
import { defaultHost } from './constant.js'
|
||||
|
||||
@@ -45,6 +50,8 @@ export class Ollama {
|
||||
this.fetch = config?.fetch ?? fetch
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Abort any ongoing streamed requests to Ollama
|
||||
public abort() {
|
||||
for (const request of this.ongoingStreamedRequests) {
|
||||
@@ -320,9 +327,52 @@ async encodeImage(image: Uint8Array | string): Promise<string> {
|
||||
})
|
||||
return (await response.json()) as ListResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Ollama server version.
|
||||
* @returns {Promise<VersionResponse>} - The server version object.
|
||||
*/
|
||||
async version(): Promise<VersionResponse> {
|
||||
const response = await utils.get(this.fetch, `${this.config.host}/api/version`, {
|
||||
headers: this.config.headers,
|
||||
})
|
||||
return (await response.json()) as VersionResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs web search using the Ollama web search API
|
||||
* @param request {WebSearchRequest} - The search request containing query and options
|
||||
* @returns {Promise<WebSearchResponse>} - The search results
|
||||
* @throws {Error} - If the request is invalid or the server returns an error
|
||||
*/
|
||||
async webSearch(request: WebSearchRequest): Promise<WebSearchResponse> {
|
||||
if (!request.query || request.query.length === 0) {
|
||||
throw new Error('Query is required')
|
||||
}
|
||||
const response = await utils.post(this.fetch, `https://ollama.com/api/web_search`, { ...request }, {
|
||||
headers: this.config.headers
|
||||
})
|
||||
return (await response.json()) as WebSearchResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single page using the Ollama web fetch API
|
||||
* @param request {WebFetchRequest} - The fetch request containing a URL
|
||||
* @returns {Promise<WebFetchResponse>} - The fetch result
|
||||
* @throws {Error} - If the request is invalid or the server returns an error
|
||||
*/
|
||||
async webFetch(request: WebFetchRequest): Promise<WebFetchResponse> {
|
||||
if (!request.url || request.url.length === 0) {
|
||||
throw new Error('URL is required')
|
||||
}
|
||||
const response = await utils.post(this.fetch, `https://ollama.com/api/web_fetch`, { ...request }, { headers: this.config.headers })
|
||||
return (await response.json()) as WebFetchResponse
|
||||
}
|
||||
}
|
||||
|
||||
export default new Ollama()
|
||||
|
||||
// export all types from the main entry point so that packages importing types dont need to specify paths
|
||||
export * from './interfaces.js'
|
||||
|
||||
export type { AbortableAsyncIterator }
|
||||
|
||||
+79
-11
@@ -30,6 +30,7 @@ export interface Options {
|
||||
num_predict: number
|
||||
top_k: number
|
||||
top_p: number
|
||||
min_p: number
|
||||
tfs_z: number
|
||||
typical_p: number
|
||||
repeat_last_n: number
|
||||
@@ -56,6 +57,14 @@ export interface GenerateRequest {
|
||||
format?: string | object
|
||||
images?: Uint8Array[] | string[]
|
||||
keep_alive?: string | number // a number (seconds) or a string with a duration unit suffix ("300ms", "1.5h", "2h45m", etc)
|
||||
think?: boolean | 'high' | 'medium' | 'low'
|
||||
logprobs?: boolean
|
||||
top_logprobs?: number
|
||||
|
||||
// Experimental image generation parameters
|
||||
width?: number
|
||||
height?: number
|
||||
steps?: number
|
||||
|
||||
options?: Partial<Options>
|
||||
}
|
||||
@@ -63,8 +72,10 @@ export interface GenerateRequest {
|
||||
export interface Message {
|
||||
role: string
|
||||
content: string
|
||||
thinking?: string
|
||||
images?: Uint8Array[] | string[]
|
||||
tool_calls?: ToolCall[]
|
||||
tool_name?: string
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
@@ -79,16 +90,20 @@ export interface ToolCall {
|
||||
export interface Tool {
|
||||
type: string;
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: {
|
||||
type: string;
|
||||
required: string[];
|
||||
properties: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
type?: string;
|
||||
parameters?: {
|
||||
type?: string;
|
||||
$defs?: any;
|
||||
items?: any;
|
||||
required?: string[];
|
||||
properties?: {
|
||||
[key: string]: {
|
||||
type: string;
|
||||
description: string;
|
||||
enum?: string[];
|
||||
type?: string | string[];
|
||||
items?: any;
|
||||
description?: string;
|
||||
enum?: any[];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -102,6 +117,9 @@ export interface ChatRequest {
|
||||
format?: string | object
|
||||
keep_alive?: string | number // a number (seconds) or a string with a duration unit suffix ("300ms", "1.5h", "2h45m", etc)
|
||||
tools?: Tool[]
|
||||
think?: boolean | 'high' | 'medium' | 'low'
|
||||
logprobs?: boolean
|
||||
top_logprobs?: number
|
||||
|
||||
options?: Partial<Options>
|
||||
}
|
||||
@@ -152,6 +170,7 @@ export interface EmbedRequest {
|
||||
input: string | string[]
|
||||
truncate?: boolean
|
||||
keep_alive?: string | number // a number (seconds) or a string with a duration unit suffix ("300ms", "1.5h", "2h45m", etc)
|
||||
dimensions?: number
|
||||
|
||||
options?: Partial<Options>
|
||||
}
|
||||
@@ -165,11 +184,20 @@ export interface EmbeddingsRequest {
|
||||
}
|
||||
|
||||
// response types
|
||||
export interface TokenLogprob {
|
||||
token: string
|
||||
logprob: number
|
||||
}
|
||||
|
||||
export interface Logprob extends TokenLogprob {
|
||||
top_logprobs?: TokenLogprob[]
|
||||
}
|
||||
|
||||
export interface GenerateResponse {
|
||||
model: string
|
||||
created_at: Date
|
||||
response: string
|
||||
response?: string
|
||||
thinking?: string
|
||||
done: boolean
|
||||
done_reason: string
|
||||
context: number[]
|
||||
@@ -179,6 +207,12 @@ export interface GenerateResponse {
|
||||
prompt_eval_duration: number
|
||||
eval_count: number
|
||||
eval_duration: number
|
||||
logprobs?: Logprob[]
|
||||
|
||||
// Image generation response fields
|
||||
image?: string // Base64-encoded generated image data
|
||||
completed?: number // Number of completed steps (for streaming progress)
|
||||
total?: number // Total number of steps (for streaming progress)
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
@@ -193,11 +227,15 @@ export interface ChatResponse {
|
||||
prompt_eval_duration: number
|
||||
eval_count: number
|
||||
eval_duration: number
|
||||
logprobs?: Logprob[]
|
||||
}
|
||||
|
||||
export interface EmbedResponse {
|
||||
model: string
|
||||
embeddings: number[][]
|
||||
total_duration: number
|
||||
load_duration: number
|
||||
prompt_eval_count: number
|
||||
}
|
||||
|
||||
export interface EmbeddingsResponse {
|
||||
@@ -240,10 +278,15 @@ export interface ShowResponse {
|
||||
details: ModelDetails
|
||||
messages: Message[]
|
||||
modified_at: Date
|
||||
model_info: Map<string, any>
|
||||
model_info: Map<string, any>,
|
||||
capabilities: string[],
|
||||
projector_info?: Map<string, any>
|
||||
}
|
||||
|
||||
export interface VersionResponse {
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface ListResponse {
|
||||
models: ModelResponse[]
|
||||
}
|
||||
@@ -255,3 +298,28 @@ export interface ErrorResponse {
|
||||
export interface StatusResponse {
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface WebSearchRequest {
|
||||
query: string
|
||||
maxResults?: number
|
||||
}
|
||||
|
||||
export interface WebSearchResult {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface WebSearchResponse {
|
||||
results: WebSearchResult[]
|
||||
}
|
||||
|
||||
// Fetch types
|
||||
export interface WebFetchRequest {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface WebFetchResponse {
|
||||
title: string
|
||||
url: string
|
||||
content: string
|
||||
links: string[]
|
||||
}
|
||||
|
||||
+77
-26
@@ -1,6 +1,6 @@
|
||||
import { version } from './version.js'
|
||||
import { defaultHost, defaultPort } from './constant.js'
|
||||
import type { ErrorResponse, Fetch } from './interfaces.js'
|
||||
import { defaultPort, defaultHost } from './constant.js'
|
||||
import { version } from './version.js'
|
||||
|
||||
/**
|
||||
* An error class for response errors.
|
||||
@@ -28,7 +28,11 @@ export class AbortableAsyncIterator<T extends object> {
|
||||
private readonly itr: AsyncGenerator<T | ErrorResponse>
|
||||
private readonly doneCallback: () => void
|
||||
|
||||
constructor(abortController: AbortController, itr: AsyncGenerator<T | ErrorResponse>, doneCallback: () => void) {
|
||||
constructor(
|
||||
abortController: AbortController,
|
||||
itr: AsyncGenerator<T | ErrorResponse>,
|
||||
doneCallback: () => void,
|
||||
) {
|
||||
this.abortController = abortController
|
||||
this.itr = itr
|
||||
this.doneCallback = doneCallback
|
||||
@@ -119,23 +123,27 @@ function getPlatform(): string {
|
||||
* - An array of key-value pairs representing headers.
|
||||
* @returns {Record<string,string>} - A plain object representing the normalized headers.
|
||||
*/
|
||||
function normalizeHeaders(headers?: HeadersInit | undefined): Record<string,string> {
|
||||
function normalizeHeaders(headers?: HeadersInit | undefined): Record<string, string> {
|
||||
if (headers instanceof Headers) {
|
||||
// If headers are an instance of Headers, convert it to an object
|
||||
const obj: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
obj[key] = value;
|
||||
});
|
||||
return obj;
|
||||
// If headers are an instance of Headers, convert it to an object
|
||||
const obj: Record<string, string> = {}
|
||||
headers.forEach((value, key) => {
|
||||
obj[key] = value
|
||||
})
|
||||
return obj
|
||||
} else if (Array.isArray(headers)) {
|
||||
// If headers are in array format, convert them to an object
|
||||
return Object.fromEntries(headers);
|
||||
// If headers are in array format, convert them to an object
|
||||
return Object.fromEntries(headers)
|
||||
} else {
|
||||
// Otherwise assume it's already a plain object
|
||||
return headers || {};
|
||||
// Otherwise assume it's already a plain object
|
||||
return headers || {}
|
||||
}
|
||||
}
|
||||
|
||||
const readEnvVar = (obj: object, key: string): string | undefined => {
|
||||
return obj[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper around fetch that adds default headers.
|
||||
* @param fetch {Fetch} - The fetch function to use
|
||||
@@ -155,16 +163,41 @@ const fetchWithHeaders = async (
|
||||
} as HeadersInit
|
||||
|
||||
// Normalizes headers into a plain object format.
|
||||
options.headers = normalizeHeaders(options.headers);
|
||||
|
||||
// Filter out default headers from custom headers
|
||||
options.headers = normalizeHeaders(options.headers)
|
||||
|
||||
// Automatically add the API key to the headers if the URL is https://ollama.com
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol === 'https:' && parsed.hostname === 'ollama.com') {
|
||||
const apiKey =
|
||||
typeof process === 'object' &&
|
||||
process !== null &&
|
||||
typeof process.env === 'object' &&
|
||||
process.env !== null
|
||||
? readEnvVar(process.env, 'OLLAMA_API_KEY')
|
||||
: undefined
|
||||
const authorization =
|
||||
options.headers['authorization'] || options.headers['Authorization']
|
||||
if (!authorization && apiKey) {
|
||||
options.headers['Authorization'] = `Bearer ${apiKey}`
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('error parsing url', error)
|
||||
}
|
||||
|
||||
const customHeaders = Object.fromEntries(
|
||||
Object.entries(options.headers).filter(([key]) => !Object.keys(defaultHeaders).some(defaultKey => defaultKey.toLowerCase() === key.toLowerCase()))
|
||||
Object.entries(options.headers).filter(
|
||||
([key]) =>
|
||||
!Object.keys(defaultHeaders).some(
|
||||
(defaultKey) => defaultKey.toLowerCase() === key.toLowerCase(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
options.headers = {
|
||||
...defaultHeaders,
|
||||
...customHeaders
|
||||
...customHeaders,
|
||||
}
|
||||
|
||||
return fetch(url, options)
|
||||
@@ -176,9 +209,13 @@ const fetchWithHeaders = async (
|
||||
* @param host {string} - The host to fetch
|
||||
* @returns {Promise<Response>} - The fetch response
|
||||
*/
|
||||
export const get = async (fetch: Fetch, host: string, options?: { headers?: HeadersInit }): Promise<Response> => {
|
||||
export const get = async (
|
||||
fetch: Fetch,
|
||||
host: string,
|
||||
options?: { headers?: HeadersInit },
|
||||
): Promise<Response> => {
|
||||
const response = await fetchWithHeaders(fetch, host, {
|
||||
headers: options?.headers
|
||||
headers: options?.headers,
|
||||
})
|
||||
|
||||
await checkOk(response)
|
||||
@@ -212,7 +249,7 @@ export const post = async (
|
||||
fetch: Fetch,
|
||||
host: string,
|
||||
data?: Record<string, unknown> | BodyInit,
|
||||
options?: { signal?: AbortSignal, headers?: HeadersInit },
|
||||
options?: { signal?: AbortSignal; headers?: HeadersInit },
|
||||
): Promise<Response> => {
|
||||
const isRecord = (input: any): input is Record<string, unknown> => {
|
||||
return input !== null && typeof input === 'object' && !Array.isArray(input)
|
||||
@@ -224,7 +261,7 @@ export const post = async (
|
||||
method: 'POST',
|
||||
body: formattedData,
|
||||
signal: options?.signal,
|
||||
headers: options?.headers
|
||||
headers: options?.headers,
|
||||
})
|
||||
|
||||
await checkOk(response)
|
||||
@@ -247,7 +284,7 @@ export const del = async (
|
||||
const response = await fetchWithHeaders(fetch, host, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify(data),
|
||||
headers: options?.headers
|
||||
headers: options?.headers,
|
||||
})
|
||||
|
||||
await checkOk(response)
|
||||
@@ -271,10 +308,11 @@ export const parseJSON = async function* <T = unknown>(
|
||||
const { done, value: chunk } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
reader.releaseLock()
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(chunk)
|
||||
buffer += decoder.decode(chunk, { stream: true })
|
||||
|
||||
const parts = buffer.split('\n')
|
||||
|
||||
@@ -289,6 +327,9 @@ export const parseJSON = async function* <T = unknown>(
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining bytes from incomplete multibyte sequences
|
||||
buffer += decoder.decode()
|
||||
|
||||
for (const part of buffer.split('\n').filter((p) => p !== '')) {
|
||||
try {
|
||||
yield JSON.parse(part)
|
||||
@@ -331,7 +372,17 @@ export const formatHost = (host: string): string => {
|
||||
}
|
||||
}
|
||||
|
||||
let formattedHost = `${url.protocol}//${url.hostname}:${port}${url.pathname}`
|
||||
// Build basic auth part if present
|
||||
let auth = ''
|
||||
if (url.username) {
|
||||
auth = url.username
|
||||
if (url.password) {
|
||||
auth += `:${url.password}`
|
||||
}
|
||||
auth += '@'
|
||||
}
|
||||
|
||||
let formattedHost = `${url.protocol}//${auth}${url.hostname}:${port}${url.pathname}`
|
||||
// remove trailing slashes
|
||||
if (formattedHost.endsWith('/')) {
|
||||
formattedHost = formattedHost.slice(0, -1)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { Ollama } from '../src/browser'
|
||||
import type { ChatResponse, GenerateResponse } from '../src/interfaces'
|
||||
import type { AbortableAsyncIterator } from '../src/browser'
|
||||
|
||||
describe('AbortableAsyncIterator type export', () => {
|
||||
it('should be importable from browser module', () => {
|
||||
const typeCheck = (_: AbortableAsyncIterator<ChatResponse> | null) => {}
|
||||
typeCheck(null)
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Ollama logprob request fields', () => {
|
||||
it('forwards logprob settings in generate requests', async () => {
|
||||
const client = new Ollama()
|
||||
const spy = vi
|
||||
.spyOn(client as any, 'processStreamableRequest')
|
||||
.mockResolvedValue({} as GenerateResponse)
|
||||
|
||||
await client.generate({
|
||||
model: 'dummy',
|
||||
prompt: 'Hello',
|
||||
logprobs: true,
|
||||
top_logprobs: 5,
|
||||
})
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'generate',
|
||||
expect.objectContaining({
|
||||
logprobs: true,
|
||||
top_logprobs: 5,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards logprob settings in chat requests', async () => {
|
||||
const client = new Ollama()
|
||||
const spy = vi
|
||||
.spyOn(client as any, 'processStreamableRequest')
|
||||
.mockResolvedValue({} as ChatResponse)
|
||||
|
||||
await client.chat({
|
||||
model: 'dummy',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
logprobs: true,
|
||||
top_logprobs: 3,
|
||||
})
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'chat',
|
||||
expect.objectContaining({
|
||||
logprobs: true,
|
||||
top_logprobs: 3,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Ollama image generation request fields', () => {
|
||||
it('forwards image generation parameters in generate requests', async () => {
|
||||
const client = new Ollama()
|
||||
const spy = vi
|
||||
.spyOn(client as any, 'processStreamableRequest')
|
||||
.mockResolvedValue({} as GenerateResponse)
|
||||
|
||||
await client.generate({
|
||||
model: 'dummy-image',
|
||||
prompt: 'a sunset over mountains',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
steps: 20,
|
||||
})
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'generate',
|
||||
expect.objectContaining({
|
||||
model: 'dummy-image',
|
||||
prompt: 'a sunset over mountains',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
steps: 20,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('handles image generation response with image field', async () => {
|
||||
const mockResponse: GenerateResponse = {
|
||||
model: 'dummy-image',
|
||||
created_at: new Date(),
|
||||
done: true,
|
||||
done_reason: 'stop',
|
||||
context: [],
|
||||
total_duration: 1000,
|
||||
load_duration: 100,
|
||||
prompt_eval_count: 10,
|
||||
prompt_eval_duration: 50,
|
||||
eval_count: 0,
|
||||
eval_duration: 0,
|
||||
image: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
}
|
||||
|
||||
const client = new Ollama()
|
||||
vi.spyOn(client as any, 'processStreamableRequest').mockResolvedValue(mockResponse)
|
||||
|
||||
const response = await client.generate({
|
||||
model: 'dummy-image',
|
||||
prompt: 'a sunset',
|
||||
})
|
||||
|
||||
expect(response.image).toBeDefined()
|
||||
expect(response.done).toBe(true)
|
||||
})
|
||||
|
||||
it('handles streaming progress fields for image generation', async () => {
|
||||
const mockResponse: GenerateResponse = {
|
||||
model: 'dummy-image',
|
||||
created_at: new Date(),
|
||||
done: false,
|
||||
done_reason: '',
|
||||
context: [],
|
||||
total_duration: 0,
|
||||
load_duration: 0,
|
||||
prompt_eval_count: 0,
|
||||
prompt_eval_duration: 0,
|
||||
eval_count: 0,
|
||||
eval_duration: 0,
|
||||
completed: 5,
|
||||
total: 20,
|
||||
}
|
||||
|
||||
const client = new Ollama()
|
||||
vi.spyOn(client as any, 'processStreamableRequest').mockResolvedValue(mockResponse)
|
||||
|
||||
const response = await client.generate({
|
||||
model: 'dummy-image',
|
||||
prompt: 'a sunset',
|
||||
})
|
||||
|
||||
expect(response.completed).toBe(5)
|
||||
expect(response.total).toBe(20)
|
||||
expect(response.done).toBe(false)
|
||||
})
|
||||
})
|
||||
+31
-2
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatHost } from '../src/utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defaultHost } from '../src/constant'
|
||||
import { formatHost } from '../src/utils'
|
||||
|
||||
describe('formatHost Function Tests', () => {
|
||||
it('should return default URL for empty string', () => {
|
||||
@@ -62,4 +62,33 @@ describe('formatHost Function Tests', () => {
|
||||
it('should handle trailing slash with only a port', () => {
|
||||
expect(formatHost(':56789/')).toBe('http://127.0.0.1:56789')
|
||||
})
|
||||
|
||||
// Basic Auth Tests
|
||||
it('should preserve username in URL', () => {
|
||||
expect(formatHost('http://user@localhost:1234')).toBe('http://user@localhost:1234')
|
||||
})
|
||||
|
||||
it('should preserve username and password in URL', () => {
|
||||
expect(formatHost('http://user:pass@localhost:5678')).toBe('http://user:pass@localhost:5678')
|
||||
})
|
||||
|
||||
it('should preserve username with default port', () => {
|
||||
expect(formatHost('http://user@localhost')).toBe('http://user@localhost:80')
|
||||
})
|
||||
|
||||
it('should preserve username and password with default port', () => {
|
||||
expect(formatHost('http://user:pass@localhost')).toBe('http://user:pass@localhost:80')
|
||||
})
|
||||
|
||||
it('should preserve basic auth with https', () => {
|
||||
expect(formatHost('https://user:secret@secure.com')).toBe('https://user:secret@secure.com:443')
|
||||
})
|
||||
|
||||
it('should preserve basic auth with domain and custom port', () => {
|
||||
expect(formatHost('http://admin:1234@example.com:8080')).toBe('http://admin:1234@example.com:8080')
|
||||
})
|
||||
|
||||
it('should preserve basic auth and remove trailing slash', () => {
|
||||
expect(formatHost('http://john:doe@site.com:3000/')).toBe('http://john:doe@site.com:3000')
|
||||
})
|
||||
})
|
||||
|
||||
+27
-2
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { get } from '../src/utils'
|
||||
import { get, parseJSON } from '../src/utils'
|
||||
|
||||
describe('get Function Header Tests', () => {
|
||||
const mockFetch = vi.fn();
|
||||
@@ -79,4 +79,29 @@ describe('get Function Header Tests', () => {
|
||||
headers: expect.objectContaining(defaultHeaders)
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseJSON UTF-8 multibyte character handling', () => {
|
||||
it('should correctly decode multibyte UTF-8 characters split across chunk boundaries', async () => {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
// Create chunks where the 'ь' character (UTF-8: 0xD1 0x8C) is split
|
||||
const chunks = [
|
||||
new Uint8Array([...encoder.encode('{"text":"использоват'), 0xd1]),
|
||||
new Uint8Array([0x8c, ...encoder.encode('"}\n')]),
|
||||
]
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
const itr = parseJSON<{ text: string }>(stream)
|
||||
const { value } = await itr.next()
|
||||
expect(value?.text).toBe('использовать')
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user