@ulam/halohalo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -0
- package/agenticAiService.js +76 -0
- package/aiService.js +31 -0
- package/angular.js +102 -0
- package/connectivity.js +17 -0
- package/constants.js +43 -0
- package/createCompletion.js +113 -0
- package/createProviderConfig.js +80 -0
- package/fetch.js +113 -0
- package/index.js +20 -0
- package/init.js +33 -0
- package/models.js +38 -0
- package/package.json +37 -0
- package/prefs.js +17 -0
- package/providers.js +108 -0
- package/react.js +2 -0
- package/search.js +63 -0
- package/useCompletion.js +17 -0
- package/useProviderConfig.js +22 -0
- package/vue.js +73 -0
package/README.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# @ulam/halohalo
|
|
2
|
+
|
|
3
|
+
AI service adapters, model configuration, and provider abstraction. Vanilla core with a React hooks adapter.
|
|
4
|
+
|
|
5
|
+
Named for halo-halo, the Filipino shaved ice dessert: a mix of many things that somehow works together.
|
|
6
|
+
|
|
7
|
+
## The ulam framework
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
ulam
|
|
11
|
+
├── @ulam/halohalo mixed : AI provider adapters ← you are here
|
|
12
|
+
├── @ulam/taho warm : ARIA live region announcer
|
|
13
|
+
├── @ulam/sili hot : focus management, overlays, routing
|
|
14
|
+
├── @ulam/calamansi sour : i18n, hooks, utilities, logic
|
|
15
|
+
├── @ulam/ube sweet : React UI components, theming
|
|
16
|
+
└── @ulam/sawsawan bridge : wires the above together
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @ulam/halohalo
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Peer dependencies: `fuse.js >= 7` (for search). Framework adapters add `react >= 18`, `vue >= 3`, or `@angular/core >= 17` as needed.
|
|
26
|
+
|
|
27
|
+
## Supported providers
|
|
28
|
+
|
|
29
|
+
- Anthropic (Claude)
|
|
30
|
+
- OpenAI (GPT)
|
|
31
|
+
- Google (Gemini)
|
|
32
|
+
|
|
33
|
+
Bring your own API key. Keys stay in the browser via localStorage and are sent directly to the provider, never to a server.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
### Initialize
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
import { initApiKeys, initModels, getAiProvider } from '@ulam/halohalo'
|
|
41
|
+
|
|
42
|
+
initApiKeys({ anthropic: 'sk-ant-...', openai: 'sk-...' })
|
|
43
|
+
initModels({ anthropic: 'claude-sonnet-4-6', openai: 'gpt-4o' })
|
|
44
|
+
|
|
45
|
+
const provider = getAiProvider() // 'anthropic' | 'openai' | 'google'
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Call a provider
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
import { createCompletion } from '@ulam/halohalo'
|
|
52
|
+
|
|
53
|
+
const result = await createCompletion({
|
|
54
|
+
prompt: 'Rewrite this finding for a mobile context.',
|
|
55
|
+
systemPrompt: 'You are an accessibility audit assistant.',
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Anthropic with tool use
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
import { callAnthropicWithTools } from '@ulam/halohalo'
|
|
63
|
+
|
|
64
|
+
const result = await callAnthropicWithTools({
|
|
65
|
+
messages,
|
|
66
|
+
tools,
|
|
67
|
+
system: 'You are an accessibility audit assistant.',
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Agentic mode
|
|
72
|
+
|
|
73
|
+
Agentic mode uses tool calling to search the corpus for similar past revisions before rewriting, matching the tone and depth of established work.
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
import { getAgenticRefinement } from '@ulam/halohalo'
|
|
77
|
+
|
|
78
|
+
const revised = await getAgenticRefinement({
|
|
79
|
+
finding,
|
|
80
|
+
notes: 'This is specific to mobile, element is a tooltip',
|
|
81
|
+
})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### React hooks adapter
|
|
85
|
+
|
|
86
|
+
```jsx
|
|
87
|
+
import { useProviderConfig, useCompletion } from '@ulam/halohalo/react'
|
|
88
|
+
|
|
89
|
+
function AISettings() {
|
|
90
|
+
const { provider, model, setProvider } = useProviderConfig()
|
|
91
|
+
const { complete, loading } = useCompletion()
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<button onClick={() => complete('Rewrite this for mobile.')}>
|
|
95
|
+
{loading ? 'Revising...' : 'Rewrite'}
|
|
96
|
+
</button>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Vue composables adapter
|
|
102
|
+
|
|
103
|
+
`@ulam/halohalo/vue` provides composables that wrap the vanilla `createCompletion` and `createProviderConfig` with Vue reactivity. Loading and animating state are `readonly` refs that update as the completion runs.
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
import { useCompletion, useProviderConfig } from '@ulam/halohalo/vue'
|
|
107
|
+
import { onUnmounted } from 'vue'
|
|
108
|
+
|
|
109
|
+
// In setup()
|
|
110
|
+
const { loading, animating, complete, cancel, cleanup } = useCompletion()
|
|
111
|
+
onUnmounted(cleanup)
|
|
112
|
+
|
|
113
|
+
// loading.value and animating.value are reactive
|
|
114
|
+
await complete({ prompt: 'Rewrite this for mobile.' })
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`useProviderConfig()` returns reactive refs for `provider`, `models`, and `mode`, plus all setter functions from the vanilla config store.
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
const { provider, setProvider } = useProviderConfig()
|
|
121
|
+
// provider.value is reactive
|
|
122
|
+
setProvider('openai')
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Both composables return a `cleanup` function. Call it in `onUnmounted()` if the composable is used inside a component. For app-level use outside a component, cleanup is optional.
|
|
126
|
+
|
|
127
|
+
### Angular services adapter
|
|
128
|
+
|
|
129
|
+
`@ulam/halohalo/angular` provides two injectable services backed by Angular signals.
|
|
130
|
+
|
|
131
|
+
**`CompletionService`** is not `providedIn: 'root'`. Each injection creates a separate completion instance with its own loading state. Provide it at the component level for scoped instances, or at the application level for a shared one.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { CompletionService, ProviderConfigService, provideHalohalo } from '@ulam/halohalo/angular'
|
|
135
|
+
|
|
136
|
+
// Application root (shared singleton):
|
|
137
|
+
bootstrapApplication(AppComponent, {
|
|
138
|
+
providers: [provideHalohalo()]
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// Or component-level (scoped per component):
|
|
142
|
+
@Component({
|
|
143
|
+
providers: [CompletionService],
|
|
144
|
+
template: `
|
|
145
|
+
<button (click)="rewrite()" [disabled]="completion.loading()">
|
|
146
|
+
{{ completion.loading() ? 'Revising...' : 'Rewrite' }}
|
|
147
|
+
</button>
|
|
148
|
+
`
|
|
149
|
+
})
|
|
150
|
+
export class RewriteButtonComponent {
|
|
151
|
+
completion = inject(CompletionService)
|
|
152
|
+
|
|
153
|
+
async rewrite() {
|
|
154
|
+
await this.completion.complete({ prompt: 'Rewrite for mobile.' })
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`completion.loading()` and `completion.animating()` are Angular `computed` signals that work directly in templates and in `effect()` calls.
|
|
160
|
+
|
|
161
|
+
**`ProviderConfigService`** is `providedIn: 'root'`, giving you one config store for the whole app.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
@Component({ ... })
|
|
165
|
+
export class SettingsComponent {
|
|
166
|
+
providerConfig = inject(ProviderConfigService)
|
|
167
|
+
|
|
168
|
+
// In template:
|
|
169
|
+
// {{ providerConfig.provider() }}
|
|
170
|
+
// (click)="providerConfig.setProvider('openai')"
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Design
|
|
175
|
+
|
|
176
|
+
**Bring your own key.** No proxy, no server, no account. API keys are stored in localStorage and sent directly to the provider.
|
|
177
|
+
|
|
178
|
+
**Provider-agnostic core.** `createCompletion` works the same regardless of which provider is active. Switch providers without changing call sites.
|
|
179
|
+
|
|
180
|
+
**Vanilla-first.** The core has no framework dependency. Import from `/react`, `/vue`, or `/angular` only when you need framework reactivity.
|
|
181
|
+
|
|
182
|
+
## Subpath exports
|
|
183
|
+
|
|
184
|
+
| Import | Contents |
|
|
185
|
+
| ------ | -------- |
|
|
186
|
+
| `@ulam/halohalo` | Vanilla core: `createCompletion`, `createProviderConfig`, `callProvider`, `callAnthropicWithTools`, `getAgenticRefinement`, `searchItems`, `makeSearchTool`, and more |
|
|
187
|
+
| `@ulam/halohalo/react` | `useCompletion`, `useProviderConfig` |
|
|
188
|
+
| `@ulam/halohalo/vue` | `useCompletion`, `useProviderConfig` |
|
|
189
|
+
| `@ulam/halohalo/angular` | `CompletionService`, `ProviderConfigService`, `provideHalohalo` |
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { callAnthropicWithTools } from './fetch.js'
|
|
2
|
+
import { makeSearchTool } from './search.js'
|
|
3
|
+
import { getAdapter } from '@ulam/sawsawan'
|
|
4
|
+
import { AI_AGENTIC_MAX_TOKENS, AGENTIC_MAX_TOOL_TURNS, LS_APIKEY_PREFIX } from './constants.js'
|
|
5
|
+
import { getAiModel } from './prefs.js'
|
|
6
|
+
import { parseAiResponse } from './aiService.js'
|
|
7
|
+
import { getSystemPrompt } from './init.js'
|
|
8
|
+
|
|
9
|
+
export { AiApiError } from './providers.js'
|
|
10
|
+
|
|
11
|
+
const FALLBACK_SYSTEM_PROMPT = `You are an AI assistant helping rewrite text entries based on user notes. Search for related entries before rewriting, then produce a revised description and suggested fix.
|
|
12
|
+
|
|
13
|
+
Format your final output as exactly two lines:
|
|
14
|
+
Description: [rewritten description]
|
|
15
|
+
Suggested Fix: [rewritten suggested fix]`
|
|
16
|
+
|
|
17
|
+
const CORPUS_SEARCH_FIELDS = [
|
|
18
|
+
{ name: 'title', weight: 0.32 },
|
|
19
|
+
{ name: 'keywords', weight: 0.30 },
|
|
20
|
+
{ name: 'desc', weight: 0.07 },
|
|
21
|
+
{ name: 'fix', weight: 0.03 },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
const CORPUS_PICK = ['id', 'title', 'primarySC', 'severity', 'desc', 'fix']
|
|
25
|
+
|
|
26
|
+
export async function getAgenticRefinement({ finding, descText, fixText, note, corpus }) {
|
|
27
|
+
const key = await getAdapter().getKey(`${LS_APIKEY_PREFIX}anthropic`)
|
|
28
|
+
|
|
29
|
+
if (!key) throw new Error('Anthropic API key required for agentic mode. Add one in Settings → AI Assist.')
|
|
30
|
+
|
|
31
|
+
const model = getAiModel('anthropic')
|
|
32
|
+
|
|
33
|
+
const { schema: toolSchema, handler: toolHandler } = makeSearchTool(corpus, {
|
|
34
|
+
name: 'search_corpus',
|
|
35
|
+
description:
|
|
36
|
+
'Search the accessibility finding corpus for entries related to a natural-language query. ' +
|
|
37
|
+
'Call this before rewriting to find similar findings that demonstrate the expected voice, ' +
|
|
38
|
+
'tone, and technical depth. Returns up to 3 matching entries.',
|
|
39
|
+
queryDescription: 'Natural-language search query, e.g. "keyboard focus visible" or "color contrast low vision".',
|
|
40
|
+
fields: CORPUS_SEARCH_FIELDS,
|
|
41
|
+
pick: CORPUS_PICK,
|
|
42
|
+
limit: 3,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const userPrompt = `Refine this accessibility finding based on the auditor's note.
|
|
46
|
+
|
|
47
|
+
Title: ${finding.title}
|
|
48
|
+
WCAG SC: ${finding.primarySC}
|
|
49
|
+
Severity: ${finding.severity}
|
|
50
|
+
Platform: ${finding.platform}
|
|
51
|
+
|
|
52
|
+
Current description:
|
|
53
|
+
${descText}
|
|
54
|
+
|
|
55
|
+
Current suggested fix:
|
|
56
|
+
${fixText}
|
|
57
|
+
|
|
58
|
+
Auditor's note: "${note}"
|
|
59
|
+
|
|
60
|
+
Search the corpus for related findings, then rewrite the description and suggested fix to reflect the refinement.`
|
|
61
|
+
|
|
62
|
+
const messages = [{ role: 'user', content: userPrompt }]
|
|
63
|
+
|
|
64
|
+
const text = await callAnthropicWithTools({
|
|
65
|
+
key,
|
|
66
|
+
model,
|
|
67
|
+
system: getSystemPrompt() || FALLBACK_SYSTEM_PROMPT,
|
|
68
|
+
tools: [toolSchema],
|
|
69
|
+
messages,
|
|
70
|
+
maxTokens: AI_AGENTIC_MAX_TOKENS,
|
|
71
|
+
maxTurns: AGENTIC_MAX_TOOL_TURNS,
|
|
72
|
+
onToolCall: toolHandler,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return parseAiResponse(text)
|
|
76
|
+
}
|
package/aiService.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { callProvider } from './fetch.js'
|
|
2
|
+
import { getAdapter } from '@ulam/sawsawan'
|
|
3
|
+
import { AI_MAX_TOKENS, AI_DESC_REGEX, AI_FIX_REGEX, LS_APIKEY_PREFIX } from './constants.js'
|
|
4
|
+
import { getAiProvider, getAiModel } from './prefs.js'
|
|
5
|
+
import { getBuildPrompt } from './init.js'
|
|
6
|
+
|
|
7
|
+
export { AiApiError, httpStatusToErrorType } from './providers.js'
|
|
8
|
+
|
|
9
|
+
export function parseAiResponse(text) {
|
|
10
|
+
const descMatch = text.match(AI_DESC_REGEX)
|
|
11
|
+
const fixMatch = text.match(AI_FIX_REGEX)
|
|
12
|
+
return {
|
|
13
|
+
desc: descMatch?.[1]?.trim() || null,
|
|
14
|
+
fix: fixMatch?.[1]?.trim() || null,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function getAiRefinement({ finding, descText, fixText, note }) {
|
|
19
|
+
const buildPrompt = getBuildPrompt()
|
|
20
|
+
if (!buildPrompt) throw new Error('halohalo: call initHalohalo({ buildPrompt }) before getAiRefinement')
|
|
21
|
+
|
|
22
|
+
const provider = getAiProvider()
|
|
23
|
+
const key = await getAdapter().getKey(`${LS_APIKEY_PREFIX}${provider}`)
|
|
24
|
+
|
|
25
|
+
if (!key) throw new Error(`No API key found for ${provider}. Add one in Settings.`)
|
|
26
|
+
|
|
27
|
+
const model = getAiModel(provider)
|
|
28
|
+
const prompt = buildPrompt({ finding, descText, fixText, note })
|
|
29
|
+
const text = await callProvider({ provider, model, key, prompt, maxTokens: AI_MAX_TOKENS })
|
|
30
|
+
return parseAiResponse(text)
|
|
31
|
+
}
|
package/angular.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ulam/halohalo/angular: Angular adapter
|
|
3
|
+
*
|
|
4
|
+
* Injectable services wrapping the vanilla halohalo core with Angular signal
|
|
5
|
+
* reactivity. Provide once at the application root via provideHalohalo().
|
|
6
|
+
*/
|
|
7
|
+
import { Injectable, signal, computed } from '@angular/core'
|
|
8
|
+
import { createCompletion } from './createCompletion.js'
|
|
9
|
+
import { createProviderConfig } from './createProviderConfig.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Service for AI completions. Each inject() call gets its own completion
|
|
13
|
+
* instance; loading/animating state is per-instance.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* private completion = inject(CompletionService)
|
|
17
|
+
*
|
|
18
|
+
* await this.completion.complete({ prompt: 'Rewrite for mobile.' })
|
|
19
|
+
* // this.completion.loading() is true while running
|
|
20
|
+
*/
|
|
21
|
+
@Injectable()
|
|
22
|
+
export class CompletionService {
|
|
23
|
+
#instance = createCompletion()
|
|
24
|
+
#loading = signal(false)
|
|
25
|
+
#animating = signal(false)
|
|
26
|
+
|
|
27
|
+
loading = computed(() => this.#loading())
|
|
28
|
+
animating = computed(() => this.#animating())
|
|
29
|
+
|
|
30
|
+
constructor() {
|
|
31
|
+
this.#instance.subscribe(({ loading, animating }) => {
|
|
32
|
+
this.#loading.set(loading)
|
|
33
|
+
this.#animating.set(animating)
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
complete(options) {
|
|
38
|
+
return this.#instance.complete(options)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
cancel() {
|
|
42
|
+
this.#instance.cancel()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
ngOnDestroy() {
|
|
46
|
+
this.#instance.cancel()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Service for AI provider and model configuration. One config store per application.
|
|
52
|
+
*
|
|
53
|
+
* Usage:
|
|
54
|
+
* private providerConfig = inject(ProviderConfigService)
|
|
55
|
+
*
|
|
56
|
+
* this.providerConfig.setProvider('openai')
|
|
57
|
+
* const current = this.providerConfig.provider() // Signal<string>
|
|
58
|
+
*/
|
|
59
|
+
@Injectable({ providedIn: 'root' })
|
|
60
|
+
export class ProviderConfigService {
|
|
61
|
+
#config = createProviderConfig()
|
|
62
|
+
#provider = signal(this.#config.provider)
|
|
63
|
+
#models = signal(this.#config.models)
|
|
64
|
+
#mode = signal(this.#config.mode)
|
|
65
|
+
|
|
66
|
+
provider = computed(() => this.#provider())
|
|
67
|
+
models = computed(() => this.#models())
|
|
68
|
+
mode = computed(() => this.#mode())
|
|
69
|
+
providers = this.#config.providers
|
|
70
|
+
|
|
71
|
+
constructor() {
|
|
72
|
+
this.#config.subscribe(() => {
|
|
73
|
+
this.#provider.set(this.#config.provider)
|
|
74
|
+
this.#models.set(this.#config.models)
|
|
75
|
+
this.#mode.set(this.#config.mode)
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
setProvider(id) { this.#config.setProvider(id) }
|
|
80
|
+
setModel(pid, mid) { this.#config.setModel(pid, mid) }
|
|
81
|
+
setMode(v) { this.#config.setMode(v) }
|
|
82
|
+
setKey(pid, v) { return this.#config.setKey(pid, v) }
|
|
83
|
+
getKey(pid) { return this.#config.getKey(pid) }
|
|
84
|
+
getModel(pid) { return this.#config.getModel(pid) }
|
|
85
|
+
getLabel(pid) { return this.#config.getLabel(pid) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Standalone provider function. Use in bootstrapApplication() or as a
|
|
90
|
+
* component-level provider when you need scoped completion instances.
|
|
91
|
+
*
|
|
92
|
+
* App-level (shared singleton):
|
|
93
|
+
* bootstrapApplication(AppComponent, {
|
|
94
|
+
* providers: [provideHalohalo()]
|
|
95
|
+
* })
|
|
96
|
+
*
|
|
97
|
+
* Component-level (scoped instance):
|
|
98
|
+
* @Component({ providers: [CompletionService] })
|
|
99
|
+
*/
|
|
100
|
+
export function provideHalohalo() {
|
|
101
|
+
return [ProviderConfigService, CompletionService]
|
|
102
|
+
}
|
package/connectivity.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probes a list of endpoints in parallel to check network reachability.
|
|
3
|
+
*
|
|
4
|
+
* @param {Array<{ label: string, url: string }>} probes
|
|
5
|
+
* @param {number} [timeoutMs=4000]
|
|
6
|
+
* @returns {Promise<Array<{ label: string, ok: boolean }>>}
|
|
7
|
+
*/
|
|
8
|
+
export async function checkConnectivity(probes, timeoutMs = 4000) {
|
|
9
|
+
const results = await Promise.allSettled(
|
|
10
|
+
probes.map(p =>
|
|
11
|
+
fetch(p.url, { method: 'HEAD', mode: 'no-cors', signal: AbortSignal.timeout(timeoutMs) })
|
|
12
|
+
.then(() => ({ label: p.label, ok: true }))
|
|
13
|
+
.catch(() => ({ label: p.label, ok: false }))
|
|
14
|
+
)
|
|
15
|
+
)
|
|
16
|
+
return results.map(r => r.value || r.reason)
|
|
17
|
+
}
|
package/constants.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const LS_AI_PROVIDER = 'ai_provider'
|
|
2
|
+
export const LS_AGENTIC_MODE = 'agentic_mode'
|
|
3
|
+
export const LS_APIKEY_PREFIX = 'apikey_'
|
|
4
|
+
export const LS_AI_MODEL_PREFIX = 'ai_model_'
|
|
5
|
+
|
|
6
|
+
export const AI_MAX_TOKENS = 1024
|
|
7
|
+
export const AI_AGENTIC_MAX_TOKENS = 2048
|
|
8
|
+
export const AGENTIC_MAX_TOOL_TURNS = 5
|
|
9
|
+
|
|
10
|
+
export const ANTHROPIC_API_VERSION = '2023-06-01'
|
|
11
|
+
export const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages'
|
|
12
|
+
|
|
13
|
+
export const AI_DESC_REGEX = /^Description:\s*(.+)/m
|
|
14
|
+
export const AI_FIX_REGEX = /^Suggested Fix:\s*(.+)/ms
|
|
15
|
+
|
|
16
|
+
export const DEBUG_AI_DELAY_MS = 1200
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_AI_MODELS = {
|
|
19
|
+
anthropic: 'claude-sonnet-4-6',
|
|
20
|
+
openai: 'gpt-4o',
|
|
21
|
+
google: 'gemini-1.5-flash',
|
|
22
|
+
microsoft: '',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const PROVIDER_LABELS = {
|
|
26
|
+
anthropic: 'Claude',
|
|
27
|
+
openai: 'GPT',
|
|
28
|
+
google: 'Gemini',
|
|
29
|
+
microsoft: 'Copilot',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const DEBUG_COMMANDS = Object.freeze({
|
|
33
|
+
OK: 'debug ok',
|
|
34
|
+
WRONG: 'debug wrong',
|
|
35
|
+
AUTH: 'debug 401',
|
|
36
|
+
RATE: 'debug 429',
|
|
37
|
+
SERVICE: 'debug 503',
|
|
38
|
+
NETWORK: 'debug network',
|
|
39
|
+
AI_ASSIST_ON: 'debug ai assist on',
|
|
40
|
+
AI_ASSIST_OFF: 'debug ai assist off',
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export const DEBUG_COMMAND_VALUES = Object.freeze(Object.values(DEBUG_COMMANDS))
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { callProvider, callAnthropicWithTools } from './fetch.js'
|
|
2
|
+
import { AiApiError } from './providers.js'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TYPEWRITER = { tickMs: 33, minCharsPerTick: 2, charDivisor: 40 }
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Vanilla completion runner. No React required.
|
|
8
|
+
* Returns { complete(options), cancel() } plus a subscribe() for state changes.
|
|
9
|
+
*
|
|
10
|
+
* State: { loading: boolean, animating: boolean }
|
|
11
|
+
*
|
|
12
|
+
* options for complete():
|
|
13
|
+
* provider, model, key, prompt, maxTokens: for standard completions
|
|
14
|
+
* agentOptions: { system, tools, messages, maxTurns, onToolCall }: for agentic
|
|
15
|
+
* onResult(fields) : called with parsed result after completion
|
|
16
|
+
* onError(error) : called with AiApiError on failure
|
|
17
|
+
* parseResponse(text) : maps raw text to result fields object
|
|
18
|
+
* typewriter : { tickMs, minCharsPerTick, charDivisor } or false
|
|
19
|
+
* onAnimate(field, text) : called each tick with field + current text slice
|
|
20
|
+
* onAnimateDone() : called when typewriter finishes
|
|
21
|
+
*/
|
|
22
|
+
export function createCompletion() {
|
|
23
|
+
let loading = false
|
|
24
|
+
let animating = false
|
|
25
|
+
let timer = null
|
|
26
|
+
const listeners = new Set()
|
|
27
|
+
const notify = () => listeners.forEach(fn => fn({ loading, animating }))
|
|
28
|
+
|
|
29
|
+
function runTypewriter(fields, { typewriter, onAnimate, onAnimateDone }) {
|
|
30
|
+
clearTimeout(timer)
|
|
31
|
+
const { tickMs, minCharsPerTick, charDivisor } = { ...DEFAULT_TYPEWRITER, ...typewriter }
|
|
32
|
+
|
|
33
|
+
const entries = Object.entries(fields).filter(([, v]) => typeof v === 'string' && v.length > 0)
|
|
34
|
+
if (!entries.length) {
|
|
35
|
+
animating = false
|
|
36
|
+
notify()
|
|
37
|
+
onAnimateDone?.()
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const total = entries.reduce((sum, [, v]) => sum + v.length, 0)
|
|
42
|
+
const charsPerTick = Math.max(minCharsPerTick, Math.ceil(total / charDivisor))
|
|
43
|
+
const indices = Object.fromEntries(entries.map(([k]) => [k, 0]))
|
|
44
|
+
let entryIdx = 0
|
|
45
|
+
|
|
46
|
+
function tick() {
|
|
47
|
+
const [field, text] = entries[entryIdx]
|
|
48
|
+
indices[field] = Math.min(indices[field] + charsPerTick, text.length)
|
|
49
|
+
onAnimate?.(field, text.slice(0, indices[field]))
|
|
50
|
+
if (indices[field] >= text.length) entryIdx++
|
|
51
|
+
if (entryIdx >= entries.length) {
|
|
52
|
+
animating = false
|
|
53
|
+
notify()
|
|
54
|
+
onAnimateDone?.()
|
|
55
|
+
} else {
|
|
56
|
+
timer = setTimeout(tick, tickMs)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
timer = setTimeout(tick, tickMs)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
get loading() { return loading },
|
|
65
|
+
get animating() { return animating },
|
|
66
|
+
|
|
67
|
+
subscribe(fn) {
|
|
68
|
+
listeners.add(fn)
|
|
69
|
+
return () => listeners.delete(fn)
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async complete({ provider, model, key, prompt, maxTokens, agentOptions, onResult, onError, parseResponse, typewriter = DEFAULT_TYPEWRITER, onAnimate, onAnimateDone }) {
|
|
73
|
+
loading = true
|
|
74
|
+
notify()
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
let text
|
|
78
|
+
if (agentOptions) {
|
|
79
|
+
const { system, tools, messages, maxTurns, onToolCall } = agentOptions
|
|
80
|
+
text = await callAnthropicWithTools({ key, model, system, tools, messages, maxTokens, maxTurns, onToolCall })
|
|
81
|
+
} else {
|
|
82
|
+
text = await callProvider({ provider, model, key, prompt, maxTokens })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const result = parseResponse ? parseResponse(text) : { text }
|
|
86
|
+
loading = false
|
|
87
|
+
|
|
88
|
+
if (typewriter && onAnimate) {
|
|
89
|
+
animating = true
|
|
90
|
+
notify()
|
|
91
|
+
onResult?.(result)
|
|
92
|
+
runTypewriter(result, { typewriter, onAnimate, onAnimateDone })
|
|
93
|
+
} else {
|
|
94
|
+
notify()
|
|
95
|
+
onResult?.(result)
|
|
96
|
+
onAnimateDone?.()
|
|
97
|
+
}
|
|
98
|
+
} catch (e) {
|
|
99
|
+
loading = false
|
|
100
|
+
animating = false
|
|
101
|
+
notify()
|
|
102
|
+
onError?.(e instanceof AiApiError ? e : new AiApiError('api_error'))
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
cancel() {
|
|
107
|
+
clearTimeout(timer)
|
|
108
|
+
loading = false
|
|
109
|
+
animating = false
|
|
110
|
+
notify()
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { DEFAULT_MODELS, DEFAULT_PROVIDERS, DEFAULT_PROVIDER_LABELS } from './providers.js'
|
|
2
|
+
import { getAdapter } from '@ulam/sawsawan'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Vanilla provider config store. No React required.
|
|
6
|
+
* Returns a plain object with getters, setters, and a subscribe() for change notifications.
|
|
7
|
+
*
|
|
8
|
+
* storageKeys: { provider, modelPrefix, keyPrefix, mode? }
|
|
9
|
+
* providers: optional array of { id, label, defaultModel? }
|
|
10
|
+
*/
|
|
11
|
+
export function createProviderConfig(storageKeys, providers = DEFAULT_PROVIDERS) {
|
|
12
|
+
const { provider: providerKey, modelPrefix, keyPrefix, mode: modeKey } = storageKeys
|
|
13
|
+
|
|
14
|
+
const providerList = providers.map(p =>
|
|
15
|
+
typeof p === 'string' ? { id: p, label: DEFAULT_PROVIDER_LABELS[p] || p } : p
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
let provider = getAdapter().readPref(providerKey) || providerList[0]?.id || 'anthropic'
|
|
19
|
+
|
|
20
|
+
let models = Object.fromEntries(
|
|
21
|
+
providerList.map(p => [
|
|
22
|
+
p.id,
|
|
23
|
+
getAdapter().readPref(`${modelPrefix}${p.id}`) || p.defaultModel || DEFAULT_MODELS[p.id] || '',
|
|
24
|
+
])
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
let mode = modeKey ? getAdapter().readPref(modeKey) === 'true' : false
|
|
28
|
+
|
|
29
|
+
const listeners = new Set()
|
|
30
|
+
const notify = () => listeners.forEach(fn => fn())
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
get provider() { return provider },
|
|
34
|
+
get models() { return { ...models } },
|
|
35
|
+
get mode() { return mode },
|
|
36
|
+
get providers() { return providerList },
|
|
37
|
+
|
|
38
|
+
setProvider(id) {
|
|
39
|
+
getAdapter().writePref(providerKey, id)
|
|
40
|
+
provider = id
|
|
41
|
+
notify()
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
setModel(providerId, modelId) {
|
|
45
|
+
getAdapter().writePref(`${modelPrefix}${providerId}`, modelId)
|
|
46
|
+
models = { ...models, [providerId]: modelId }
|
|
47
|
+
notify()
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
setMode(value) {
|
|
51
|
+
if (!modeKey) return
|
|
52
|
+
getAdapter().writePref(modeKey, value ? 'true' : 'false')
|
|
53
|
+
mode = value
|
|
54
|
+
notify()
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
async setKey(providerId, value) {
|
|
58
|
+
await getAdapter().setKey(`${keyPrefix}${providerId}`, value)
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async getKey(providerId) {
|
|
62
|
+
return (await getAdapter().getKey(`${keyPrefix}${providerId}`)) || ''
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
getModel(providerId) {
|
|
66
|
+
return models[providerId] || DEFAULT_MODELS[providerId] || ''
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
getLabel(providerId) {
|
|
70
|
+
return providerList.find(p => p.id === providerId)?.label
|
|
71
|
+
|| DEFAULT_PROVIDER_LABELS[providerId]
|
|
72
|
+
|| providerId
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
subscribe(fn) {
|
|
76
|
+
listeners.add(fn)
|
|
77
|
+
return () => listeners.delete(fn)
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
package/fetch.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { AiApiError, httpStatusToErrorType, PROVIDER_CONFIGS } from './providers.js'
|
|
2
|
+
|
|
3
|
+
// ─── callProvider ─────────────────────────────────────────────────────────────
|
|
4
|
+
// Single-turn completion against any configured provider.
|
|
5
|
+
// Returns the response text string.
|
|
6
|
+
|
|
7
|
+
export async function callProvider({ provider, model, key, prompt, maxTokens = 1024 }) {
|
|
8
|
+
const config = PROVIDER_CONFIGS[provider]
|
|
9
|
+
if (!config) throw new AiApiError('api_error', { provider })
|
|
10
|
+
|
|
11
|
+
let url
|
|
12
|
+
if (config.buildUrl) {
|
|
13
|
+
url = config.buildUrl(key, model)
|
|
14
|
+
if (!url) throw new AiApiError('api_error', { provider })
|
|
15
|
+
} else {
|
|
16
|
+
url = config.url
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let res
|
|
20
|
+
try {
|
|
21
|
+
res = await fetch(url, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: config.buildHeaders(key),
|
|
24
|
+
body: config.buildBody(prompt, model, maxTokens),
|
|
25
|
+
})
|
|
26
|
+
} catch {
|
|
27
|
+
throw new AiApiError('network_error', { provider })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
throw new AiApiError(httpStatusToErrorType(res.status), { status: res.status, provider })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return config.parseResponse(res)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── callAnthropicWithTools ───────────────────────────────────────────────────
|
|
38
|
+
// Agentic tool-use loop for Anthropic. Calls the messages API repeatedly until
|
|
39
|
+
// stop_reason is 'end_turn' or maxTurns is exhausted.
|
|
40
|
+
//
|
|
41
|
+
// onToolCall(toolName, toolInput) => toolResultContent (string or object)
|
|
42
|
+
// Returns the final text block content.
|
|
43
|
+
|
|
44
|
+
export async function callAnthropicWithTools({
|
|
45
|
+
key,
|
|
46
|
+
model,
|
|
47
|
+
system,
|
|
48
|
+
tools,
|
|
49
|
+
messages,
|
|
50
|
+
maxTokens = 2048,
|
|
51
|
+
maxTurns = 5,
|
|
52
|
+
onToolCall,
|
|
53
|
+
}) {
|
|
54
|
+
const config = PROVIDER_CONFIGS.anthropic
|
|
55
|
+
let turns = 0
|
|
56
|
+
|
|
57
|
+
while (turns <= maxTurns) {
|
|
58
|
+
let res
|
|
59
|
+
try {
|
|
60
|
+
res = await fetch(config.url, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: config.buildHeaders(key),
|
|
63
|
+
body: JSON.stringify({ model, max_tokens: maxTokens, system, tools, messages }),
|
|
64
|
+
})
|
|
65
|
+
} catch {
|
|
66
|
+
throw new AiApiError('network_error', { provider: 'anthropic' })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
throw new AiApiError(httpStatusToErrorType(res.status), { status: res.status, provider: 'anthropic' })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const data = await res.json()
|
|
74
|
+
|
|
75
|
+
if (import.meta.env.DEV) console.log(`[halohalo] turn ${turns + 1}, stop_reason: ${data.stop_reason}`)
|
|
76
|
+
|
|
77
|
+
messages.push({ role: 'assistant', content: data.content })
|
|
78
|
+
|
|
79
|
+
if (data.stop_reason === 'end_turn') {
|
|
80
|
+
return data.content.find(b => b.type === 'text')?.text || ''
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (data.stop_reason === 'tool_use') {
|
|
84
|
+
if (turns >= maxTurns) {
|
|
85
|
+
if (import.meta.env.DEV) console.warn('[halohalo] maxTurns reached')
|
|
86
|
+
throw new AiApiError('api_error', { provider: 'anthropic' })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const toolBlocks = data.content.filter(b => b.type === 'tool_use')
|
|
90
|
+
const toolResults = await Promise.all(
|
|
91
|
+
toolBlocks.map(async (block) => {
|
|
92
|
+
const result = await onToolCall?.(block.name, block.input) ?? ''
|
|
93
|
+
if (import.meta.env.DEV) {
|
|
94
|
+
console.log(`[halohalo] tool_use: ${block.name}`, block.input, '→', result)
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
type: 'tool_result',
|
|
98
|
+
tool_use_id: block.id,
|
|
99
|
+
content: typeof result === 'string' ? result : JSON.stringify(result),
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
messages.push({ role: 'user', content: toolResults })
|
|
105
|
+
turns++
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
throw new AiApiError('api_error', { provider: 'anthropic' })
|
|
113
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// vanilla core
|
|
2
|
+
export { initHalohalo } from './init.js'
|
|
3
|
+
export { AiApiError, httpStatusToErrorType, PROVIDER_CONFIGS } from './providers.js'
|
|
4
|
+
export { callProvider, callAnthropicWithTools } from './fetch.js'
|
|
5
|
+
export { searchItems, makeSearchTool } from './search.js'
|
|
6
|
+
export { createProviderConfig } from './createProviderConfig.js'
|
|
7
|
+
export { createCompletion } from './createCompletion.js'
|
|
8
|
+
export { getAiRefinement, parseAiResponse } from './aiService.js'
|
|
9
|
+
export { getAgenticRefinement } from './agenticAiService.js'
|
|
10
|
+
export { PROVIDERS, PROVIDER_MODELS, initModels, initApiKeys } from './models.js'
|
|
11
|
+
export { getAiProvider, isAgenticModeEnabled, getAiModel, getProviderLabel } from './prefs.js'
|
|
12
|
+
export { checkConnectivity } from './connectivity.js'
|
|
13
|
+
export {
|
|
14
|
+
LS_AI_PROVIDER, LS_AGENTIC_MODE, LS_APIKEY_PREFIX, LS_AI_MODEL_PREFIX,
|
|
15
|
+
AI_MAX_TOKENS, AI_AGENTIC_MAX_TOKENS, AGENTIC_MAX_TOOL_TURNS,
|
|
16
|
+
AI_DESC_REGEX, AI_FIX_REGEX,
|
|
17
|
+
DEBUG_AI_DELAY_MS, DEFAULT_AI_MODELS, PROVIDER_LABELS,
|
|
18
|
+
DEBUG_COMMANDS, DEBUG_COMMAND_VALUES,
|
|
19
|
+
ANTHROPIC_API_VERSION, ANTHROPIC_API_URL,
|
|
20
|
+
} from './constants.js'
|
package/init.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// ─── halohalo module singleton ────────────────────────────────────────────────
|
|
2
|
+
// Holds app-injected config. Call initHalohalo() before using aiService or
|
|
3
|
+
// agenticAiService.
|
|
4
|
+
|
|
5
|
+
let _buildPrompt = null
|
|
6
|
+
let _systemPrompt = null
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Register app-specific AI config. Call once at app init.
|
|
10
|
+
*
|
|
11
|
+
* @param {{
|
|
12
|
+
* buildPrompt: (params: object) => string,
|
|
13
|
+
* systemPrompt?: string,
|
|
14
|
+
* }} config
|
|
15
|
+
*
|
|
16
|
+
* buildPrompt receives { finding, descText, fixText, note } and must return
|
|
17
|
+
* the user-turn prompt string for single-turn refinement.
|
|
18
|
+
*
|
|
19
|
+
* systemPrompt is used for agentic (tool-use) mode. If omitted, agentic mode
|
|
20
|
+
* falls back to a generic instruction.
|
|
21
|
+
*/
|
|
22
|
+
export function initHalohalo({ buildPrompt, systemPrompt = null }) {
|
|
23
|
+
_buildPrompt = buildPrompt
|
|
24
|
+
_systemPrompt = systemPrompt
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getBuildPrompt() {
|
|
28
|
+
return _buildPrompt
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getSystemPrompt() {
|
|
32
|
+
return _systemPrompt
|
|
33
|
+
}
|
package/models.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getAdapter } from '@ulam/sawsawan'
|
|
2
|
+
import { LS_AI_MODEL_PREFIX, LS_APIKEY_PREFIX, DEFAULT_AI_MODELS } from './constants.js'
|
|
3
|
+
|
|
4
|
+
export const PROVIDERS = [
|
|
5
|
+
{ id: 'anthropic', label: 'Anthropic (Claude)', placeholderKey: 'settings.api_placeholder_anthropic' },
|
|
6
|
+
{ id: 'openai', label: 'OpenAI (GPT)', placeholderKey: 'settings.api_placeholder_openai' },
|
|
7
|
+
{ id: 'google', label: 'Google (Gemini)', placeholderKey: 'settings.api_placeholder_google' },
|
|
8
|
+
{ id: 'microsoft', label: 'Microsoft (Copilot)', placeholderKey: 'settings.api_placeholder_default' },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
export const PROVIDER_MODELS = {
|
|
12
|
+
anthropic: [
|
|
13
|
+
{ id: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5, fast, low cost' },
|
|
14
|
+
{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6, balanced (default)' },
|
|
15
|
+
{ id: 'claude-opus-4-7', label: 'Claude Opus 4.7, most capable' },
|
|
16
|
+
],
|
|
17
|
+
openai: [
|
|
18
|
+
{ id: 'gpt-4o-mini', label: 'GPT-4o Mini, fast, low cost' },
|
|
19
|
+
{ id: 'gpt-4o', label: 'GPT-4o, balanced (default)' },
|
|
20
|
+
],
|
|
21
|
+
google: [
|
|
22
|
+
{ id: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash, fast (default)' },
|
|
23
|
+
{ id: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro, most capable' },
|
|
24
|
+
],
|
|
25
|
+
microsoft: [],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function initModels() {
|
|
29
|
+
return Object.fromEntries(
|
|
30
|
+
PROVIDERS.map(p => [p.id, getAdapter().readPref(`${LS_AI_MODEL_PREFIX}${p.id}`) || DEFAULT_AI_MODELS[p.id] || ''])
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function initApiKeys() {
|
|
35
|
+
return Object.fromEntries(
|
|
36
|
+
PROVIDERS.map(p => [p.id, window.electronAPI ? '' : getAdapter().readPref(`${LS_APIKEY_PREFIX}${p.id}`) || ''])
|
|
37
|
+
)
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ulam/halohalo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI service adapters, model configuration, and provider abstraction.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/mikeyil/ulam.git",
|
|
10
|
+
"directory": "packages/halohalo"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["ai", "llm", "anthropic", "openai", "react", "vue", "angular", "hooks", "ulam"],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./index.js",
|
|
15
|
+
"./react": "./react.js",
|
|
16
|
+
"./vue": "./vue.js",
|
|
17
|
+
"./angular": "./angular.js"
|
|
18
|
+
},
|
|
19
|
+
"files": ["*.js", "README.md"],
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"react": ">=18",
|
|
22
|
+
"vue": ">=3",
|
|
23
|
+
"@angular/core": ">=17",
|
|
24
|
+
"fuse.js": ">=7",
|
|
25
|
+
"@ulam/sawsawan": "*"
|
|
26
|
+
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"react": { "optional": true },
|
|
29
|
+
"vue": { "optional": true },
|
|
30
|
+
"@angular/core": { "optional": true },
|
|
31
|
+
"fuse.js": { "optional": true },
|
|
32
|
+
"@ulam/sawsawan": { "optional": true }
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/prefs.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { LS_AI_PROVIDER, LS_AI_MODEL_PREFIX, LS_AGENTIC_MODE, DEFAULT_AI_MODELS, PROVIDER_LABELS } from './constants.js'
|
|
2
|
+
|
|
3
|
+
export function getAiProvider() {
|
|
4
|
+
return localStorage.getItem(LS_AI_PROVIDER) || 'anthropic'
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isAgenticModeEnabled() {
|
|
8
|
+
return localStorage.getItem(LS_AGENTIC_MODE) === 'true'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getAiModel(provider) {
|
|
12
|
+
return localStorage.getItem(`${LS_AI_MODEL_PREFIX}${provider}`) || DEFAULT_AI_MODELS[provider] || ''
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getProviderLabel(provider) {
|
|
16
|
+
return PROVIDER_LABELS[provider] || provider
|
|
17
|
+
}
|
package/providers.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// ─── Error types ─────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export class AiApiError extends Error {
|
|
4
|
+
/** @param {'invalid_key'|'rate_limit'|'service_error'|'network_error'|'api_error'} type */
|
|
5
|
+
constructor(type, { status, provider } = {}) {
|
|
6
|
+
super(`AiApiError: ${type}`)
|
|
7
|
+
this.type = type
|
|
8
|
+
this.status = status
|
|
9
|
+
this.provider = provider
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function httpStatusToErrorType(status) {
|
|
14
|
+
if (status === 401 || status === 403) return 'invalid_key'
|
|
15
|
+
if (status === 429) return 'rate_limit'
|
|
16
|
+
if (status >= 500) return 'service_error'
|
|
17
|
+
return 'api_error'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── Provider configs ─────────────────────────────────────────────────────────
|
|
21
|
+
// Each entry describes how to build headers, body, and parse responses for a
|
|
22
|
+
// given AI provider. getModel is injected at call time so the config itself
|
|
23
|
+
// carries no storage dependency.
|
|
24
|
+
|
|
25
|
+
export const PROVIDER_CONFIGS = {
|
|
26
|
+
anthropic: {
|
|
27
|
+
url: 'https://api.anthropic.com/v1/messages',
|
|
28
|
+
buildHeaders: (key) => ({
|
|
29
|
+
'Content-Type': 'application/json',
|
|
30
|
+
'x-api-key': key,
|
|
31
|
+
'anthropic-version': '2023-06-01',
|
|
32
|
+
'anthropic-dangerous-direct-browser-access': 'true',
|
|
33
|
+
}),
|
|
34
|
+
buildBody: (prompt, model, maxTokens) => JSON.stringify({
|
|
35
|
+
model,
|
|
36
|
+
max_tokens: maxTokens,
|
|
37
|
+
messages: [{ role: 'user', content: prompt }],
|
|
38
|
+
}),
|
|
39
|
+
parseResponse: async (res) => {
|
|
40
|
+
const data = await res.json()
|
|
41
|
+
return data.content?.[0]?.text || ''
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
openai: {
|
|
46
|
+
url: 'https://api.openai.com/v1/chat/completions',
|
|
47
|
+
buildHeaders: (key) => ({
|
|
48
|
+
'Content-Type': 'application/json',
|
|
49
|
+
'Authorization': `Bearer ${key}`,
|
|
50
|
+
}),
|
|
51
|
+
buildBody: (prompt, model, maxTokens) => JSON.stringify({
|
|
52
|
+
model,
|
|
53
|
+
max_tokens: maxTokens,
|
|
54
|
+
messages: [{ role: 'user', content: prompt }],
|
|
55
|
+
}),
|
|
56
|
+
parseResponse: async (res) => {
|
|
57
|
+
const data = await res.json()
|
|
58
|
+
return data.choices?.[0]?.message?.content || ''
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
google: {
|
|
63
|
+
buildUrl: (key, model) =>
|
|
64
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`,
|
|
65
|
+
buildHeaders: () => ({ 'Content-Type': 'application/json' }),
|
|
66
|
+
buildBody: (prompt, _model, maxTokens) => JSON.stringify({
|
|
67
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
68
|
+
generationConfig: { maxOutputTokens: maxTokens },
|
|
69
|
+
}),
|
|
70
|
+
parseResponse: async (res) => {
|
|
71
|
+
const data = await res.json()
|
|
72
|
+
return data.candidates?.[0]?.content?.parts?.[0]?.text || ''
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
microsoft: {
|
|
77
|
+
// Requires VITE_AZURE_OPENAI_ENDPOINT set to your Azure deployment URL.
|
|
78
|
+
buildUrl: () => import.meta.env.VITE_AZURE_OPENAI_ENDPOINT || null,
|
|
79
|
+
buildHeaders: (key) => ({
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
'api-key': key,
|
|
82
|
+
}),
|
|
83
|
+
buildBody: (prompt, _model, maxTokens) => JSON.stringify({
|
|
84
|
+
max_tokens: maxTokens,
|
|
85
|
+
messages: [{ role: 'user', content: prompt }],
|
|
86
|
+
}),
|
|
87
|
+
parseResponse: async (res) => {
|
|
88
|
+
const data = await res.json()
|
|
89
|
+
return data.choices?.[0]?.message?.content || ''
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const DEFAULT_PROVIDERS = ['anthropic', 'openai', 'google', 'microsoft']
|
|
95
|
+
|
|
96
|
+
export const DEFAULT_MODELS = {
|
|
97
|
+
anthropic: 'claude-sonnet-4-6',
|
|
98
|
+
openai: 'gpt-4o',
|
|
99
|
+
google: 'gemini-1.5-flash',
|
|
100
|
+
microsoft: '',
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const DEFAULT_PROVIDER_LABELS = {
|
|
104
|
+
anthropic: 'Claude',
|
|
105
|
+
openai: 'GPT',
|
|
106
|
+
google: 'Gemini',
|
|
107
|
+
microsoft: 'Copilot',
|
|
108
|
+
}
|
package/react.js
ADDED
package/search.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import Fuse from 'fuse.js'
|
|
2
|
+
|
|
3
|
+
// ─── searchItems ──────────────────────────────────────────────────────────────
|
|
4
|
+
// Generic Fuse.js search over any corpus array.
|
|
5
|
+
// fields : Fuse key/weight config: [{ name, weight }, ...]
|
|
6
|
+
// Returns simplified result objects containing only the requested fields.
|
|
7
|
+
|
|
8
|
+
export function searchItems(query, corpus, { fields, threshold = 0.4, minMatchCharLength = 2, limit = 3, pick } = {}) {
|
|
9
|
+
if (!query?.trim() || !Array.isArray(corpus) || corpus.length === 0) return []
|
|
10
|
+
|
|
11
|
+
const fuse = new Fuse(corpus, {
|
|
12
|
+
keys: fields,
|
|
13
|
+
threshold,
|
|
14
|
+
minMatchCharLength,
|
|
15
|
+
includeScore: true,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const results = fuse.search(query.trim()).slice(0, limit).map(r => r.item)
|
|
19
|
+
|
|
20
|
+
if (pick) return results.map(item => Object.fromEntries(pick.map(k => [k, item[k]])))
|
|
21
|
+
return results
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── makeSearchTool ───────────────────────────────────────────────────────────
|
|
25
|
+
// Builds an Anthropic tool-use schema + handler pair for a corpus search tool.
|
|
26
|
+
// The returned { schema, handler } can be passed directly to callAnthropicWithTools.
|
|
27
|
+
//
|
|
28
|
+
// options.name : tool name (default: 'search_corpus')
|
|
29
|
+
// options.description : tool description string
|
|
30
|
+
// options.fields : Fuse key/weight config
|
|
31
|
+
// options.pick : fields to include in returned results
|
|
32
|
+
// options.limit : max results (default: 3)
|
|
33
|
+
|
|
34
|
+
export function makeSearchTool(corpus, options = {}) {
|
|
35
|
+
const {
|
|
36
|
+
name = 'search_corpus',
|
|
37
|
+
description = 'Search the corpus for entries related to a natural-language query.',
|
|
38
|
+
queryDescription = 'Natural-language search query.',
|
|
39
|
+
fields,
|
|
40
|
+
pick,
|
|
41
|
+
limit = 3,
|
|
42
|
+
threshold,
|
|
43
|
+
minMatchCharLength,
|
|
44
|
+
} = options
|
|
45
|
+
|
|
46
|
+
const schema = {
|
|
47
|
+
name,
|
|
48
|
+
description,
|
|
49
|
+
input_schema: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {
|
|
52
|
+
query: { type: 'string', description: queryDescription },
|
|
53
|
+
},
|
|
54
|
+
required: ['query'],
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function handler(_toolName, input) {
|
|
59
|
+
return searchItems(input?.query ?? '', corpus, { fields, pick, limit, threshold, minMatchCharLength })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { schema, handler }
|
|
63
|
+
}
|
package/useCompletion.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { createCompletion } from './createCompletion.js'
|
|
3
|
+
|
|
4
|
+
export function useCompletion(options = {}) {
|
|
5
|
+
const [instance] = useState(createCompletion)
|
|
6
|
+
const [state, setState] = useState({ loading: false, animating: false })
|
|
7
|
+
|
|
8
|
+
useEffect(() => instance.subscribe(setState), [instance])
|
|
9
|
+
useEffect(() => () => instance.cancel(), [instance])
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
loading: state.loading,
|
|
13
|
+
animating: state.animating,
|
|
14
|
+
complete: (callOptions) => instance.complete({ ...options, ...callOptions }),
|
|
15
|
+
cancel: () => instance.cancel(),
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect } from 'react'
|
|
2
|
+
import { createProviderConfig } from './createProviderConfig.js'
|
|
3
|
+
|
|
4
|
+
export function useProviderConfig(storageKeys, providers) {
|
|
5
|
+
const [config] = useState(() => createProviderConfig(storageKeys, providers))
|
|
6
|
+
const [, rerender] = useState(0)
|
|
7
|
+
useEffect(() => config.subscribe(() => rerender(n => n + 1)), [config])
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
provider: config.provider,
|
|
11
|
+
models: config.models,
|
|
12
|
+
mode: config.mode,
|
|
13
|
+
providers: config.providers,
|
|
14
|
+
setProvider: useCallback((id) => config.setProvider(id), [config]),
|
|
15
|
+
setModel: useCallback((pid, mid) => config.setModel(pid, mid), [config]),
|
|
16
|
+
setMode: useCallback((v) => config.setMode(v), [config]),
|
|
17
|
+
setKey: useCallback((pid, v) => config.setKey(pid, v), [config]),
|
|
18
|
+
getKey: useCallback((pid) => config.getKey(pid), [config]),
|
|
19
|
+
getModel: useCallback((pid) => config.getModel(pid), [config]),
|
|
20
|
+
getLabel: useCallback((pid) => config.getLabel(pid), [config]),
|
|
21
|
+
}
|
|
22
|
+
}
|
package/vue.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ulam/halohalo/vue: Vue composables adapter
|
|
3
|
+
*/
|
|
4
|
+
import { ref, readonly } from 'vue'
|
|
5
|
+
import { createCompletion } from './createCompletion.js'
|
|
6
|
+
import { createProviderConfig } from './createProviderConfig.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reactive wrapper around createCompletion(). Returns reactive loading and
|
|
10
|
+
* animating state, plus complete() and cancel() functions.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} [defaultOptions] - default options merged into every complete() call
|
|
13
|
+
*/
|
|
14
|
+
export function useCompletion(defaultOptions = {}) {
|
|
15
|
+
const instance = createCompletion()
|
|
16
|
+
const loading = ref(false)
|
|
17
|
+
const animating = ref(false)
|
|
18
|
+
|
|
19
|
+
const unsubscribe = instance.subscribe(({ loading: l, animating: a }) => {
|
|
20
|
+
loading.value = l
|
|
21
|
+
animating.value = a
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// caller should invoke cleanup() in onUnmounted if used inside a component
|
|
25
|
+
function cleanup() {
|
|
26
|
+
instance.cancel()
|
|
27
|
+
unsubscribe()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
loading: readonly(loading),
|
|
32
|
+
animating: readonly(animating),
|
|
33
|
+
complete: (callOptions) => instance.complete({ ...defaultOptions, ...callOptions }),
|
|
34
|
+
cancel: () => instance.cancel(),
|
|
35
|
+
cleanup,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reactive wrapper around createProviderConfig(). All config fields are reactive
|
|
41
|
+
* refs; setters update the underlying singleton and trigger re-reads.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} [storageKeys]
|
|
44
|
+
* @param {Array} [providers]
|
|
45
|
+
*/
|
|
46
|
+
export function useProviderConfig(storageKeys, providers) {
|
|
47
|
+
const config = createProviderConfig(storageKeys, providers)
|
|
48
|
+
|
|
49
|
+
const provider = ref(config.provider)
|
|
50
|
+
const models = ref(config.models)
|
|
51
|
+
const mode = ref(config.mode)
|
|
52
|
+
|
|
53
|
+
const unsubscribe = config.subscribe(() => {
|
|
54
|
+
provider.value = config.provider
|
|
55
|
+
models.value = config.models
|
|
56
|
+
mode.value = config.mode
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
provider: readonly(provider),
|
|
61
|
+
models: readonly(models),
|
|
62
|
+
mode: readonly(mode),
|
|
63
|
+
providers: config.providers,
|
|
64
|
+
setProvider: (id) => config.setProvider(id),
|
|
65
|
+
setModel: (pid, mid) => config.setModel(pid, mid),
|
|
66
|
+
setMode: (v) => config.setMode(v),
|
|
67
|
+
setKey: (pid, v) => config.setKey(pid, v),
|
|
68
|
+
getKey: (pid) => config.getKey(pid),
|
|
69
|
+
getModel: (pid) => config.getModel(pid),
|
|
70
|
+
getLabel: (pid) => config.getLabel(pid),
|
|
71
|
+
cleanup: unsubscribe,
|
|
72
|
+
}
|
|
73
|
+
}
|