@ulam/halohalo 0.1.0 → 0.2.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/agenticAiService.js +76 -76
- package/aiService.js +31 -31
- package/connectivity.js +17 -17
- package/createCompletion.js +113 -113
- package/createProviderConfig.js +80 -80
- package/fetch.js +113 -113
- package/index.js +20 -20
- package/init.js +33 -33
- package/models.js +38 -38
- package/package.json +31 -8
- package/react.js +2 -2
- package/useCompletion.js +17 -17
- package/useProviderConfig.js +22 -22
package/agenticAiService.js
CHANGED
|
@@ -1,76 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,31 +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
|
-
}
|
|
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/connectivity.js
CHANGED
|
@@ -1,17 +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
|
-
}
|
|
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/createCompletion.js
CHANGED
|
@@ -1,113 +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
|
-
}
|
|
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
|
+
}
|
package/createProviderConfig.js
CHANGED
|
@@ -1,80 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,113 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,20 +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'
|
|
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
CHANGED
|
@@ -1,33 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,38 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ulam/halohalo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "AI service adapters, model configuration, and provider abstraction.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,14 +9,27 @@
|
|
|
9
9
|
"url": "https://github.com/mikeyil/ulam.git",
|
|
10
10
|
"directory": "packages/halohalo"
|
|
11
11
|
},
|
|
12
|
-
"keywords": [
|
|
12
|
+
"keywords": [
|
|
13
|
+
"ai",
|
|
14
|
+
"llm",
|
|
15
|
+
"anthropic",
|
|
16
|
+
"openai",
|
|
17
|
+
"react",
|
|
18
|
+
"vue",
|
|
19
|
+
"angular",
|
|
20
|
+
"hooks",
|
|
21
|
+
"ulam"
|
|
22
|
+
],
|
|
13
23
|
"exports": {
|
|
14
24
|
".": "./index.js",
|
|
15
25
|
"./react": "./react.js",
|
|
16
26
|
"./vue": "./vue.js",
|
|
17
27
|
"./angular": "./angular.js"
|
|
18
28
|
},
|
|
19
|
-
"files": [
|
|
29
|
+
"files": [
|
|
30
|
+
"*.js",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
20
33
|
"peerDependencies": {
|
|
21
34
|
"react": ">=18",
|
|
22
35
|
"vue": ">=3",
|
|
@@ -25,11 +38,21 @@
|
|
|
25
38
|
"@ulam/sawsawan": "*"
|
|
26
39
|
},
|
|
27
40
|
"peerDependenciesMeta": {
|
|
28
|
-
"react": {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
|
|
41
|
+
"react": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"vue": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"@angular/core": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"fuse.js": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"@ulam/sawsawan": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
33
56
|
},
|
|
34
57
|
"publishConfig": {
|
|
35
58
|
"access": "public"
|
package/react.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { useProviderConfig } from './useProviderConfig.js'
|
|
2
|
-
export { useCompletion } from './useCompletion.js'
|
|
1
|
+
export { useProviderConfig } from './useProviderConfig.js'
|
|
2
|
+
export { useCompletion } from './useCompletion.js'
|
package/useCompletion.js
CHANGED
|
@@ -1,17 +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
|
-
}
|
|
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
|
+
}
|
package/useProviderConfig.js
CHANGED
|
@@ -1,22 +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
|
-
}
|
|
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
|
+
}
|