feat: Claude client + Voyage AI embed helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr
This commit is contained in:
2026-07-11 16:10:26 +08:00
co-authored by Claude Sonnet 4.6
parent 0e53d5c771
commit ebaa98d9f4
6 changed files with 101 additions and 2 deletions
+2
View File
@@ -3,3 +3,5 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000
CRON_SECRET=<generate with: openssl rand -hex 32> CRON_SECRET=<generate with: openssl rand -hex 32>
ANTHROPIC_API_KEY=sk-ant-...
VOYAGE_API_KEY=pa-...
+5
View File
@@ -0,0 +1,5 @@
import Anthropic from '@anthropic-ai/sdk'
export const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY ?? '',
})
+14
View File
@@ -0,0 +1,14 @@
export async function embedText(text: string): Promise<number[]> {
if (!process.env.VOYAGE_API_KEY) throw new Error('VOYAGE_API_KEY is not set')
const res = await fetch('https://api.voyageai.com/v1/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.VOYAGE_API_KEY}`,
},
body: JSON.stringify({ input: [text], model: 'voyage-3-lite' }),
})
if (!res.ok) throw new Error(`Voyage embed failed: ${res.status}`)
const json = await res.json() as { data: Array<{ embedding: number[] }> }
return json.data[0].embedding
}
+42 -2
View File
@@ -8,6 +8,7 @@
"name": "ims-app", "name": "ims-app",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.111.0",
"@supabase/ssr": "^0.12.0", "@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.110.2", "@supabase/supabase-js": "^2.110.2",
"next": "^15.5.20", "next": "^15.5.20",
@@ -55,6 +56,27 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@anthropic-ai/sdk": {
"version": "0.111.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.111.0.tgz",
"integrity": "sha512-1hUqKi+uJQoS5X90+InwHbFAXMvgq0DnsC5hVLEeSRaODiU5WvmqDAcVCmGS2wC0pN9Z8jtWCbWw7JLzeDdm/Q==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@asamuzakjp/css-color": { "node_modules/@asamuzakjp/css-color": {
"version": "5.1.11", "version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
@@ -302,7 +324,6 @@
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -6451,6 +6472,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -8592,6 +8626,12 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -9437,7 +9477,7 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
+1
View File
@@ -12,6 +12,7 @@
"generate-qr": "tsx scripts/generate-qr.ts" "generate-qr": "tsx scripts/generate-qr.ts"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.111.0",
"@supabase/ssr": "^0.12.0", "@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.110.2", "@supabase/supabase-js": "^2.110.2",
"next": "^15.5.20", "next": "^15.5.20",
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
describe('embedText', () => {
beforeEach(() => {
process.env.VOYAGE_API_KEY = 'test-key'
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns 1024-element embedding array', async () => {
const mockEmbedding = Array.from({ length: 1024 }, (_, i) => i / 1024)
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [{ embedding: mockEmbedding }] }),
}))
const { embedText } = await import('@/lib/claude/embed')
const result = await embedText('forklift hit racking in zone B')
expect(result).toHaveLength(1024)
expect(result[0]).toBeCloseTo(0)
expect(result[1023]).toBeCloseTo(1023 / 1024)
})
it('throws on non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401 }))
const { embedText } = await import('@/lib/claude/embed')
await expect(embedText('test')).rejects.toThrow('Voyage embed failed: 401')
})
it('throws when VOYAGE_API_KEY is missing', async () => {
delete process.env.VOYAGE_API_KEY
vi.resetModules()
const { embedText } = await import('@/lib/claude/embed')
await expect(embedText('test')).rejects.toThrow('VOYAGE_API_KEY')
})
})