diff --git a/packages/docs/package.json b/packages/docs/package.json index 859a5b13a..c15cae6b5 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -29,7 +29,7 @@ "asciinema-player": "^3.4.0", "clsx": "^1.2.1", "dotenv": "^16.3.1", - "fixie": "*", + "fixie-web": "^1.0.7", "mixpanel-browser": "^2.47.0", "prism-react-renderer": "^1.3.5", "react": "^17.0.2", diff --git a/packages/docs/src/components/Fixie/index.js b/packages/docs/src/components/Fixie/index.js index 18bd647db..0f58f9190 100644 --- a/packages/docs/src/components/Fixie/index.js +++ b/packages/docs/src/components/Fixie/index.js @@ -1,6 +1,6 @@ import React from 'react'; import BrowserOnly from '@docusaurus/BrowserOnly'; -import { FloatingFixieEmbed } from 'fixie/web'; +import { FloatingFixieEmbed } from 'fixie-web'; const FixieSidekick = () => { return ( diff --git a/packages/fixie/.eslintignore b/packages/fixie/.eslintignore deleted file mode 100644 index a1ca35117..000000000 --- a/packages/fixie/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -dist -*.d.ts -*.js \ No newline at end of file diff --git a/packages/fixie/.eslintrc.cjs b/packages/fixie/.eslintrc.cjs deleted file mode 100644 index 0be824047..000000000 --- a/packages/fixie/.eslintrc.cjs +++ /dev/null @@ -1,59 +0,0 @@ -const path = require('path'); - -module.exports = { - extends: ['eslint:recommended', 'plugin:@typescript-eslint/strict', 'nth'], - parser: '@typescript-eslint/parser', - parserOptions: { - project: [ - path.join(__dirname, 'tsconfig.json'), - path.join(__dirname, 'scripts', 'tsconfig.json'), - path.join(__dirname, 'test', 'tsconfig.json'), - ], - }, - plugins: ['@typescript-eslint'], - root: true, - - env: { - node: true, - es6: true, - }, - - rules: { - // Disable eslint rules to let their TS equivalents take over. - 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': ['warn', { ignoreRestSiblings: true, argsIgnorePattern: '^_' }], - 'no-undef': 'off', - 'no-magic-numbers': 'off', - '@typescript-eslint/no-magic-numbers': 'off', - - // There are too many third-party libs that use camelcase. - camelcase: ['off'], - - 'no-use-before-define': 'off', - '@typescript-eslint/no-use-before-define': ['error', { functions: false, variables: true }], - - 'no-trailing-spaces': 'warn', - 'no-else-return': ['warn', { allowElseIf: false }], - 'no-constant-condition': ['error', { checkLoops: false }], - - // Disable style rules to let prettier own it - 'object-curly-spacing': 'off', - 'comma-dangle': 'off', - 'max-len': 'off', - indent: 'off', - 'no-mixed-operators': 'off', - 'no-console': 'off', - 'arrow-parens': 'off', - 'generator-star-spacing': 'off', - 'space-before-function-paren': 'off', - 'jsx-quotes': 'off', - 'brace-style': 'off', - - // Add additional strictness beyond the recommended set - '@typescript-eslint/parameter-properties': ['warn', { prefer: 'parameter-properties' }], - '@typescript-eslint/prefer-readonly': 'warn', - '@typescript-eslint/switch-exhaustiveness-check': 'warn', - '@typescript-eslint/no-base-to-string': 'error', - '@typescript-eslint/no-unnecessary-condition': ['warn', { allowConstantLoopConditions: true }], - }, -}; diff --git a/packages/fixie/.gitignore b/packages/fixie/.gitignore deleted file mode 100644 index f84e22067..000000000 --- a/packages/fixie/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -*.js -*.d.ts diff --git a/packages/fixie/.npmignore b/packages/fixie/.npmignore deleted file mode 100644 index 22e4852ac..000000000 --- a/packages/fixie/.npmignore +++ /dev/null @@ -1,2 +0,0 @@ -# N.B. Because this file exists, npm will not honor the local .gitignore. -.* diff --git a/packages/fixie/index.ts b/packages/fixie/index.ts deleted file mode 100644 index 79832fdfb..000000000 --- a/packages/fixie/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { FixieClient } from './src/client.js'; -export * from './src/types.js'; diff --git a/packages/fixie/package.json b/packages/fixie/package.json deleted file mode 100644 index ecfee62a0..000000000 --- a/packages/fixie/package.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "name": "fixie", - "version": "6.0.1", - "license": "MIT", - "repository": "fixie-ai/ai-jsx", - "bugs": "https://github.com/fixie-ai/ai-jsx/issues", - "homepage": "https://fixie.ai", - "type": "module", - "scripts": { - "build": "tsc", - "start": "node --no-warnings src/main.js", - "build-start": "yarn run build && yarn run start", - "format": "prettier --write .", - "test": "yarn run build && yarn run lint", - "lint": "eslint .", - "lint:fix": "eslint .", - "prepack": "yarn build" - }, - "volta": { - "extends": "../../package.json" - }, - "bin": "./src/main.js", - "main": "./index.js", - "types": "./index.d.ts", - "dependencies": { - "@apollo/client": "^3.8.1", - "@types/apollo-upload-client": "^17.0.2", - "apollo-upload-client": "^17.0.0", - "axios": "^1.5.1", - "base64-arraybuffer": "^1.0.2", - "commander": "^11.0.0", - "execa": "^8.0.1", - "extract-files": "^13.0.0", - "graphql": "^16.8.0", - "js-yaml": "^4.1.0", - "open": "^9.1.0", - "ora": "^7.0.1", - "terminal-kit": "^3.0.0", - "type-fest": "^4.3.1", - "typescript-json-schema": "^0.61.0", - "untildify": "^5.0.0", - "watcher": "^2.3.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react-dom": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - }, - "devDependencies": { - "@fixieai/sdk": "*", - "@tsconfig/node18": "^2.0.1", - "@types/extract-files": "^8.1.1", - "@types/js-yaml": "^4.0.5", - "@types/node": "^20.4.1", - "@types/react": "^18.2.22", - "@types/react-dom": "^18.2.7", - "@types/terminal-kit": "^2.5.1", - "@typescript-eslint/eslint-plugin": "^5.60.0", - "@typescript-eslint/parser": "^5.60.0", - "eslint": "^8.40.0", - "eslint-config-nth": "^2.0.1", - "prettier": "^3.0.0", - "typescript": "5.1.3" - }, - "publishConfig": { - "access": "public" - }, - "engines": { - "node": ">=18.0.0" - } -} diff --git a/packages/fixie/readme.md b/packages/fixie/readme.md deleted file mode 100644 index bfa76a1c6..000000000 --- a/packages/fixie/readme.md +++ /dev/null @@ -1,79 +0,0 @@ -# Fixie Platform SDK & CLI - -This package contains an SDK and command-line interface to the [Fixie.ai](https://fixie.ai) platform. - -## CLI - -The /src folder has a file called `main.ts` which is the CLI. - -### To test new features added to the CLI - -- From the root, run `yarn workspace fixie build-start ` where is the command you want to test. - -## Web APIs - -This package contains a number of ways for you to integrate a Fixie agent into your web app, depending on what level of opinionation / flexibility you prefer: - -- Embed an iframe to the generic hosted Fixie UI: - - [``](#floatingfixieembed) - - [``](#controlledfloatingfixieembed) - - [``](#inlinefixieembed) -- Bring Your Own Frontend: - - [`useFixie`](#usefixie) - - [`FixieClient`](#fixieclient) - -### Embed - -Fixie provides a generic hosted UI. You can embed it in your UI, similar to how you might embed an [Intercom](https://www.intercom.com/) widget. - -#### `` - -```ts -import { FloatingFixieEmbed } from 'fixie/web'; -``` - -This React component will place a Fixie chat window floating above your content. It will also create a launcher button. The user can click the button to open and close the Fixie chat window. - -#### `` - -```ts -import { ControlledFloatingFixieEmbed } from 'fixie/web'; -``` - -This React component will place a Fixie chat window floating above your content. Unlike `FloatingFixieEmbed`, it does not additionally create a launcher button. Instead, you manage the visibility yourself, via the `visible` prop. - -#### `` - -```ts -import { InlineFixieEmbed } from 'fixie/web'; -``` - -This React component will embed a Fixie chat window inline with your content. - -### Bring Your Own Frontend - -If you want to directly integrate Fixie into your webapp, use these APIs. - -#### `useFixie` - -```ts -import { useFixie } from 'fixie/web'; -``` - -This hook provides a fully managed API for a conversation. It returns a number of fields you can use to drive a rich UI, including loading states and debug diagnostics. - -#### `FixieClient` - -```ts -import { FixieClient } from 'fixie'; -``` - -This low-level API provides direct access to the Fixie Conversation and Corpus APIs. You need to manage things like loading state and response parsing on your own, but it's the most flexible. - -## Dev Notes - -To publish this package: - -1. Update the version number in `package.json`. -1. Run `yarn install` in the root to get the changes to `yarn.lock`. -1. Finally, run `yarn npm publish` in this directory to get the updated package published to npm. diff --git a/packages/fixie/src/agent.ts b/packages/fixie/src/agent.ts deleted file mode 100644 index 3e4f6e13e..000000000 --- a/packages/fixie/src/agent.ts +++ /dev/null @@ -1,955 +0,0 @@ -import { gql } from '@apollo/client/core/index.js'; -import yaml from 'js-yaml'; -import fs from 'fs'; -import terminal from 'terminal-kit'; -import { execSync } from 'child_process'; -import ora from 'ora'; -import os from 'os'; -import path from 'path'; -import { execa } from 'execa'; -import Watcher from 'watcher'; -import net from 'node:net'; - -import * as TJS from 'typescript-json-schema'; - -const { terminal: term } = terminal; - -import { FixieClient } from './client.js'; -import { MergeExclusive } from 'type-fest'; - -/** Represents metadata about an agent managed by the Fixie service. */ -export interface AgentMetadata { - uuid: string; - handle: string; - name?: string; - description?: string; - moreInfoUrl?: string; - published?: boolean; - created: Date; - modified: Date; - currentRevision?: AgentRevision; - allRevisions?: AgentRevision[]; -} - -/** Represents the contents of an agent.yaml configuration file. */ -export interface AgentConfig { - handle: string; - name?: string; - description?: string; - moreInfoUrl?: string; - deploymentUrl?: string; -} - -/** Represents metadata about an agent revision. */ -export interface AgentRevision { - id: string; - created: Date; - isCurrent: boolean; -} - -/** Represents an Agent Log entry. */ -export interface AgentLogEntry { - timestamp: Date; - traceId?: string; - spanId?: string; - severity?: number; - message?: string; -} - -/** - * This class provides an interface to the Fixie Agent API. - */ -export class FixieAgent { - /** Use GetAgent or CreateAgent instead. */ - private constructor(readonly client: FixieClient, public metadata: AgentMetadata) {} - - public get handle(): string { - return this.metadata.handle; - } - - /** Return the URL for this agent's page on Fixie. */ - public agentUrl(baseUrl?: string): string { - const url = new URL(`agents/${this.metadata.uuid}`, baseUrl ?? 'https://api.fixie.ai'); - // If using the default API host, change it to the console host. - if (url.hostname === 'api.fixie.ai') { - url.hostname = 'console.fixie.ai'; - } - return url.toString(); - } - - /** Get the agent with the given agent ID or handle. */ - public static async GetAgent({ - client, - agentId, - handle, - }: { - client: FixieClient; - agentId?: string; - handle?: string; - }): Promise { - if (!agentId && !handle) { - throw new Error('Must specify either agentId or handle'); - } - if (agentId && handle) { - throw new Error('Must specify either agentId or handle, not both'); - } - let metadata: AgentMetadata; - if (agentId) { - metadata = await FixieAgent.getAgentById(client, agentId); - } else { - metadata = await FixieAgent.getAgentByHandle(client, handle!); - } - return new FixieAgent(client, metadata); - } - - /** Return all agents visible to the user. */ - public static async ListAgents(client: FixieClient): Promise { - const result = await client.gqlClient().query({ - fetchPolicy: 'no-cache', - query: gql` - { - allAgentsForUser { - uuid - } - } - `, - }); - return Promise.all( - result.data.allAgentsForUser.map((agent: any) => this.GetAgent({ client, agentId: agent.uuid })) - ); - } - - /** Return the metadata associated with the given agent by ID. */ - private static async getAgentById(client: FixieClient, agentId: string): Promise { - const result = await client.gqlClient().query({ - fetchPolicy: 'no-cache', - query: gql` - query GetAgentById($agentId: String!) { - agent: agentById(agentId: $agentId) { - agentId - uuid - handle - name - description - moreInfoUrl - created - modified - published - currentRevision { - id - created - } - allRevisions { - id - created - } - } - } - `, - variables: { agentId }, - }); - - return { - uuid: result.data.agent.uuid, - handle: result.data.agent.handle, - name: result.data.agent.name, - description: result.data.agent.description, - moreInfoUrl: result.data.agent.moreInfoUrl, - published: result.data.agent.published, - created: new Date(result.data.agent.created), - modified: new Date(result.data.agent.modified), - currentRevision: result.data.agent.currentRevision, - allRevisions: result.data.agent.allRevisions, - }; - } - - /** Return the metadata associated with the given agent handle. */ - private static async getAgentByHandle(client: FixieClient, handle: string): Promise { - const result = await client.gqlClient().query({ - fetchPolicy: 'no-cache', - query: gql` - query GetAgentByHandle($handle: String!) { - agent: agentByHandle(handle: $handle) { - agentId - uuid - handle - name - description - moreInfoUrl - created - modified - published - currentRevision { - id - created - } - allRevisions { - id - created - } - } - } - `, - variables: { handle }, - }); - - return { - uuid: result.data.agent.uuid, - handle: result.data.agent.handle, - name: result.data.agent.name, - description: result.data.agent.description, - moreInfoUrl: result.data.agent.moreInfoUrl, - published: result.data.agent.published, - created: new Date(result.data.agent.created), - modified: new Date(result.data.agent.modified), - currentRevision: result.data.agent.currentRevision, - allRevisions: result.data.agent.allRevisions, - }; - } - - /** Create a new Agent. */ - public static async CreateAgent({ - client, - handle, - teamId, - name, - description, - moreInfoUrl, - published, - }: { - client: FixieClient; - handle: string; - teamId?: string; - name?: string; - description?: string; - moreInfoUrl?: string; - published?: boolean; - }): Promise { - const result = await client.gqlClient().mutate({ - mutation: gql` - mutation CreateAgent( - $handle: String! - $teamId: String - $description: String - $moreInfoUrl: String - $published: Boolean - ) { - createAgent( - agentData: { - handle: $handle - teamId: $teamId - description: $description - moreInfoUrl: $moreInfoUrl - published: $published - } - ) { - agent { - uuid - } - } - } - `, - variables: { - handle, - teamId, - name, - description, - moreInfoUrl, - published: published ?? true, - }, - }); - const agentId = result.data.createAgent.agent.uuid; - return FixieAgent.GetAgent({ client, agentId }); - } - - /** Delete this agent. */ - delete() { - return this.client.gqlClient().mutate({ - mutation: gql` - mutation DeleteAgent($uuid: UUID!) { - deleteAgent(agentData: { uuid: $uuid }) { - agent { - uuid - handle - } - } - } - `, - variables: { uuid: this.metadata.uuid }, - }); - } - - /** Update this agent. */ - async update({ - name, - description, - moreInfoUrl, - published, - }: { - name?: string; - description?: string; - moreInfoUrl?: string; - published?: boolean; - }) { - await this.client.gqlClient().mutate({ - mutation: gql` - mutation UpdateAgent( - $uuid: UUID! - $handle: String - $name: String - $description: String - $moreInfoUrl: String - $published: Boolean - ) { - updateAgent( - agentData: { - uuid: $uuid - handle: $handle - name: $name - description: $description - moreInfoUrl: $moreInfoUrl - published: $published - } - ) { - agent { - uuid - } - } - } - `, - variables: { - uuid: this.metadata.uuid, - handle: this.handle, - name, - description, - moreInfoUrl, - published, - }, - }); - this.metadata = await FixieAgent.getAgentById(this.client, this.metadata.uuid); - } - - /** Return logs for this Agent. Returns the last 15 minutes of agent logs. */ - async getLogs({ - start, - end, - limit, - offset, - minSeverity, - conversationId, - messageId, - }: { - start?: Date; - end?: Date; - limit?: number; - offset?: number; - minSeverity?: number; - conversationId?: string; - messageId?: string; - }): Promise { - // We don't actually care about the full URL here. We're only using the - // URL to build up the query parameters. - const url = new URL('http://localhost/'); - if (start) { - url.searchParams.append('startTimestamp', Math.floor(start.getTime() / 1000).toString()); - } - if (end) { - url.searchParams.append('endTimestamp', Math.floor(end.getTime() / 1000).toString()); - } - if (limit) { - url.searchParams.append('limit', limit.toString()); - } - if (offset) { - url.searchParams.append('offset', offset.toString()); - } - if (minSeverity) { - url.searchParams.append('minSeverity', minSeverity.toString()); - } - if (conversationId) { - url.searchParams.append('conversationId', conversationId); - } - if (messageId) { - url.searchParams.append('messageId', messageId); - } - const retval = await this.client.request(`/api/v1/agents/${this.metadata.uuid}/logs${url.search}`); - if (retval.status !== 200) { - return []; - } - const logs = (await retval.json()) as { logs: AgentLogEntry[] }; - return logs.logs; - } - - /** Load an agent configuration from the given directory. */ - public static LoadConfig(agentPath: string): AgentConfig { - const fullPath = path.resolve(path.join(agentPath, 'agent.yaml')); - const config = yaml.load(fs.readFileSync(fullPath, 'utf8')) as object; - - // Warn if any fields are present in config that are not supported. - const validKeys = [ - 'handle', - 'name', - 'description', - 'moreInfoUrl', - 'more_info_url', - 'deploymentUrl', - 'deployment_url', - ]; - const invalidKeys = Object.keys(config).filter((key) => !validKeys.includes(key)); - for (const key of invalidKeys) { - term('❓ Ignoring invalid key ').yellow(key)(' in agent.yaml\n'); - } - return config as AgentConfig; - } - - private static inferRuntimeParametersSchema(agentPath: string): TJS.Definition | null { - // If there's a tsconfig.json file, try to use Typescript to produce a JSON schema - // with the runtime parameters for the agent. - const tsconfigPath = path.resolve(path.join(agentPath, 'tsconfig.json')); - if (!fs.existsSync(tsconfigPath)) { - term.yellow(`⚠️ tsconfig.json not found at ${tsconfigPath}. Your agent will not support runtime parameters.\n`); - return null; - } - - const settings: TJS.PartialArgs = { - required: true, - noExtraProps: true, - }; - - // We're currently assuming the entrypoint is exported from src/index.{ts,tsx}. - const handlerPath = path.resolve(path.join(agentPath, 'src/index.js')); - const tempPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'fixie-')), 'extract-parameters-schema.mts'); - fs.writeFileSync( - tempPath, - ` - import Handler from '${handlerPath}'; - export type RuntimeParameters = Parameters extends [infer T, ...any] ? T : {}; - ` - ); - const program = TJS.programFromConfig(tsconfigPath, [tempPath]); - const schema = TJS.generateSchema(program, 'RuntimeParameters', settings); - if (schema && schema.type !== 'object') { - throw new Error(`The first argument of your default export must be an object (not ${schema.type})`); - } - - return schema; - } - - /** Package the code in the given directory and return the path to the tarball. */ - private static getCodePackage(agentPath: string): string { - // Read the package.json file to get the package name and version. - const packageJsonPath = path.resolve(path.join(agentPath, 'package.json')); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - - // Create a temporary directory and run `npm pack` inside. - const tempdir = fs.mkdtempSync(path.join(os.tmpdir(), `fixie-tmp-${packageJson.name}-${packageJson.version}-`)); - const commandline = `npm pack ${path.resolve(agentPath)}`; - try { - execSync(commandline, { cwd: tempdir, stdio: 'inherit' }); - } catch (ex) { - throw new Error(`\`${commandline}\` failed. Check for build errors above and retry.`); - } - return `${tempdir}/${packageJson.name}-${packageJson.version}.tgz`; - } - - /** Create a new agent revision, which deploys the agent. */ - private async createRevision( - opts: MergeExclusive<{ externalUrl: string }, { tarball: string; environmentVariables: Record }> & { - defaultRuntimeParameters?: Record | null; - runtimeParametersSchema?: TJS.Definition | null; - } - ): Promise { - const uploadFile = opts.tarball ? fs.readFileSync(fs.realpathSync(opts.tarball)) : undefined; - - const result = await this.client.gqlClient().mutate({ - mutation: gql` - mutation CreateAgentRevision( - $agentUuid: UUID! - $metadata: [RevisionMetadataKeyValuePairInput!]! - $makeCurrent: Boolean! - $externalDeployment: ExternalDeploymentInput - $managedDeployment: ManagedDeploymentInput - $defaultRuntimeParameters: JSONString - ) { - createAgentRevision( - agentUuid: $agentUuid - makeCurrent: $makeCurrent - revision: { - metadata: $metadata - externalDeployment: $externalDeployment - managedDeployment: $managedDeployment - defaultRuntimeParameters: $defaultRuntimeParameters - } - ) { - revision { - id - created - } - } - } - `, - variables: { - agentUuid: this.metadata.uuid, - metadata: [], - makeCurrent: true, - defaultRuntimeParameters: JSON.stringify(opts.defaultRuntimeParameters), - externalDeployment: opts.externalUrl && { - url: opts.externalUrl, - runtimeParametersSchema: JSON.stringify(opts.runtimeParametersSchema), - }, - managedDeployment: opts.tarball && - uploadFile && { - codePackage: new Blob([uploadFile], { type: 'application/gzip' }), - environmentVariables: Object.entries(opts.environmentVariables).map(([key, value]) => ({ - name: key, - value, - })), - runtimeParametersSchema: JSON.stringify(opts.runtimeParametersSchema), - }, - }, - fetchPolicy: 'no-cache', - }); - - return result.data.createAgentRevision.revision; - } - - /** Get the current agent revision. */ - public async getCurrentRevision(): Promise { - const result = await this.client.gqlClient().query({ - fetchPolicy: 'no-cache', - query: gql` - query GetRevisionId($agentId: String!) { - agentById(agentId: $agentId) { - currentRevision { - id - created - } - } - } - `, - variables: { agentId: this.metadata.uuid }, - }); - return result.data.agentById.currentRevision as AgentRevision; - } - - /** Set the current agent revision. */ - public async setCurrentRevision(revisionId: string): Promise { - const result = await this.client.gqlClient().mutate({ - mutation: gql` - mutation SetCurrentAgentRevision($agentUuid: UUID!, $currentRevisionId: ID!) { - updateAgent(agentData: { uuid: $agentUuid, currentRevisionId: $currentRevisionId }) { - agent { - currentRevision { - id - created - } - } - } - } - `, - variables: { agentUuid: this.metadata.uuid, currentRevisionId: revisionId }, - fetchPolicy: 'no-cache', - }); - return result.data.updateAgent.agent.currentRevision as AgentRevision; - } - - public async deleteRevision(revisionId: string): Promise { - await this.client.gqlClient().mutate({ - mutation: gql` - mutation DeleteAgentRevision($agentUuid: UUID!, $revisionId: ID!) { - deleteAgentRevision(agentUuid: $agentUuid, revisionId: $revisionId) { - agent { - agentId - } - } - } - `, - variables: { agentUuid: this.metadata.uuid, revisionId }, - fetchPolicy: 'no-cache', - }); - } - - /** Ensure that the agent is created or updated. */ - private static async ensureAgent(client: FixieClient, config: AgentConfig): Promise { - let agent: FixieAgent; - try { - agent = await FixieAgent.GetAgent({ client, handle: config.handle }); - await agent.update({ - name: config.name, - description: config.description, - moreInfoUrl: config.moreInfoUrl, - }); - } catch (e) { - // Try to create the agent instead. - term('🦊 Creating new agent ').green(config.handle)('...\n'); - agent = await FixieAgent.CreateAgent({ - client, - handle: config.handle, - name: config.name, - description: config.description, - moreInfoUrl: config.moreInfoUrl, - }); - } - return agent; - } - - static spawnAgentProcess(agentPath: string, port: number, env: Record) { - term(`🌱 Building agent at ${agentPath}...\n`); - this.getCodePackage(agentPath); - - const pathToCheck = path.resolve(path.join(agentPath, 'dist', 'index.js')); - if (!fs.existsSync(pathToCheck)) { - throw Error(`Your agent was not found at ${pathToCheck}. Did the build fail?`); - } - - const cmdline = `npx --package=@fixieai/sdk fixie-serve-bin --packagePath ./dist/index.js --port ${port}`; - // Split cmdline into the first value (argv0) and a list of arguments separated by spaces. - term('🌱 Running: ').green(cmdline)('\n'); - - const [argv0, ...args] = cmdline.split(' '); - const subProcess = execa(argv0, args, { cwd: agentPath, env }); - term('🌱 Agent process running at PID: ').green(subProcess.pid)('\n'); - subProcess.stdout?.setEncoding('utf8'); - subProcess.stderr?.setEncoding('utf8'); - - subProcess.on('spawn', () => { - console.log(`🌱 Agent child process started with PID [${subProcess.pid}]`); - }); - subProcess.stdout?.on('data', (sdata: string) => { - console.log(`🌱 Agent stdout: ${sdata.trimEnd()}`); - }); - subProcess.stderr?.on('data', (sdata: string) => { - console.error(`🌱 Agent stdout: ${sdata.trimEnd()}`); - }); - subProcess.on('error', (err: any) => { - term('🌱 ').red(`Agent child process [${subProcess.pid}] exited with error: ${err.message}\n`); - }); - subProcess.on('close', (returnCode: number) => { - term('🌱 ').red(`Agent child process [${subProcess.pid}] exited with code ${returnCode}\n`); - }); - return subProcess; - } - - /** Deploy an agent from the given directory. */ - public static async DeployAgent( - client: FixieClient, - agentPath: string, - environmentVariables: Record = {} - ): Promise { - const config = await FixieAgent.LoadConfig(agentPath); - term('🦊 Deploying agent ').green(config.handle)('...\n'); - - // Check that the package.json path exists in this directory. - const packageJsonPath = path.resolve(path.join(agentPath, 'package.json')); - if (!fs.existsSync(packageJsonPath)) { - throw Error(`No package.json found at ${packageJsonPath}. Only JS-based agents are supported.`); - } - - const yarnLockPath = path.resolve(path.join(agentPath, 'yarn.lock')); - const pnpmLockPath = path.resolve(path.join(agentPath, 'pnpm-lock.yaml')); - - if (fs.existsSync(yarnLockPath)) { - term.yellow( - '⚠️ Detected yarn.lock file, but Fixie only supports npm. Fixie will try to install your package with npm, which may produce unexpected results.' - ); - } - if (fs.existsSync(pnpmLockPath)) { - term.yellow( - '⚠️ Detected pnpm-lock.yaml file, but Fixie only supports npm. Fixie will try to install your package with npm, which may produce unexpected results.' - ); - } - - const agent = await this.ensureAgent(client, config); - const runtimeParametersSchema = this.inferRuntimeParametersSchema(agentPath); - const tarball = FixieAgent.getCodePackage(agentPath); - const spinner = ora(' 🚀 Deploying... (hang tight, this takes a minute or two!)').start(); - const revision = await agent.createRevision({ tarball, environmentVariables, runtimeParametersSchema }); - spinner.succeed(`Agent ${config.handle} is running at: ${agent.agentUrl(client.url)}`); - return revision; - } - - /** Run an agent locally from the given directory. */ - public static async ServeAgent({ - client, - agentPath, - tunnel, - port, - environmentVariables, - debug, - }: { - client: FixieClient; - agentPath: string; - tunnel?: boolean; - port: number; - environmentVariables: Record; - debug?: boolean; - }) { - const config = await FixieAgent.LoadConfig(agentPath); - term('🦊 Serving agent ').green(config.handle)('...\n'); - - // Check if the package.json path exists in this directory. - const packageJsonPath = path.resolve(path.join(agentPath, 'package.json')); - if (!fs.existsSync(packageJsonPath)) { - throw Error(`No package.json found in ${packageJsonPath}. Only JS-based agents are supported.`); - } - - // Infer the runtime parameters schema. We'll create a generator that yields whenever the schema changes. - let runtimeParametersSchema = FixieAgent.inferRuntimeParametersSchema(agentPath); - const { iterator: schemaGenerator, push: pushToSchemaGenerator } = - this.createAsyncIterable(); - pushToSchemaGenerator(runtimeParametersSchema); - - // Start the agent process locally. - let agentProcess = FixieAgent.spawnAgentProcess(agentPath, port, environmentVariables); - - // Watch files in the agent directory for changes. - const watchPath = path.resolve(agentPath); - const watchExcludePaths = [ - path.resolve(path.join(agentPath, 'dist')), - path.resolve(path.join(agentPath, 'node_modules')), - ]; - // Return true if the path matches the prefix of any of the exclude paths. - const ignoreFunc = (path: string): boolean => { - if (watchExcludePaths.some((excludePath) => path.startsWith(excludePath))) { - return true; - } - return false; - }; - console.log(`🌱 Watching ${watchPath} for changes...`); - - const watcher = new Watcher(watchPath, { - ignoreInitial: true, - recursive: true, - ignore: ignoreFunc, - }); - watcher.on('all', async (event: any, targetPath: string, _targetPathNext: any) => { - console.log(`🌱 Restarting local agent process due to ${event}: ${targetPath}`); - agentProcess.kill(); - // Let it shut down gracefully. - await new Promise((resolve) => { - if (agentProcess.exitCode !== null || agentProcess.signalCode !== null) { - resolve(); - } else { - agentProcess.on('close', () => { - resolve(); - }); - } - }); - - try { - const newSchema = FixieAgent.inferRuntimeParametersSchema(agentPath); - if (JSON.stringify(runtimeParametersSchema) !== JSON.stringify(newSchema)) { - pushToSchemaGenerator(newSchema); - runtimeParametersSchema = newSchema; - } - - agentProcess = FixieAgent.spawnAgentProcess(agentPath, port, environmentVariables); - } catch (ex) { - term(`❌ Failed to restart agent process: ${ex} \n`); - } - }); - - // This is an iterator which yields the public URL of the tunnel where the agent - // can be reached by the Fixie service. The tunnel address can change over time. - let deploymentUrlsIter: AsyncIterator; - if (tunnel) { - deploymentUrlsIter = FixieAgent.spawnTunnel(port, Boolean(debug)); - } else { - if (!config.deploymentUrl) { - throw Error('No deployment URL specified in agent.yaml'); - } - deploymentUrlsIter = (async function* () { - yield config.deploymentUrl!; - - // Never yield another value. - await new Promise(() => {}); - })(); - } - - const agent = await this.ensureAgent(client, config); - const originalRevision = await agent.getCurrentRevision(); - if (originalRevision) { - term('🥡 Replacing current agent revision ').green(originalRevision.id)('\n'); - } - let currentRevision: AgentRevision | null = null; - const doCleanup = async () => { - watcher.close(); - if (originalRevision) { - try { - await agent.setCurrentRevision(originalRevision.id); - term('🥡 Restoring original agent revision ').green(originalRevision.id)('\n'); - } catch (e: any) { - term('🥡 Failed to restore original agent revision: ').red(e.message)('\n'); - } - } - if (currentRevision) { - try { - await agent.deleteRevision(currentRevision.id); - term('🥡 Deleting temporary agent revision ').green(currentRevision.id)('\n'); - } catch (e: any) { - term('🥡 Failed to delete temporary agent revision: ').red(e.message)('\n'); - } - } - }; - process.on('SIGINT', async () => { - console.log('Got Ctrl-C - cleaning up and exiting.'); - await doCleanup(); - }); - - // The tunnel may yield different URLs over time. We need to create a new - // agent revision each time. - for await (const [currentUrl, runtimeParametersSchema] of this.zipAsyncIterables( - deploymentUrlsIter, - schemaGenerator - )) { - await FixieAgent.pollPortUntilReady(port); - - term('🚇 Current tunnel URL is: ').green(currentUrl)('\n'); - try { - if (currentRevision) { - term('🥡 Deleting temporary agent revision ').green(currentRevision.id)('\n'); - await agent.deleteRevision(currentRevision.id); - currentRevision = null; - } - currentRevision = await agent.createRevision({ externalUrl: currentUrl, runtimeParametersSchema }); - term('🥡 Created temporary agent revision ').green(currentRevision.id)('\n'); - term('🥡 Agent ').green(config.handle)(' is running at: ').green(agent.agentUrl(client.url))('\n'); - } catch (e: any) { - term('🥡 Got error trying to create agent revision: ').red(e.message)('\n'); - console.error(e); - continue; - } - } - } - - private static async pollPortUntilReady(port: number): Promise { - while (true) { - try { - await new Promise((resolve, reject) => { - const socket = net.connect({ - host: '127.0.0.1', - port, - }); - - socket.on('connect', resolve); - socket.on('error', reject); - }); - break; - } catch { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } - } - - private static createAsyncIterable(): { iterator: AsyncIterator; push: (value: T) => void } { - let streamController: ReadableStreamDefaultController; - const stream = new ReadableStream({ - start(controller) { - streamController = controller; - }, - }); - - return { - // @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/62651 - iterator: stream[Symbol.asyncIterator](), - push: (value: T) => { - streamController.enqueue(value); - }, - }; - } - - private static async *zipAsyncIterables( - gen1: AsyncIterator, - gen2: AsyncIterator - ): AsyncGenerator<[T, U]> { - const generators = [gen1, gen2] as const; - const currentValues = (await Promise.all(generators.map((g) => g.next()))).map((v) => v.value) as [T, U]; - const nextPromises = generators.map((g) => g.next()); - - async function updateWithReadyValue(index: number): Promise { - const value = await Promise.race([nextPromises[index], null]); - if (value === null) { - return false; - } - - if (value.done) { - return true; - } - - currentValues[index] = value.value; - nextPromises[index] = generators[index].next(); - return false; - } - - while (true) { - yield currentValues; - - // Wait for one of the generators to yield a new value. - await Promise.race(nextPromises); - - const shouldExit = await Promise.all([0, 1].map(updateWithReadyValue)); - if (shouldExit.some((v) => v)) { - break; - } - } - } - - private static spawnTunnel(port: number, debug: boolean): AsyncIterator { - const { iterator, push: pushToIterator } = this.createAsyncIterable(); - - term('🚇 Starting tunnel process...\n'); - // We use localhost.run as a tunneling service. This sets up an SSH tunnel - // to the provided local port via localhost.run. The subprocess returns a - // stream of JSON responses, one per line, with the external URL of the tunnel - // as it changes. - const subProcess = execa('ssh', [ - '-R', - // N.B. 127.0.0.1 must be used on Windows (not localhost or 0.0.0.0) - `80:127.0.0.1:${port}`, - '-o', - // Need to send keepalives to prevent the connection from getting chopped - // (see https://localhost.run/docs/faq#my-connection-is-unstable-tunnels-go-down-often) - 'ServerAliveInterval=59', - '-o', - 'StrictHostKeyChecking=accept-new', - 'nokey@localhost.run', - '--', - '--output=json', - ]); - subProcess.stdout?.setEncoding('utf8'); - - // Every time the subprocess emits a new line, we parse it as JSON ans - // extract the 'address' field. - let currentLine = ''; - subProcess.stdout?.on('data', (chunk: string) => { - // We need to do buffering since the data we get from stdout - // will not necessarily be line-buffered. We can get 0, 1, or more complete - // lines in a single chunk. - currentLine += chunk; - let newlineIndex; - while ((newlineIndex = currentLine.indexOf('\n')) !== -1) { - const line = currentLine.slice(0, newlineIndex); - currentLine = currentLine.slice(newlineIndex + 1); - // Parse data as JSON. - const pdata = JSON.parse(line); - // If pdata has the 'address' field, yield it. - if (pdata.address) { - pushToIterator(`https://${pdata.address}`); - } - } - }); - - subProcess.stderr?.on('data', (sdata: string) => { - if (debug) { - console.error(`🚇 Tunnel stderr: ${sdata}`); - } - }); - subProcess.on('close', (returnCode: number) => { - if (debug) { - console.log(`🚇 Tunnel child process exited with code ${returnCode}`); - } - iterator.return?.(null); - }); - - return iterator; - } -} diff --git a/packages/fixie/src/auth.ts b/packages/fixie/src/auth.ts deleted file mode 100644 index 2a4c19efb..000000000 --- a/packages/fixie/src/auth.ts +++ /dev/null @@ -1,209 +0,0 @@ -import yaml from 'js-yaml'; -import fs from 'fs'; -import terminal from 'terminal-kit'; -import path from 'path'; -import untildify from 'untildify'; -import axios from 'axios'; -import open from 'open'; -import http from 'http'; -import crypto from 'crypto'; -import net from 'net'; - -const { terminal: term } = terminal; - -import { FixieClient } from './client.js'; - -/** Represents contents of the Fixie CLI config file. */ -export interface FixieConfig { - apiUrl?: string; - apiKey?: string; -} - -export const FIXIE_API_URL = 'https://api.fixie.ai'; -export const FIXIE_CONFIG_FILE = '~/.config/fixie/config.yaml'; - -/** Load the client configuration from the given file. */ -export function loadConfig(configFile: string): FixieConfig { - const fullPath = untildify(configFile); - if (!fs.existsSync(fullPath)) { - return {}; - } - const config = yaml.load(fs.readFileSync(fullPath, 'utf8')) as object; - // Warn if any fields are present in config that are not supported. - const validKeys = ['apiUrl', 'apiKey']; - const invalidKeys = Object.keys(config).filter((key) => !validKeys.includes(key)); - for (const key of invalidKeys) { - term('❓ Ignoring invalid key ').yellow(key)(` in ${fullPath}\n`); - } - return config as FixieConfig; -} - -/** Save the client configuration to the given file. */ -export function saveConfig(config: FixieConfig, configFile: string) { - const fullPath = untildify(configFile); - const dirName = path.dirname(fullPath); - if (!fs.existsSync(dirName)) { - fs.mkdirSync(dirName, { recursive: true }); - } - if (fs.existsSync(fullPath)) { - // Merge the new config with the existing config, so we don't - // overwrite any fields that are not specified. - const currentConfig = yaml.load(fs.readFileSync(fullPath, 'utf8')) as object; - const mergedConfig = { ...currentConfig, ...config }; - fs.writeFileSync(fullPath, yaml.dump(mergedConfig)); - } else { - fs.writeFileSync(fullPath, yaml.dump(config)); - } -} - -/** Returns an authenticated FixieClient, or null if the user is not authenticated. */ -export async function Authenticate({ - apiUrl, - configFile, -}: { - apiUrl?: string; - configFile?: string; -}): Promise { - // The precedence for selecting the API URL and key is: - // 1. apiUrl argument to this function. (The key cannot be passed as an argument.) - // 2. FIXIE_API_URL and FIXIE_API_KEY environment variables. - // 3. apiUrl and apiKey fields in the config file. - // 4. Fallback value for apiUrl (constant defined above). - const config = loadConfig(configFile ?? FIXIE_CONFIG_FILE); - const useApiUrl = apiUrl ?? process.env.FIXIE_API_URL ?? config.apiUrl ?? FIXIE_API_URL; - const useApiKey = process.env.FIXIE_API_KEY ?? config.apiKey; - if (!useApiKey) { - // No key available. Need to punt. - return null; - } - try { - const client = new FixieClient({ apiKey: useApiKey, url: useApiUrl }); - await client.userInfo(); - return client; - } catch (error: any) { - // If the client is not authenticated, we will get a 401 error. - return null; - } -} - -/** Returns an authenticated FixieClient, starting an OAuth flow to authenticate the user if necessary. */ -export async function AuthenticateOrLogIn({ - apiUrl, - configFile, - forceReauth, -}: { - apiUrl?: string; - configFile?: string; - forceReauth?: boolean; -}): Promise { - if (!forceReauth) { - const client = await Authenticate({ - apiUrl, - configFile, - }); - if (client) { - try { - await client.userInfo(); - return client; - } catch (error: any) { - // If the client is not authenticated, we will get a 401 error. - } - } - } - - const apiKey = await oauthFlow(apiUrl ?? FIXIE_API_URL); - const config: FixieConfig = { - apiUrl: apiUrl ?? FIXIE_API_URL, - apiKey, - }; - saveConfig(config, configFile ?? FIXIE_CONFIG_FILE); - const client = await Authenticate({ apiUrl, configFile: configFile ?? FIXIE_CONFIG_FILE }); - if (!client) { - throw new Error('Failed to authenticate - please try logging in at https://console.fixie.ai on the web.'); - } - const userInfo = await client.userInfo(); - term('🎉 Successfully logged into ') - .green(apiUrl ?? FIXIE_API_URL)(' as ') - .green(userInfo.email)('\n'); - return client; -} - -// The Fixie CLI client ID. -const CLIENT_ID = 'II4FM6ToxVwSKB6DW1r114AKAuSnuZEgYehEBB-5WQA'; -// The scopes requested by the OAUth flow. -const SCOPES = ['api-access']; - -/** - * Runs an interactive authorization flow with the user, returning a Fixie API key - * if successful. - */ -async function oauthFlow(apiUrl: string): Promise { - const port = await findFreePort(); - const redirectUri = `http://localhost:${port}`; - const state = crypto.randomBytes(16).toString('base64url'); - const url = `${apiUrl}/authorize?client_id=${CLIENT_ID}&scope=${SCOPES.join( - ' ' - )}&state=${state}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code`; - - const serverPromise = new Promise((resolve, reject) => { - const server = http - .createServer(async (req, res) => { - if (req.url) { - const searchParams = new URL(req.url, `http://localhost:${port}`).searchParams; - const code = searchParams.get('code'); - const receivedState = searchParams.get('state'); - if (code && receivedState === state) { - try { - const bodyFormData = new FormData(); - bodyFormData.append('code', code); - bodyFormData.append('redirect_uri', redirectUri); - bodyFormData.append('client_id', CLIENT_ID); - bodyFormData.append('grant_type', 'authorization_code'); - const response = await axios.post(`${apiUrl}/access/token`, bodyFormData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); - const accessToken = response.data.access_token; - if (typeof accessToken === 'string') { - res.writeHead(200); - res.end('You can close this tab now.'); - resolve(accessToken); - } else { - res.writeHead(200); - const errMsg = `Error: Invalid access token type ${typeof accessToken}`; - res.end(errMsg); - reject(new Error(errMsg)); - } - } catch (error: any) { - res.writeHead(200); - const errMsg = error.response?.data?.error_description ?? error.message; - res.end(errMsg); - reject(error); - } - } - server.close(); - } - }) - .listen(port); - }); - - await open(url); - term('🔑 Your browser has been opened to visit:\n\n ').blue.underline(url)('\n\n'); - return serverPromise as Promise; -} - -/** Return a free port on the local machine. */ -function findFreePort(): Promise { - return new Promise((res) => { - const srv = net.createServer(); - srv.listen(0, () => { - const address = srv.address(); - if (address && typeof address === 'object') { - srv.close((_) => res(address.port)); - } else { - throw new Error('Failed to find free port'); - } - }); - }); -} diff --git a/packages/fixie/src/client.ts b/packages/fixie/src/client.ts deleted file mode 100644 index d00e59935..000000000 --- a/packages/fixie/src/client.ts +++ /dev/null @@ -1,920 +0,0 @@ -import { ApolloClient } from '@apollo/client/core/ApolloClient.js'; -import { InMemoryCache } from '@apollo/client/cache/inmemory/inMemoryCache.js'; -import createUploadLink from 'apollo-upload-client/public/createUploadLink.js'; -import type { Jsonifiable } from 'type-fest'; -import { - AgentId, - AssistantConversationTurn, - Conversation, - ConversationId, - Metadata, - User, - Team, - Membership, - MembershipRole, -} from './types.js'; -import { encode } from 'base64-arraybuffer'; - -export class AgentDoesNotExistError extends Error { - code = 'agent-does-not-exist'; -} - -/** - * Represents an error that occurs when the Fixie client encounters an error contacting - * the API endpoint. - */ -export class FixieClientError extends Error { - url: URL; - statusCode: number; - statusText: string; - detail: unknown; - - constructor(url: URL, statusCode: number, statusText: string, message?: string, detail: unknown = {}) { - super(message); - this.url = url; - this.statusCode = statusCode; - this.statusText = statusText; - this.name = 'FixieClientError'; - this.detail = detail; - } -} - -/** - * A client to the Fixie AI platform. - * - * This client can be used on the web or in NodeJS. - */ -export class FixieClient { - /** - * The API key to use for requests. - */ - public readonly apiKey?: string; - - /** - * The URL of the Fixie API to use for requests. - */ - public readonly url: string; - - /** - * Additional headers to send with requests. - */ - public readonly headers: Record; - - /** - * Initializes a FixieClient. - * - * @param options The options to use for the client. - * @param options.apiKey The API key to use for requests. Required for authenticated requests. - * @param options.url The URL of the Fixie API to use for requests. Defaults to https://api.fixie.ai if not specified. - * @param options.headers Additional headers to send with requests. - */ - public constructor({ url, apiKey, headers }: { apiKey?: string; url?: string; headers?: Record }) { - this.apiKey = apiKey; - this.url = url ?? 'https://api.fixie.ai'; - this.headers = headers ?? {}; - } - - public gqlClient(): ApolloClient { - // For GraphQL operations, we use an ApolloClient with the apollo-upload-client - // extension to allow for file uploads. - return new ApolloClient({ - cache: new InMemoryCache(), - // We're using the apollo-upload-client extension to allow for file uploads. - link: createUploadLink({ - uri: `${this.url}/graphql`, - headers: { - ...this.headers, - ...(this.apiKey && { Authorization: `Bearer ${this.apiKey}` }), - }, - }), - }); - } - - /** Send a request to the Fixie API with the appropriate auth headers. */ - async request(path: string, bodyData?: unknown, method?: string, options: RequestInit = {}) { - const fetchMethod = method ?? (bodyData ? 'POST' : 'GET'); - - const headers: RequestInit['headers'] = { - ...this.headers, - }; - if (bodyData) { - headers['Content-Type'] = 'application/json'; - } - if (this.apiKey) { - headers.Authorization = `Bearer ${this.apiKey}`; - } - const url = new URL(path, this.url); - const res = await fetch(url, { - ...options, - method: fetchMethod, - headers, - // This is needed so serverside NextJS doesn't cache POSTs. - cache: 'no-store', - // eslint-disable-next-line - body: bodyData ? JSON.stringify(bodyData) : undefined, - }).catch((err) => { - throw new FixieClientError(url, 0, 'Network error', `Network error accessing ${url}`, err); - }); - if (!res.ok) { - throw new FixieClientError( - url, - res.status, - res.statusText, - `Error accessing Fixie API: ${url}`, - await res.text() - ); - } - return res; - } - - async requestJson(path: string, bodyData?: unknown, method?: string): Promise { - const response = await this.request(path, bodyData, method); - return response.json(); - } - - async requestJsonLines( - path: string, - bodyData?: unknown, - method?: string - ): Promise> { - const response = await this.request(path, bodyData, method); - if (response.body === null) { - throw new FixieClientError( - new URL(path, this.url), - response.status, - response.statusText, - 'Response body was null' - ); - } - - let buffer = ''; - return response.body.pipeThrough(new TextDecoderStream()).pipeThrough( - new TransformStream({ - flush(controller) { - if (buffer.trim()) { - controller.enqueue(JSON.parse(buffer)); - buffer = ''; - } - }, - transform(chunk, controller) { - buffer += chunk; - const lines = buffer.split('\n'); - buffer = lines.pop()!; - for (const line of lines) { - if (line.trim()) { - controller.enqueue(JSON.parse(line)); - } - } - }, - }) - ); - } - - /** Return information on the currently logged-in user. */ - async userInfo(): Promise { - const rawUserInfo: { user: User } = await this.requestJson('/api/v1/users/me'); - return rawUserInfo.user; - } - - /** - * Update the current user's metadata. - * - * @param options.email The new email address for this user. - * @param options.fullName The new full name for this user. - */ - async updateUser({ email, fullName }: { email?: string; fullName?: string }): Promise { - if (!email && !fullName) { - throw new Error('Must specify either email or fullName'); - } - const fieldMask: string[] = []; - if (email !== undefined) { - fieldMask.push('email'); - } - if (fullName !== undefined) { - fieldMask.push('fullName'); - } - const body = { - user: { - email, - fullName, - }, - updateMask: fieldMask.join(','), - }; - const result: { user: User } = await this.requestJson('/api/v1/users/me', body, 'PUT'); - return result.user; - } - - /** List Corpora visible to this user. - * @param options.teamId Optional team ID to list corpora for. - * @param options.offset The offset into the list of corpora to return. - * @param options.limit The maximum number of corpora to return. - */ - listCorpora({ - teamId, - offset = 0, - limit = 100, - }: { - teamId?: string; - offset?: number; - limit?: number; - }): Promise { - if (teamId !== undefined) { - return this.requestJson(`/api/v1/corpora?team_id=${teamId}&offset=${offset}&limit=${limit}`); - } - return this.requestJson(`/api/v1/corpora?offset=${offset}&limit=${limit}`); - } - - /** - * Get information about a given Corpus. - * - * @param corpusId The ID of the Corpus to get. - */ - getCorpus(corpusId: string): Promise { - return this.requestJson(`/api/v1/corpora/${corpusId}`); - } - - /** - * Create a new Corpus. - * - * @param options.name The name of the new Corpus. - * @param options.description The description of the new Corpus. - * @param options.teamId Optional team ID to own the new Corpus. - */ - createCorpus({ - name, - description, - teamId, - }: { - name?: string; - description?: string; - teamId?: string; - }): Promise { - const body = { - teamId, - corpus: { - display_name: name, - description, - }, - }; - return this.requestJson('/api/v1/corpora', body); - } - - /** - * Update a Corpus. - * - * @param options.name The new name of the Corpus. - * @param options.description The new description of the Corpus. - */ - updateCorpus({ - corpusId, - displayName, - description, - }: { - corpusId: string; - displayName?: string; - description?: string; - }): Promise { - if (!displayName && !description) { - throw new Error('Must specify either displayName or description'); - } - const fieldMask: string[] = []; - if (displayName !== undefined) { - fieldMask.push('displayName'); - } - if (description !== undefined) { - fieldMask.push('description'); - } - const body = { - corpus: { - corpus_id: corpusId, - displayName, - description, - }, - updateMask: fieldMask.join(','), - }; - return this.requestJson(`/api/v1/corpora/${corpusId}`, body, 'PUT'); - } - - /** - * Query a given Corpus. - * - * @param options.corpusId The ID of the Corpus to query. - * @param options.query The query to run. - * @param options.maxChunks The maximum number of chunks to return. - */ - queryCorpus({ - corpusId, - query, - maxChunks, - }: { - corpusId: string; - query: string; - maxChunks?: number; - }): Promise { - const body = { - corpus_id: corpusId, - query, - max_chunks: maxChunks, - }; - return this.requestJson(`/api/v1/corpora/${corpusId}:query`, body); - } - - /** - * Delete a given Corpus. - * - * @param options.corpusId The ID of the Corpus to delete. - */ - deleteCorpus({ corpusId }: { corpusId: string }): Promise { - return this.requestJson(`/api/v1/corpora/${corpusId}`, undefined, 'DELETE'); - } - - /** - * List the Sources in a given Corpus. - * - * @param options.corpusId The ID of the Corpus to list Sources for. - * @param options.offset The offset into the list of Sources to return. - * @param options.limit The maximum number of Sources to return. - */ - listCorpusSources({ - corpusId, - offset = 0, - limit = 100, - }: { - corpusId: string; - offset?: number; - limit?: number; - }): Promise { - return this.requestJson(`/api/v1/corpora/${corpusId}/sources?offset=${offset}&limit=${limit}`); - } - - /** - * Get information about a given Source. - * - * @param options.corpusId The ID of the Corpus that the Source belongs to. - * @param options.sourceId The ID of the Source to get. - */ - getCorpusSource({ corpusId, sourceId }: { corpusId: string; sourceId: string }): Promise { - return this.requestJson(`/api/v1/corpora/${corpusId}/sources/${sourceId}`); - } - - /** - * Add a new Source to a Corpus. - * - * @param options.corpusId The ID of the Corpus to add the Source to. - * @param options.startUrls The URLs to start crawling from. - * @param options.includeGlobs The glob patterns to include. - * @param options.excludeGlobs The glob patterns to exclude. - * @param options.maxDocuments The maximum number of documents to crawl. - * @param options.maxDepth The maximum depth to crawl. - * @param options.description The description of the new Source. - * @param options.displayName The display name of the new Source. - */ - addCorpusSource({ - corpusId, - startUrls, - includeGlobs, - excludeGlobs, - maxDocuments, - maxDepth, - description, - displayName, - }: { - corpusId: string; - startUrls: string[]; - includeGlobs?: string[]; - excludeGlobs?: string[]; - maxDocuments?: number; - maxDepth?: number; - description?: string; - displayName?: string; - }): Promise { - const sanitizedStartUrls = startUrls.map((url) => { - // Delete the query and fragment from the URL. - const urlObj = new URL(url); - urlObj.search = ''; - urlObj.hash = ''; - return urlObj.toString(); - }); - - const body = { - corpus_id: corpusId, - source: { - displayName, - description, - corpus_id: corpusId, - load_spec: { - max_documents: maxDocuments, - web: { - start_urls: sanitizedStartUrls, - max_depth: maxDepth, - include_glob_patterns: includeGlobs, - exclude_glob_patterns: excludeGlobs, - }, - }, - }, - }; - return this.requestJson(`/api/v1/corpora/${corpusId}/sources`, body); - } - - /** - * Add a new file Source to a Corpus. - * - * @param options.corpusId The ID of the Corpus to add the Source to. - * @param options.files The list of files to include in the Source. - * @param options.description The description of the new Source. - * @param options.displayName The display name of the new Source. - */ - async addCorpusFileSource({ - corpusId, - files, - description, - displayName, - }: { - corpusId: string; - files: { - filename: string; - mimeType: string; - contents: Blob; - }[]; - description?: string; - displayName?: string; - }): Promise { - const body = { - corpus_id: corpusId, - source: { - corpus_id: corpusId, - displayName, - description, - load_spec: { - max_documents: files.length, - static: { - documents: await Promise.all( - files.map(async (file) => ({ - filename: file.filename, - mime_type: file.mimeType, - contents: encode(await file.contents.arrayBuffer()), - })) - ), - }, - }, - }, - }; - return this.requestJson(`/api/v1/corpora/${corpusId}/sources`, body); - } - - /** - * Update a Source. - * - * @param options.name The new name of the Source. - * @param options.description The new description of the Source. - */ - updateCorpusSource({ - corpusId, - sourceId, - displayName, - description, - }: { - corpusId: string; - sourceId: string; - displayName?: string; - description?: string; - }): Promise { - if (!displayName && !description) { - throw new Error('Must specify at least one of displayName or description'); - } - const fieldMask: string[] = []; - if (displayName !== undefined) { - fieldMask.push('displayName'); - } - if (description !== undefined) { - fieldMask.push('description'); - } - const body = { - source: { - corpus_id: corpusId, - source_id: sourceId, - displayName, - description, - }, - updateMask: fieldMask.join(','), - }; - return this.requestJson(`/api/v1/corpora/${corpusId}/sources/${sourceId}`, body, 'PUT'); - } - - /** - * Delete a given Source. - * - * The source must have no running jobs and no remaining documents. Use clearCorpusSource() to remove all documents. - * - * @param options.corpusId The ID of the Corpus that the Source belongs to. - * @param options.sourceId The ID of the Source to delete. - */ - deleteCorpusSource({ corpusId, sourceId }: { corpusId: string; sourceId: string }): Promise { - return this.requestJson(`/api/v1/corpora/${corpusId}/sources/${sourceId}`, undefined, 'DELETE'); - } - - /** - * Refresh the given Source. - * - * If a job is already running on this source, and force = false, this call will return an error. - * If a job is already running on this source, and force = true, that job will be killed and restarted. - * - * @param options.corpusId The ID of the Corpus that the Source belongs to. - * @param options.sourceId The ID of the Source to refresh. - * @param options.force Stop any in-progress jobs to refresh the source. - */ - refreshCorpusSource({ - corpusId, - sourceId, - force = false, - }: { - corpusId: string; - sourceId: string; - force?: boolean; - }): Promise { - return this.requestJson(`/api/v1/corpora/${corpusId}/sources/${sourceId}:refresh`, { force }); - } - - /** - * Clear the given Source, removing all its documents and their chunks. - * - * If a job is already running on this source, and force = false, this call will return an error. - * If a job is already running on this source, and force = true, that job will be killed. - * - * @param options.corpusId The ID of the Corpus that the Source belongs to. - * @param options.sourceId The ID of the Source to clear. - * @param options.force Stop any in-progress jobs before clearing the Source. - */ - clearCorpusSource({ - corpusId, - sourceId, - force = false, - }: { - corpusId: string; - sourceId: string; - force?: boolean; - }): Promise { - return this.requestJson(`/api/v1/corpora/${corpusId}/sources/${sourceId}:clear`, { force }); - } - - /** - * List Jobs associated with a given Source. - * - * @param options.corpusId The ID of the Corpus that the Source belongs to. - * @param options.sourceId The ID of the Source. - * @param options.offset The offset into the list of Jobs to return. - * @param options.limit The maximum number of Jobs to return. - */ - listCorpusSourceJobs({ - corpusId, - sourceId, - offset = 0, - limit = 100, - }: { - corpusId: string; - sourceId: string; - offset?: number; - limit?: number; - }): Promise { - return this.requestJson(`/api/v1/corpora/${corpusId}/sources/${sourceId}/jobs?offset=${offset}&limit=${limit}`); - } - - /** - * Get information about a given Job. - * - * @param options.corpusId The ID of the Corpus that the Job belongs to. - * @param options.sourceId The ID of the Source that the Job belongs to. - * @param options.jobId The ID of the Job to get. - */ - getCorpusSourceJob({ - corpusId, - sourceId, - jobId, - }: { - corpusId: string; - sourceId: string; - jobId: string; - }): Promise { - return this.requestJson(`/api/v1/corpora/${corpusId}/sources/${sourceId}/jobs/${jobId}`); - } - - /** - * List Documents in a given Corpus Source. - * - * @param options.corpusId The ID of the Corpus that the Source belongs to. - * @param options.sourceId The ID of the Source. - * @param options.offset The offset into the list of Documents to return. - * @param options.limit The maximum number of Documents to return. - */ - listCorpusSourceDocuments({ - corpusId, - sourceId, - offset = 0, - limit = 100, - }: { - corpusId: string; - sourceId: string; - offset?: number; - limit?: number; - }): Promise { - return this.requestJson( - `/api/v1/corpora/${corpusId}/sources/${sourceId}/documents?offset=${offset}&limit=${limit}` - ); - } - - /** - * Get information about a given Document. - * - * @param options.corpusId The ID of the Corpus that the Document belongs to. - * @param options.sourceId The ID of the Source that the Document belongs to. - * @param options.documentId The ID of the Document to get. - */ - getCorpusSourceDocument({ - corpusId, - sourceId, - documentId, - }: { - corpusId: string; - sourceId: string; - documentId: string; - }): Promise { - return this.requestJson(`/api/v1/corpora/${corpusId}/sources/${sourceId}/documents/${documentId}`); - } - - /** - * Start a new conversation with an agent, optionally sending the initial message. (If you don't send the initial - * message, the agent may.) - * - * @param options.agentId The ID of the agent to start a conversation with. - * @param options.message The initial message to send to the agent, if any. - * @param options.metadata Any metadata to attach to the message. - * - * @returns {Promise>} A stream of Conversation objects. Each member of the stream is - * the latest value of the conversation as the agent streams its response. So, if you're driving a UI with thisresponse, - * you always want to render the most recently emitted value from the stream. - * - * @see sendMessage - * @see stopGeneration - * @see regenerate - */ - startConversation({ agentId, message, metadata }: { agentId: AgentId; message?: string; metadata?: Metadata }) { - return this.requestJsonLines( - `/api/v1/agents/${agentId}/conversations`, - message ? { message, metadata } : undefined, - 'POST' - ); - } - - /** - * Get a conversation by ID. - * - * @param options.agentId The ID of the agent that the conversation belongs to. - * @param options.conversationId The ID of the conversation to get. - * - * @returns {Promise} The conversation. - */ - getConversation({ agentId, conversationId }: { agentId: AgentId; conversationId: ConversationId }) { - return this.requestJson(`/api/v1/agents/${agentId}/conversations/${conversationId}`); - } - - /** - * Send a message to a conversation. If the conversationId does not refer to a conversation that already exists, - * this will throw an error. - * - * @param options.agentId The ID of the agent that the conversation belongs to. - * @param options.conversationId The ID of the conversation to send the message to. - * @param options.message The message to send. - * @param options.metadata Any metadata to attach to the message. - * - * @returns {Promise>} A stream of ConversationTurn objects. Each member of the - * stream is the latest value of the turn as the agent streams its response. So, if you're driving a UI with this - * response, you always want to render the most recently emitted value from the stream. - * - * @see startConversation - */ - sendMessage({ - agentId, - conversationId, - message, - metadata, - }: { - agentId: AgentId; - conversationId: ConversationId; - message: string; - metadata?: Metadata; - }) { - return this.requestJsonLines( - `/api/v1/agents/${agentId}/conversations/${conversationId}/messages`, - { message, metadata }, - 'POST' - ); - } - - /** - * Stop a message that is currently being generated. - * - * @param options.agentId The ID of the agent that the conversation belongs to. - * @param options.conversationId The ID of the conversation to stop generating a message for. - * @param options.messageId The ID of the message to stop generating. - */ - stopGeneration({ - agentId, - conversationId, - messageId, - }: { - agentId: AgentId; - conversationId: ConversationId; - messageId: string; - }) { - return this.request( - `/api/v1/agents/${agentId}/conversations/${conversationId}/messages/${messageId}/stop`, - undefined, - 'POST' - ); - } - - /** - * Regenerate a message that has already been generated. If `messageId` is not the most recent message in the - * conversation, this request will fail. - * - * @param options.agentId The ID of the agent that the conversation belongs to. - * @param options.conversationId The ID of the conversation to regenerate a message for. - * @param options.messageId The ID of the message to regenerate. - * - * @returns {Promise>} A stream of ConversationTurn objects. Each member of the - * stream is the latest value of the turn as the agent streams its response. So, if you're driving a UI with this - * response, you always want to render the most recently emitted value from the stream. - * - * @see stopGeneration - */ - regenerate({ - agentId, - conversationId, - messageId, - }: { - agentId: AgentId; - conversationId: ConversationId; - messageId: string; - }) { - return this.requestJsonLines( - `/api/v1/agents/${agentId}/conversations/${conversationId}/messages/${messageId}/regenerate`, - undefined, - 'POST' - ); - } - - /** Return information about a given user. */ - async getUser({ userId }: { userId: string }): Promise { - const rawUserInfo: { user: User } = await this.requestJson(`/api/v1/users/${userId}`); - return rawUserInfo.user; - } - - /** Create a new team. */ - async createTeam({ - displayName, - description, - avatarUrl, - }: { - displayName?: string; - description?: string; - avatarUrl?: string; - }): Promise { - const response: { team: Team } = await this.requestJson('/api/v1/teams', { - team: { - displayName, - description, - avatarUrl, - }, - }); - return response.team; - } - - /** Get the given team. */ - async getTeam({ teamId }: { teamId: string }): Promise { - const response: { team: Team } = await this.requestJson(`/api/v1/teams/${teamId}`); - return response.team; - } - - /** Delete the given team. */ - deleteTeam({ teamId }: { teamId: string }): Promise { - return this.requestJson(`/api/v1/teams/${teamId}`, undefined, 'DELETE'); - } - - /** - * List the teams visible to the current user. - * - * @param options.offset The offset into the list of teams to return. - * @param options.limit The maximum number of teams to return. - */ - listTeams({ offset = 0, limit = 100 }: { offset?: number; limit?: number }): Promise { - return this.requestJson(`/api/v1/teams?offset=${offset}&limit=${limit}`); - } - - /** - * Update the given team's metadata. - * - * @param options.displayName The new display name for the team. - * @param options.description The new description for the team. - */ - async updateTeam({ - teamId, - displayName, - description, - }: { - teamId: string; - displayName?: string; - description?: string; - }): Promise { - if (!displayName && !description) { - throw new Error('Must specify either displayName or description'); - } - const fieldMask: string[] = []; - if (displayName !== undefined) { - fieldMask.push('displayName'); - } - if (description !== undefined) { - fieldMask.push('description'); - } - const body = { - team: { - displayName, - description, - }, - updateMask: fieldMask.join(','), - }; - const response: { team: Team } = await this.requestJson(`/api/v1/teams/${teamId}`, body, 'PUT'); - return response.team; - } - - /** - * Invite a new member to a team. - * - * @param options.teamId The ID of the team to invite the member to. - * @param options.email The email address of the member to invite. - * @param options.isAdmin Whether the member should be a team admin. - */ - inviteTeamMember({ - teamId, - email, - isAdmin, - }: { - teamId: string; - email: string; - isAdmin?: boolean; - }): Promise { - const body = { - teamId, - email, - role: { - isAdmin, - }, - }; - return this.requestJson(`/api/v1/teams/${teamId}/invite`, body, 'POST'); - } - - /** - * Cancel a pending invitation to a team. - * - * @param options.teamId The ID of the team to cancel the invitation for. - * @param options.email The email address of the member to cancel the invitation for. - */ - cancelInvitation({ teamId, email }: { teamId: string; email: string }): Promise { - return this.requestJson(`/api/v1/teams/${teamId}/invite/${email}`, null, 'DELETE'); - } - - /** - * Remove a member from a team. - * - * @param options.teamId The ID of the team to invite the member to. - * @param options.userId The user ID of the member to remove. - */ - removeTeamMember({ teamId, userId }: { teamId: string; userId: string }): Promise { - return this.requestJson(`/api/v1/teams/${teamId}/members/${userId}`, null, 'DELETE'); - } - - /** - * Update a user's role on a team. - * - * @param options.teamId The ID of the team to update. - * @param options.userId The user ID of the member to update. - * @param options.isAdmin Set the admin role for this user. - */ - updateTeamMember({ - teamId, - userId, - isAdmin, - }: { - teamId: string; - userId: string; - isAdmin: boolean; - }): Promise { - const body = { - teamId, - userId, - role: { - isAdmin, - }, - }; - return this.requestJson(`/api/v1/teams/${teamId}/members/${userId}`, body, 'PUT'); - } -} diff --git a/packages/fixie/src/fixie-embed.tsx b/packages/fixie/src/fixie-embed.tsx deleted file mode 100644 index 49fd5a3eb..000000000 --- a/packages/fixie/src/fixie-embed.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; - -export interface FixieEmbedProps extends React.IframeHTMLAttributes { - /** - * The agent ID you want to embed a conversation with. - */ - agentId: string; - - /** - * If true, the agent will speak its messages out loud. - */ - speak?: boolean; - - /** - * If true, the UI will show debug information, such as which functions the agent is calling. - */ - debug?: boolean; - - /** - * If true, the iframe will be rendered in the DOM position where this component lives. - * - * If false, the iframe will be rendered floating on top of the content, with another iframe - * to be a launcher, à la Intercom. - */ - inline?: boolean; - - /** - * If true, the agent will send a greeting message when the conversation starts. To make this work, you'll want to - * either specify a hardcoded greeting message as part of the agent config, or update the agent system message to - * tell the agent how to start the conversation. - * - * If false, the agent will be silent until the user sends a message. - * - * Defaults to false. - */ - agentSendsGreeting?: boolean; - - /** - * Sets the title of the chat window. If you don't specify this, the agent's name will be used. - */ - chatTitle?: string; - - /** - * Set a primary color for the chat window. If you don't specify this, neutral colors will be used. You may wish - * to set this to be your primary brand color. - */ - primaryColor?: string; - - /** - * If you're not sure whether you need this, the answer is "no". - */ - fixieHost?: string; -} - -const defaultFixieHost = 'https://embed.fixie.ai'; - -/** - * A component to embed the Generic Fixie Chat UI on your page. - * - * Any extra props to this component are passed through to the `iframe`. - */ -export function InlineFixieEmbed({ - speak, - debug, - agentId, - fixieHost, - chatTitle, - primaryColor, - agentSendsGreeting, - ...iframeProps -}: FixieEmbedProps) { - return ( - - ); -} - -export function ControlledFloatingFixieEmbed({ - visible, - speak, - debug, - agentSendsGreeting, - agentId, - fixieHost, - chatTitle, - primaryColor, - ...iframeProps -}: FixieEmbedProps & { - /** - * If true, the Fixie chat UI will be visible. If false, it will be hidden. - */ - visible?: boolean; -}) { - const chatStyle = { - position: 'fixed', - bottom: `${10 + 10 + 48}px`, - right: '10px', - width: '400px', - height: '90%', - border: '1px solid #ccc', - zIndex: '999999', - display: visible ? 'block' : 'none', - boxShadow: '0px 5px 40px rgba(0, 0, 0, 0.16)', - borderRadius: '16px', - ...(iframeProps.style ?? {}), - } as const; - - return ( - <> - {createPortal( - // Something rotten is happening. When I build TS from this package, it throws a dep error, which is - // incorrect. When I build from Generic Sidekick Frontend, the types work, so having a ts-expect-error here - // causes a problem. I don't know why GSF is trying to rebuild the TS in the first place. - // This hacks around it. - // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error - // @ts-ignore - , - document.body - )} - - ); -} - -export function FloatingFixieEmbed({ fixieHost, ...restProps }: FixieEmbedProps) { - const launcherStyle = { - position: 'fixed', - bottom: '10px', - right: '10px', - width: '48px', - height: '48px', - borderRadius: '50%', - zIndex: '999999', - boxShadow: '0px 5px 40px rgba(0, 0, 0, 0.16)', - background: 'none', - border: 'none', - } as const; - - const launcherUrl = new URL('embed-launcher', fixieHost ?? defaultFixieHost); - if (restProps.primaryColor) { - launcherUrl.searchParams.set('primaryColor', restProps.primaryColor); - } - const launcherRef = useRef(null); - const [visible, setVisible] = useState(false); - - useEffect(() => { - const sidekickChannel = new MessageChannel(); - const launcherIFrame = launcherRef.current; - - if (launcherIFrame) { - launcherIFrame.addEventListener('load', function () { - if (launcherIFrame.contentWindow) { - launcherIFrame.contentWindow.postMessage('channel-message-port', '*', [sidekickChannel.port2]); - } - }); - - sidekickChannel.port1.onmessage = function (event) { - if (event.data === 'clicked launcher') { - setVisible((visible) => !visible); - } - }; - } - }, [fixieHost]); - - return ( - <> - {createPortal( - // Something rotten is happening. When I build TS from this package, it throws a dep error, which is - // incorrect. When I build from Generic Sidekick Frontend, the types work, so having a ts-expect-error here - // causes a problem. I don't know why GSF is trying to rebuild the TS in the first place. - // This hacks around it. - // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error - // @ts-ignore - <> - - - - , - document.body - )} - - ); -} - -export function getBaseIframeProps({ - speak, - debug, - agentSendsGreeting, - fixieHost, - agentId, - chatTitle, - primaryColor, -}: Pick< - FixieEmbedProps, - 'speak' | 'debug' | 'fixieHost' | 'agentId' | 'agentSendsGreeting' | 'chatTitle' | 'primaryColor' ->) { - const embedUrl = new URL( - agentId.includes('/') ? `/embed/${agentId}` : `/agents/${agentId}`, - fixieHost ?? defaultFixieHost - ); - if (speak) { - embedUrl.searchParams.set('speak', '1'); - } - if (debug) { - embedUrl.searchParams.set('debug', '1'); - } - if (agentSendsGreeting) { - embedUrl.searchParams.set('agentStartsConversation', '1'); - } - if (chatTitle) { - embedUrl.searchParams.set('chatTitle', chatTitle); - } - if (primaryColor) { - embedUrl.searchParams.set('primaryColor', primaryColor); - } - - return { - src: embedUrl.toString(), - allow: 'clipboard-write', - }; -} diff --git a/packages/fixie/src/index.ts b/packages/fixie/src/index.ts deleted file mode 100644 index b92add033..000000000 --- a/packages/fixie/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './client.js'; -export * from './fixie-embed.js'; -export * from './types.js'; diff --git a/packages/fixie/src/main.ts b/packages/fixie/src/main.ts deleted file mode 100644 index 4d5504c06..000000000 --- a/packages/fixie/src/main.ts +++ /dev/null @@ -1,811 +0,0 @@ -#!/usr/bin/env node - -/** - * This is a command-line tool to interact with the Fixie platform. - */ - -import { Command, Option, program } from 'commander'; -import fs from 'fs'; -import path from 'path'; -import terminal from 'terminal-kit'; -import { fileURLToPath } from 'url'; -import { FixieAgent } from './agent.js'; -import { AuthenticateOrLogIn, FIXIE_CONFIG_FILE, loadConfig } from './auth.js'; -import { FixieClientError } from './client.js'; - -const [major] = process.version - .slice(1) - .split('.') - .map((x) => parseInt(x)); -if (major < 18) { - console.error(`This CLI requires Node.js v18 or later. (Detected version ${process.version})`); - process.exit(1); -} - -const { terminal: term } = terminal; - -/** Pretty-print a result as JSON. */ -function showResult(result: any, raw: boolean) { - if (raw) { - console.log(JSON.stringify(result)); - } else { - term.green(JSON.stringify(result, null, 2)); - } -} - -/** Parse the provided value as a Date. */ -function parseDate(value: string): Date { - const parsedDate = new Date(value); - if (isNaN(parsedDate.getTime())) { - throw new Error('Invalid date format.'); - } - return parsedDate; -} - -/** Deploy an agent from the current directory. */ -function registerDeployCommand(command: Command) { - command - .command('deploy [path]') - .description('Deploy an agent') - .option( - '-e, --env ', - 'Environment variables to set for this deployment. Variables in a .env file take precedence over those on the command line.', - (v, m: Record | undefined) => { - const [key, value] = v.split('='); - return { - ...m, - // This condition is necessary; the types are wrong. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - [key]: value ?? '', - }; - } - ) - .action(async (path: string | undefined, options: { env: Record }) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - await FixieAgent.DeployAgent(client, path ?? process.cwd(), { - FIXIE_API_URL: program.opts().url, - ...options.env, - }); - }); -} - -/** Run an agent locally. */ -function registerServeCommand(command: Command) { - command - .command('serve [path]') - .description('Run an agent locally') - .option('-p, --port ', 'Port to run the agent on', '8181') - .option( - '-e, --env ', - 'Environment variables to set for this agent. Variables in a .env file take precedence over those on the command line.', - (v, m: Record | undefined) => { - const [key, value] = v.split('='); - return { - ...m, - // This condition is necessary; the types are wrong. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - [key]: value ?? '', - }; - } - ) - .action(async (path: string | undefined, options: { port: string; env: Record }) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - await FixieAgent.ServeAgent({ - client, - agentPath: path ?? process.cwd(), - port: parseInt(options.port), - tunnel: true, - environmentVariables: { - FIXIE_API_URL: program.opts().url, - ...options.env, - }, - }); - }); -} - -// Get current version of this package. -const currentPath = path.dirname(fileURLToPath(import.meta.url)); -const packageJsonPath = path.resolve(currentPath, path.join('..', 'package.json')); -const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); - -function errorHandler(error: any) { - if (error instanceof FixieClientError) { - // Error from a REST API call. - const url = error.url; - if (error.statusCode == 401) { - term('❌ Could not authenticate to the Fixie API at ').green(`${url}\n`); - if (process.env.FIXIE_API_URL) { - term('Your ').green('FIXIE_API_URL')(' is set to ').green(process.env.FIXIE_API_URL)('\n'); - term('Check to ensure that this is the correct API endpoint.\n'); - } - if (process.env.FIXIE_API_KEY) { - term('Your ').green('FIXIE_API_KEY')(' is set to ').green(process.env.FIXIE_API_KEY.slice(0, 12))('...\n'); - term('Check to ensure that this is the correct key.\n'); - } - } else if (error.statusCode == 400) { - term('❌ Client made bad request to ').green(`${url}\n`); - term('Please check that you are running the latest version using ').green('npx fixie@latest -V')('\n'); - term('The version of this CLI is: ').green(packageJson.version)('\n'); - } else if (error.statusCode == 403) { - term('❌ Forbidden: ').green(`${url}\n`); - } else if (error.statusCode == 404) { - term('❌ Not found: ').green(`${url}\n`); - } else { - term('❌ Error accessing Fixie API at ').green(url)(': ')(error.message)('\n'); - } - term.green(JSON.stringify(error.detail, null, 2)); - } else { - term('❌ Error: ')(error.message)('\n'); - term.red(error.stack)('\n'); - } -} - -function catchErrors(fn: (...args: any[]) => Promise) { - return async (...args: any[]) => { - try { - await fn(...args); - } catch (err) { - errorHandler(err); - } - }; -} - -program - .name('fixie') - .version(packageJson.version) - .description('A command-line client to the Fixie AI platform.') - .option('-u, --url ', 'URL of the Fixie API endpoint', process.env.FIXIE_API_URL ?? 'https://api.fixie.ai') - .option('-r --raw', 'Output raw JSON instead of pretty-printing.'); - -registerDeployCommand(program); -registerServeCommand(program); - -const user = program.command('user').description('User related commands'); - -user - .command('get') - .description('Get information on the current user') - .action( - catchErrors(async () => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.userInfo(); - showResult(result, program.opts().raw); - }) - ); - -user - .command('update') - .description('Update information on the current user') - .option('--email ', 'The new email address for this user') - .option('--fullName ', 'The new full name for this user') - .action( - catchErrors(async (opts) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.updateUser({ email: opts.email, fullName: opts.fullName }); - showResult(result, program.opts().raw); - }) - ); - -program - .command('auth') - .description('Authenticate to the Fixie service') - .option('--force', 'Force reauthentication.') - .option('--show-key', 'Show Fixie API key in full.') - .action( - catchErrors(async (options: { force?: boolean; showKey?: boolean }) => { - const client = await AuthenticateOrLogIn({ forceReauth: options.force ?? false }); - const userInfo = await client.userInfo(); - term('👤 You are logged into ').green(client.url)(' as ').green(userInfo.email)('\n'); - if (options.showKey) { - term('🔑 Your FIXIE_API_KEY is: ').red(client.apiKey)('\n'); - } else { - // Truncate the key. - term('🔑 Your FIXIE_API_KEY is: ').red(`${client.apiKey?.slice(0, 12)}...`)('\n'); - } - }) - ); - -const config = program.command('config').description('Configuration related commands'); -config - .command('show') - .description('Show current config.') - .action( - // eslint-disable-next-line - catchErrors(async () => { - const config = loadConfig(FIXIE_CONFIG_FILE); - showResult(config, program.opts().raw); - }) - ); - -const corpus = program.command('corpus').description('Corpus related commands'); -corpus.alias('corpora'); - -corpus - .command('list') - .description('List corpora.') - .option( - '--teamId ', - "The team ID to list corpora for. If unspecified, the current user's corpora will be listed." - ) - .option('--offset ', 'Start offset for results to return') - .option('--limit ', 'Limit on the number of results to return') - .action( - catchErrors(async (opts) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.listCorpora({ teamId: opts.teamId, offset: opts.offset, limit: opts.limit }); - showResult(result, program.opts().raw); - }) - ); - -corpus - .command('get ') - .description('Get information about a corpus.') - .action( - catchErrors(async (corpusId: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.getCorpus(corpusId); - showResult(result, program.opts().raw); - }) - ); - -corpus - .command('create') - .description('Create a corpus.') - .option('--name ', 'The display name for this corpus') - .option('--description ', 'The description for this corpus') - .option('--teamId ', 'The team ID to own the new Corpus. If unspecified, the current user will own it.') - .action( - catchErrors(async (opts) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.createCorpus({ - name: opts.name, - description: opts.description, - teamId: opts?.teamId, - }); - showResult(result, program.opts().raw); - }) - ); - -corpus - .command('update ') - .description('Update corpus metadata.') - .option('--name ', 'The new display name for this corpus') - .option('--description ', 'The new description for this corpus') - .action( - catchErrors(async (corpusId: string, opts) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.updateCorpus({ - corpusId, - displayName: opts.name ?? undefined, - description: opts.description ?? undefined, - }); - showResult(result, program.opts().raw); - }) - ); - -corpus - .command('delete ') - .description('Delete a corpus.') - .action( - catchErrors(async (corpusId: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.deleteCorpus({ corpusId }); - showResult(result, program.opts().raw); - }) - ); - -corpus - .command('query ') - .description('Query a given corpus.') - .action( - catchErrors(async (corpusId: string, query: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.queryCorpus({ corpusId, query }); - showResult(result, program.opts().raw); - }) - ); - -const source = corpus.command('source').description('Corpus source related commands'); -source.alias('sources'); - -source - .command('add ') - .description('Add a web source to a corpus.') - .option('--description ', 'A human-readable description for the source') - .option('--max-documents ', 'Maximum number of documents to crawl') - .option('--max-depth ', 'Maximum depth to crawl') - .option('--include-patterns ', 'URL patterns to include in the crawl') - .option('--exclude-patterns ', 'URL patterns to exclude from the crawl') - .action( - catchErrors( - async ( - corpusId: string, - startUrls: string[], - { - maxDocuments, - maxDepth, - includePatterns, - excludePatterns, - description, - }: { - maxDocuments?: number; - maxDepth?: number; - includePatterns?: string[]; - excludePatterns?: string[]; - description: string; - } - ) => { - if (!includePatterns) { - term.yellow('Warning: ')( - 'No --include-patterns specfied. This is equivalent to only crawling the URLs specified as startUrls.\n' - ); - term.yellow('Warning: ')('Use ').red("--include-patterns '*'")( - ' if you want to allow all URLs in the crawl.\n' - ); - } - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.addCorpusSource({ - corpusId, - startUrls, - includeGlobs: includePatterns, - excludeGlobs: excludePatterns, - maxDocuments, - maxDepth, - description, - }); - showResult(result, program.opts().raw); - } - ) - ); - -source - .command('upload ') - .description('Upload local files to a corpus.') - .action( - catchErrors(async (corpusId: string, mimeType: string, filenames: string[]) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.addCorpusFileSource({ - corpusId, - files: filenames.map((file) => ({ - filename: path.resolve(file), - contents: new Blob([fs.readFileSync(path.resolve(file))]), - mimeType, - })), - }); - showResult(result, program.opts().raw); - }) - ); - -source - .command('list ') - .description('List sources of a corpus.') - .option('--offset ', 'Start offset for results to return') - .option('--limit ', 'Limit on the number of results to return') - .action( - catchErrors(async (corpusId: string, opts) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.listCorpusSources({ corpusId, offset: opts.offset, limit: opts.limit }); - showResult(result, program.opts().raw); - }) - ); - -source - .command('get ') - .description('Get a source for a corpus.') - .action( - catchErrors(async (corpusId: string, sourceId: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.getCorpusSource({ corpusId, sourceId }); - showResult(result, program.opts().raw); - }) - ); - -source - .command('update ') - .description('Update source metadata.') - .option('--name ', 'The new display name for this source') - .option('--description ', 'The new description for this source') - .action( - catchErrors(async (corpusId: string, sourceId: string, opts) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.updateCorpusSource({ - corpusId, - sourceId, - displayName: opts.name ?? undefined, - description: opts.description ?? undefined, - }); - showResult(result, program.opts().raw); - }) - ); - -source - .command('delete ') - .description('Delete a source from a corpus. The source must have no running jobs or remaining documents.') - .action( - catchErrors(async (corpusId: string, sourceId: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.deleteCorpusSource({ corpusId, sourceId }); - showResult(result, program.opts().raw); - }) - ); - -source - .command('refresh ') - .description('Refresh a corpus source.') - .option( - '--force', - 'By default, this command will fail if you try to refresh a source that currently has a job running. If you want to refresh the source regardless, pass this flag.' - ) - .action( - catchErrors(async (corpusId: string, sourceId: string, { force }) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.refreshCorpusSource({ corpusId, sourceId, force }); - showResult(result, program.opts().raw); - }) - ); - -source - .command('clear ') - .description('Clear a corpus source.') - .option( - '--force', - 'By default, this command will fail if you try to clear a source that currently has a job running. If you want to clear the source regardless, pass this flag.' - ) - .action( - catchErrors(async (corpusId: string, sourceId: string, { force }) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.clearCorpusSource({ corpusId, sourceId, force }); - showResult(result, program.opts().raw); - }) - ); - -const job = source.command('job').description('Job-related commands'); -job.alias('jobs'); - -job - .command('list ') - .description('List jobs for a given source.') - .option('--offset ', 'Start offset for results to return') - .option('--limit ', 'Limit on the number of results to return') - .action( - catchErrors(async (corpusId: string, sourceId: string, opts) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.listCorpusSourceJobs({ corpusId, sourceId, offset: opts.offset, limit: opts.limit }); - showResult(result, program.opts().raw); - }) - ); - -job - .command('get ') - .description('Get a job for a source.') - .action( - catchErrors(async (corpusId: string, sourceId: string, jobId: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.getCorpusSourceJob({ corpusId, sourceId, jobId }); - showResult(result, program.opts().raw); - }) - ); - -const doc = source.command('doc').description('Document-related commands'); -doc.alias('docs'); - -doc - .command('list ') - .description('List documents for a given corpus source.') - .option('--offset ', 'Start offset for results to return') - .option('--limit ', 'Limit on the number of results to return') - .action( - catchErrors(async (corpusId: string, sourceId: string, opts) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.listCorpusSourceDocuments({ - corpusId, - sourceId, - offset: opts.offset, - limit: opts.limit, - }); - showResult(result, program.opts().raw); - }) - ); - -doc - .command('get ') - .description('Get a document from a corpus source.') - .action( - catchErrors(async (corpusId: string, sourceId: string, documentId: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.getCorpusSourceDocument({ corpusId, sourceId, documentId }); - showResult(result, program.opts().raw); - }) - ); - -const agent = program.command('agent').description('Agent related commands'); -agent.alias('agents'); - -agent - .command('list') - .description('List all agents.') - .option('--teamId ', 'The team ID to list agents for. If unspecified, the current user will be used.') - .action( - catchErrors(async () => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await FixieAgent.ListAgents(client); - showResult(await Promise.all(result.map((agent) => agent.metadata)), program.opts().raw); - }) - ); - -agent - .command('get ') - .description('Get information about the given agent.') - .action( - catchErrors(async (agentId: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - try { - const result = await FixieAgent.GetAgent({ client, agentId }); - showResult(result.metadata, program.opts().raw); - } catch (e) { - // Try again with the agent handle. - const result = await FixieAgent.GetAgent({ client, handle: agentId }); - showResult(result.metadata, program.opts().raw); - } - }) - ); - -agent - .command('delete ') - .description('Delete the given agent.') - .action( - catchErrors(async (agentHandle: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const agent = await FixieAgent.GetAgent({ client, handle: agentHandle }); - const result = agent.delete(); - showResult(result, program.opts().raw); - }) - ); - -agent - .command('publish ') - .description('Publish the given agent.') - .action( - catchErrors(async (agentHandle: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const agent = await FixieAgent.GetAgent({ client, handle: agentHandle }); - const result = agent.update({ published: true }); - showResult(result, program.opts().raw); - }) - ); - -agent - .command('unpublish ') - .description('Unpublish the given agent.') - .action( - catchErrors(async (agentHandle: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const agent = await FixieAgent.GetAgent({ client, handle: agentHandle }); - const result = agent.update({ published: false }); - showResult(result, program.opts().raw); - }) - ); - -agent - .command('create ') - .description('Create an agent.') - .option('--name ', 'Agent name') - .option('--description ', 'Agent description') - .option('--url ', 'More info URL for agent') - .option('--teamId ', 'Team ID to own the new agent. If not specified, the current user will own it.') - .action( - catchErrors(async (agentHandle: string, opts) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await FixieAgent.CreateAgent({ - client, - handle: agentHandle, - teamId: opts.teamId, - name: opts.name, - description: opts.description, - moreInfoUrl: opts.url, - }); - showResult(result.metadata, program.opts().raw); - }) - ); - -agent - .command('logs ') - .description('Fetch agent logs.') - .option('--start ', 'Start date', parseDate) - .option('--end ', 'End date', parseDate) - .option('--limit ', 'Max number of results to return') - .option('--offset ', 'Starting offset of results to return') - .option('--minSeverity ', 'Minimum log severity level') - .option('--conversation ', 'Conversation ID of logs to return') - .option('--message ', 'Message ID of logs to return') - .action( - catchErrors(async (agentId: string, opts) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await FixieAgent.GetAgent({ client, agentId }); - showResult( - await result.getLogs({ - start: opts.start, - end: opts.end, - limit: opts.limit, - offset: opts.offset, - minSeverity: opts.minSeverity, - conversationId: opts.conversation, - messageId: opts.message, - }), - program.opts().raw - ); - }) - ); - -registerDeployCommand(agent); -registerServeCommand(agent); - -const revision = agent.command('revision').description('Agent revision-related commands'); -revision.alias('revisions'); - -revision - .command('list ') - .description('List all revisions for the given agent.') - .action( - catchErrors(async (agentId: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = (await FixieAgent.GetAgent({ client, agentId })).metadata.allRevisions; - showResult(result, program.opts().raw); - }) - ); - -revision - .command('get ') - .description('Get current revision for the given agent.') - .action( - catchErrors(async (agentId: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const agent = await FixieAgent.GetAgent({ client, agentId }); - const result = await agent.getCurrentRevision(); - showResult(result, program.opts().raw); - }) - ); - -revision - .command('set ') - .description('Set the current revision for the given agent.') - .action( - catchErrors(async (agentId: string, revisionId: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const agent = await FixieAgent.GetAgent({ client, agentId }); - const result = await agent.setCurrentRevision(revisionId); - showResult(result, program.opts().raw); - }) - ); - -revision - .command('delete ') - .description('Delete the given revision for the given agent.') - .action( - catchErrors(async (agentId: string, revisionId: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const agent = await FixieAgent.GetAgent({ client, agentId }); - const result = await agent.deleteRevision(revisionId); - showResult(result, program.opts().raw); - }) - ); - -const team = program.command('team').description('Team related commands'); -team.alias('teams'); - -team - .command('list') - .description('List teams') - .option('--offset ', 'Start offset for results to return') - .option('--limit ', 'Limit on the number of results to return') - .action( - catchErrors(async (opts) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.listTeams({ offset: opts.offset, limit: opts.limit }); - showResult(result, program.opts().raw); - }) - ); - -team - .command('get ') - .description('Get information about a team') - .action( - catchErrors(async (teamId: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.getTeam({ teamId }); - showResult(result, program.opts().raw); - }) - ); - -team - .command('delete ') - .description('Delete the given team') - .action( - catchErrors(async (teamId: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.deleteTeam({ teamId }); - showResult(result, program.opts().raw); - }) - ); - -team - .command('invite ') - .description('Invite a new member to a team') - .option('--admin', 'Invite the new member as a team admin') - .action( - catchErrors(async (teamId: string, email: string, opts) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.inviteTeamMember({ - teamId, - email, - isAdmin: opts.admin, - }); - showResult(result, program.opts().raw); - }) - ); - -team - .command('uninvite ') - .description('Cancel a pending invitation for a team membership') - .action( - catchErrors(async (teamId: string, email: string) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.cancelInvitation({ - teamId, - email, - }); - showResult(result, program.opts().raw); - }) - ); - -team - .command('remove ') - .description('Remove a member from a team') - .action( - catchErrors(async (teamId: string, userId: string, opts) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.removeTeamMember({ - teamId, - userId, - }); - showResult(result, program.opts().raw); - }) - ); - -team - .command('update ') - .description('Set or clear admin role for a member of a team') - .option('--admin', 'Set member as team admin') - .option('--no-admin', 'Unset member as team admin') - .action( - catchErrors(async (teamId: string, userId: string, opts) => { - if (opts.admin === undefined) { - throw new Error('Must specify --admin or --no-admin'); - } - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.updateTeamMember({ - teamId, - userId, - isAdmin: opts.admin ?? false, - }); - showResult(result, program.opts().raw); - }) - ); - -team - .command('create') - .description('Create a new team') - .option('--name ', 'The name of the team to create') - .option('--description ', 'The description for this team') - .action( - catchErrors(async (opts) => { - const client = await AuthenticateOrLogIn({ apiUrl: program.opts().url }); - const result = await client.createTeam({ - displayName: opts.name, - description: opts.description, - }); - showResult(result, program.opts().raw); - }) - ); - -program.parse(process.argv); diff --git a/packages/fixie/src/types.ts b/packages/fixie/src/types.ts deleted file mode 100644 index d305872e6..000000000 --- a/packages/fixie/src/types.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** This file defines types exposed by the Fixie service API. */ - -// TODO: Autogenerate this from our proto or OpenAPI specs. - -import { Jsonifiable } from 'type-fest'; - -/** Represents metadata about the currently logged-in user. */ -export interface User { - userId: string; - email: string; - fullName?: string; - avatarUrl?: string; - created: Date; - modified: Date; - apiToken?: string; - lastLogin: Date; -} - -/** Represents a user's role on a team. */ -export interface MembershipRole { - isAdmin: boolean; -} - -/** Represents a user's membership on a team. */ -export interface Membership { - teamId: string; - user: User; - role: MembershipRole; - pending: boolean; - created: Date; - modified: Date; -} - -/** Represents a team. */ -export interface Team { - teamId: string; - displayName?: string; - description?: string; - avatarUrl?: string; - members: Membership[]; - created: Date; - modified: Date; -} - -/** Represents a pending invitation for a user to join a team. */ -export interface Invitation { - inviteCode: string; - sender: string; - email: string; - teamName: string; - role: MembershipRole; - created: Date; -} - -/** Represents an agent ID. */ -export type AgentId = string; - -/** Represents a conversation ID. */ -export type ConversationId = string; - -/** Represents a Metadata field. */ -export type Metadata = Record; - -export interface BaseConversationTurn { - role: Role; - timestamp: string; - id: string; - - /** Any metadata the client or server would like to attach to the message. - For instance, the client might include UI state from the host app, - or the server might include debugging info. - */ - metadata?: Jsonifiable; - - state: State; -} - -export interface Conversation { - id: ConversationId; - turns: ConversationTurn[]; -} - -export interface UserOrAssistantConversationTurn extends BaseConversationTurn { - messages: Message[]; -} - -/** - * Whether the message is being generated, complete, or resulted in an error. - * - * When the user is typing or the AI is generating tokens, this will be 'in-progress'. - * - * If the backend produces an error while trying to make a response, this will be an Error object. - * - * If the user requests that the AI stop generating a message, the state will be 'stopped'. - */ -type State = 'in-progress' | 'done' | 'stopped' | 'error'; -export interface StateFields { - state: State; - errorDetail?: string; -} - -export interface AssistantConversationTurn extends UserOrAssistantConversationTurn<'assistant'>, StateFields { - /** - * The user turn that this turn was a reply to. - */ - inReplyToId?: string; -} - -export interface UserConversationTurn extends UserOrAssistantConversationTurn<'user'> {} - -export type ConversationTurn = AssistantConversationTurn | UserConversationTurn; - -/** A message in the conversation. */ -export interface BaseMessage { - /** Any metadata the client or server would like to attach to the message. - For instance, the client might include UI state from the host app, - or the server might include debugging info. - */ - metadata?: Jsonifiable; -} - -export interface FunctionCall extends BaseMessage { - kind: 'functionCall'; - name?: string; - args?: Record; -} - -export interface FunctionResponse extends BaseMessage { - kind: 'functionResponse'; - name: string; - response: string; - failed?: boolean; -} - -export interface TextMessage extends BaseMessage { - kind: 'text'; - /** The text content of the message. */ - content: string; -} - -export type Message = FunctionCall | FunctionResponse | TextMessage; diff --git a/packages/fixie/src/use-fixie.ts b/packages/fixie/src/use-fixie.ts deleted file mode 100644 index 25e0f320a..000000000 --- a/packages/fixie/src/use-fixie.ts +++ /dev/null @@ -1,632 +0,0 @@ -import { useState, SetStateAction, Dispatch, useEffect, useRef } from 'react'; -import { AgentId, AssistantConversationTurn, TextMessage, ConversationId, Conversation } from './types.js'; -import { FixieClient } from './client.js'; - -/** - * The result of the useFixie hook. - */ -export interface UseFixieResult { - /** - * The conversation that is currently being managed. - */ - conversation: Conversation | undefined; - - /** - * A value that indicates whether the initial conversation (if any) has loaded. - * This is _not_ an indicator of whether the LLM is currently generating a response. - */ - loadState: 'loading' | 'loaded' | 'error'; - - /** - * Regenerate the most recent model response. Only has an effect if the most recent response is not in progress. - * - * Returns true if the most recent response was not in progress, false otherwise. - */ - regenerate: () => boolean; - - /** - * Request a stop of the current model response. Only has an effect if the most recent response is in progress. - * - * Returns true if the most recent response was in progress, false otherwise. - */ - stop: () => boolean; - - /** - * Append `message` to the conversation. Only sends a message if the model is not currently generating a response. - * - * Returns true if the message was sent, false otherwise. - */ - sendMessage: (message?: string) => boolean; - - /** - * Starts a new conversation. - */ - newConversation: () => void; - - /** - * If the loadState is `"error"`, contains additional details about the error. - */ - error: any; -} - -/** - * Arguments passed to the useFixie hook. - */ -export interface UseFixieArgs { - /** - * The agent UUID to use. - */ - agentId: AgentId; - - /** - * The ID of the conversation to use. - */ - conversationId?: ConversationId; - - /** - * If true, the agent will send the first message in conversations. - */ - agentStartsConversation?: boolean; - - /** - * A function that will be called whenever the model generates new text. - * - * If the model generates a sentence like "I am a brown dog", this function may be called with: - * - * onNewTokens("I am") - * onNewTokens("a") - * onNewTokens("brown dog") - */ - onNewTokens?: (tokens: string) => void; - - /** - * A function that will be called whenever the conversation ID changes. - */ - onNewConversation?: (conversationId?: ConversationId) => void; - - /** - * An optional URL to use for the Fixie API instead of the default. - */ - fixieApiUrl?: string; -} - -/** - * A hook that fires the `onNewTokens` callback whenever text is generated. - */ -function useTokenNotifications(conversation: Conversation | undefined, onNewTokens: UseFixieArgs['onNewTokens']) { - const conversationRef = useRef(conversation); - - useEffect(() => { - if ( - !conversation || - !onNewTokens || - !conversationRef.current || - conversation === conversationRef.current || - conversationRef.current.id !== conversation.id - ) { - // Only fire notifications when we observe a change within the same conversation. - conversationRef.current = conversation; - return; - } - - const lastTurn = conversation.turns.at(-1); - if (!lastTurn || lastTurn.role !== 'assistant') { - conversationRef.current = conversation; - return; - } - - const lastTurnText = lastTurn.messages - .filter((m) => m.kind === 'text') - .map((m) => (m as TextMessage).content) - .join(''); - - const previousLastTurn = conversationRef.current.turns.at(-1); - const previousLastTurnText = - previousLastTurn?.id !== lastTurn.id - ? '' - : previousLastTurn.messages - .filter((m) => m.kind === 'text') - .map((m) => (m as TextMessage).content) - .join(''); - - // Find the longest matching prefix. - let i = 0; - while (i < lastTurnText.length && i < previousLastTurnText.length && lastTurnText[i] === previousLastTurnText[i]) { - i++; - } - const newTokens = lastTurnText.slice(i); - if (newTokens.length > 0) { - onNewTokens(newTokens); - } - conversationRef.current = conversation; - }, [conversation, onNewTokens]); -} - -/** - * A hook that fires the `onNewConversation` callback whenever the conversation ID changes. - */ -function useNewConversationNotfications( - conversation: Conversation | undefined, - onNewConversation: UseFixieArgs['onNewConversation'] -) { - const conversationIdRef = useRef(conversation?.id); - - useEffect(() => { - if (conversation?.id !== conversationIdRef.current) { - onNewConversation?.(conversation?.id); - } - conversationIdRef.current = conversation?.id; - }, [conversation, onNewConversation]); -} - -/** - * A hook that polls the Fixie API for updates to the conversation. - */ -function useConversationPoller( - fixieApiUrl: string | undefined, - agentId: string, - conversation: Conversation | undefined, - setConversation: Dispatch>, - isStreamingFromApi: boolean -) { - const conversationId = conversation?.id; - const anyTurnInProgress = Boolean(conversation?.turns.find((t) => t.state === 'in-progress')); - const [isVisible, setIsVisible] = useState(true); - const delay = isVisible && anyTurnInProgress ? 100 : isVisible ? 1000 : 60000; - - useEffect(() => { - function handleVisibilityChange() { - setIsVisible(document.visibilityState === 'visible'); - } - - setIsVisible(document.visibilityState === 'visible'); - document.addEventListener('visibilitychange', handleVisibilityChange); - - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, []); - - useEffect(() => { - if (conversationId === undefined || isStreamingFromApi) { - return; - } - - let abandoned = false; - let timeout: ReturnType; - - const updateConversation = () => - new FixieClient({ url: fixieApiUrl }).getConversation({ agentId, conversationId }).then((newConversation) => { - setConversation((existing) => { - if ( - abandoned || - !existing || - existing.id !== newConversation.id || - JSON.stringify(existing) === JSON.stringify(newConversation) - ) { - return existing; - } - - return newConversation; - }); - - if (!abandoned) { - timeout = setTimeout(updateConversation, delay); - } - }); - - timeout = setTimeout(updateConversation, delay); - return () => { - abandoned = true; - clearTimeout(timeout); - }; - }, [fixieApiUrl, agentId, conversationId, setConversation, isStreamingFromApi, delay]); -} - -/** - * A hook that manages mutations to the conversation. - */ -function useConversationMutations( - fixieApiUrl: string | undefined, - agentId: string, - conversation: Conversation | undefined, - setConversation: Dispatch>, - onError: (type: 'newConversation' | 'send' | 'regenerate' | 'stop', error: any) => void -): { - sendMessage: (message?: string) => boolean; - regenerate: (messageId?: string) => boolean; - stop: (messageId?: string) => boolean; - isStreamingFromApi: boolean; -} { - // Track in-progress requests. - const nextRequestId = useRef(0); - const [activeRequests, setActiveRequests] = useState>({}); - const startRequest = () => { - const requestId = nextRequestId.current++; - setActiveRequests((existing) => ({ ...existing, [requestId]: true })); - return { - requestId, - endRequest: () => { - setActiveRequests((existing) => { - if (!(requestId in existing)) { - return existing; - } - - const { [requestId]: _, ...rest } = existing; - return rest; - }); - }, - }; - }; - - // If stop/regenerate are triggered referencing an optimistic ID, we'll queue them up and handle them when the - // optimistic ID can resolve to the real one. - const [localIdMap, setLocalIdMap] = useState>({}); - const [pendingRequests, setPendingRequests] = useState< - { type: 'stop' | 'regenerate'; conversationId: string; localMessageId: string }[] - >([]); - const setLocalId = (localId: string, remoteId: string) => { - setLocalIdMap((existing) => (localId in existing ? existing : { ...existing, [localId]: remoteId })); - }; - useEffect(() => { - if (pendingRequests.length === 0) { - return; - } - - const nextPendingRequest = pendingRequests[0]; - if (nextPendingRequest.conversationId !== conversation?.id) { - setPendingRequests((existing) => existing.slice(1)); - return; - } - - if (nextPendingRequest.localMessageId in localIdMap) { - const action = nextPendingRequest.type === 'regenerate' ? regenerate : stop; - action(localIdMap[nextPendingRequest.localMessageId]); - setPendingRequests((existing) => existing.slice(1)); - } - }, [pendingRequests, localIdMap, conversation?.id, regenerate, stop]); - - const client = new FixieClient({ url: fixieApiUrl }); - - async function handleTurnStream( - stream: ReadableStream, - optimisticUserTurnId: string, - optimisticAssistantTurnId: string, - endRequest: () => void - ) { - const reader = stream.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - setLocalId(optimisticAssistantTurnId, value.id); - if (value.inReplyToId) { - setLocalId(optimisticUserTurnId, value.inReplyToId); - } - - setConversation((existingConversation) => { - // If the conversation ID has changed in the meantime, ignore it. - if (!existingConversation || !conversation || existingConversation.id !== conversation.id) { - endRequest(); - return existingConversation; - } - - return { - ...existingConversation, - turns: existingConversation.turns.map((t) => { - if ( - (t.id === value.id || t.id === optimisticAssistantTurnId) && - (t.state === 'in-progress' || value.state !== 'in-progress') - ) { - return value; - } - - if (t.id === optimisticUserTurnId && value.inReplyToId) { - // We have the actual ID now. - return { - ...t, - id: value.inReplyToId, - }; - } - - return t; - }), - }; - }); - } - } - - function sendMessage(message?: string) { - if (!conversation) { - // Start a new conversation. - const { endRequest } = startRequest(); - client - .startConversation({ agentId, message }) - .then(async (newConversationStream) => { - const reader = newConversationStream.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - // If the conversation ID has changed in the meantime, ignore the update. - setConversation((existing) => { - if (existing && existing.id !== value.id) { - endRequest(); - return existing; - } - return value; - }); - } - }) - .catch((e) => onError('newConversation', e)) - .finally(endRequest); - - return true; - } - - // Send a message to the existing conversation. - if (conversation.turns.find((t) => t.state === 'in-progress')) { - // Can't send a message if the model is already generating a response. - return false; - } - - if (message === undefined) { - return false; - } - - const { requestId, endRequest } = startRequest(); - const optimisticUserTurnId = `local-user-${requestId}`; - const optimisticAssistantTurnId = `local-assistant-${requestId}`; - client - .sendMessage({ agentId, conversationId: conversation.id, message }) - .then((stream) => handleTurnStream(stream, optimisticUserTurnId, optimisticAssistantTurnId, endRequest)) - .catch((e) => onError('send', e)) - .finally(endRequest); - - setConversation((existingConversation) => { - if ( - !existingConversation || - existingConversation.id !== conversation.id || - existingConversation.turns.find((t) => t.state === 'in-progress') - ) { - endRequest(); - return existingConversation; - } - - // Do an optimistic update. - return { - ...existingConversation, - turns: [ - ...existingConversation.turns, - { - id: optimisticUserTurnId, - role: 'user', - state: 'done', - timestamp: new Date().toISOString(), - messages: [{ kind: 'text', content: message }], - }, - { - id: optimisticAssistantTurnId, - role: 'assistant', - state: 'in-progress', - timestamp: new Date().toISOString(), - inReplyToId: optimisticUserTurnId, - messages: [], - }, - ], - }; - }); - - return true; - } - - function regenerate(messageId: string | undefined = conversation?.turns.at(-1)?.id) { - const lastTurn = conversation?.turns.at(-1); - if ( - conversation === undefined || - lastTurn === undefined || - lastTurn.role !== 'assistant' || - lastTurn.state === 'in-progress' || - lastTurn.id !== messageId - ) { - return false; - } - - if (lastTurn.id.startsWith('local-')) { - setPendingRequests((existing) => [ - ...existing, - { type: 'regenerate', conversationId: conversation.id, localMessageId: messageId }, - ]); - return true; - } - - const { requestId, endRequest } = startRequest(); - const optimisticUserTurnId = `local-user-${requestId}`; - const optimisticAssistantTurnId = `local-assistant-${requestId}`; - client - .regenerate({ agentId, conversationId: conversation.id, messageId }) - .then((stream) => handleTurnStream(stream, optimisticUserTurnId, optimisticAssistantTurnId, endRequest)) - .catch((e) => onError('regenerate', e)) - .finally(endRequest); - - // Do an optimistic update. - setConversation((existingConversation) => { - const lastTurn = existingConversation?.turns.at(-1); - if ( - !existingConversation || - existingConversation.id !== conversation.id || - existingConversation.turns.length === 0 || - !lastTurn || - lastTurn.role !== 'assistant' || - lastTurn.id !== messageId - ) { - endRequest(); - return existingConversation; - } - - return { - ...existingConversation, - turns: [ - ...existingConversation.turns.slice(0, -1), - { - id: optimisticAssistantTurnId, - role: 'assistant', - state: 'in-progress', - timestamp: new Date().toISOString(), - inReplyToId: lastTurn.inReplyToId, - messages: [], - }, - ], - }; - }); - - return true; - } - - function stop(messageId: string | undefined = conversation?.turns.at(-1)?.id) { - const lastTurn = conversation?.turns.at(-1); - if ( - conversation === undefined || - lastTurn === undefined || - lastTurn.state !== 'in-progress' || - lastTurn.id !== messageId - ) { - return false; - } - - if (lastTurn.id.startsWith('local-')) { - setPendingRequests((existing) => [ - ...existing, - { type: 'stop', conversationId: conversation.id, localMessageId: messageId }, - ]); - return true; - } - - const { endRequest } = startRequest(); - client - .stopGeneration({ agentId, conversationId: conversation.id, messageId: lastTurn.id }) - .catch((e) => onError('stop', e)) - .finally(endRequest); - - setConversation((existingConversation) => { - if (existingConversation?.id !== conversation.id || existingConversation.turns.at(-1)?.id !== messageId) { - endRequest(); - return existingConversation; - } - - return { - ...existingConversation, - turns: existingConversation.turns.map((t) => - t.id === lastTurn.id && t.state === 'in-progress' ? { ...t, state: 'stopped' } : t - ), - }; - }); - - return true; - } - - return { - isStreamingFromApi: Object.keys(activeRequests).length > 0, - sendMessage, - regenerate, - stop, - }; -} - -/** - * @experimental this API may change at any time. - * - * This hook manages the state of a Fixie-hosted conversation. - */ -export function useFixie({ - conversationId: userProvidedConversationId, - onNewTokens, - agentId, - fixieApiUrl: fixieAPIUrl, - onNewConversation, - agentStartsConversation, -}: UseFixieArgs): UseFixieResult { - const [loadState, setLoadState] = useState('loading'); - const [loadError, setLoadError] = useState(undefined); - const [conversation, setConversation] = useState(); - - function reset() { - setLoadState('loading'); - setLoadError(undefined); - setConversation(undefined); - } - - // If the agent ID changes, reset everything. - useEffect(() => reset(), [agentId, fixieAPIUrl]); - - const { sendMessage, regenerate, stop, isStreamingFromApi } = useConversationMutations( - fixieAPIUrl, - agentId, - conversation, - setConversation, - (type, e) => { - if (type === 'newConversation') { - setLoadState('error'); - setLoadError(e); - } - } - ); - - useConversationPoller(fixieAPIUrl, agentId, conversation, setConversation, isStreamingFromApi); - useTokenNotifications(conversation, onNewTokens); - useNewConversationNotfications(conversation, onNewConversation); - - // Do the initial load if the user passed a conversation ID. - useEffect(() => { - if (loadState === 'error') { - return; - } - if (!userProvidedConversationId || userProvidedConversationId === conversation?.id) { - setLoadState('loaded'); - return; - } - - let abandoned = false; - setLoadState('loading'); - new FixieClient({ url: fixieAPIUrl }) - .getConversation({ agentId, conversationId: userProvidedConversationId }) - .then((conversation) => { - if (!abandoned) { - onNewConversation?.(conversation.id); - setConversation(conversation); - setLoadState('loaded'); - } - }) - .catch((error) => { - if (!abandoned) { - setLoadState('error'); - setLoadError(error); - } - }); - - return () => { - abandoned = true; - }; - }, [fixieAPIUrl, agentId, userProvidedConversationId, conversation?.id, loadState]); - - // If the agent should start the conversation, do it. - useEffect(() => { - if (agentStartsConversation && loadState === 'loaded' && conversation === undefined && !isStreamingFromApi) { - sendMessage(); - } - }, [agentStartsConversation, loadState, conversation === undefined, isStreamingFromApi, sendMessage]); - - return { - conversation, - loadState, - error: loadError, - stop, - regenerate, - sendMessage, - newConversation: reset, - }; -} diff --git a/packages/fixie/tsconfig.json b/packages/fixie/tsconfig.json deleted file mode 100644 index ca48c4029..000000000 --- a/packages/fixie/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "@tsconfig/node18/tsconfig.json", - "compilerOptions": { - "strict": true, - "noEmitOnError": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true, - "module": "esnext", - "moduleResolution": "node16", - "jsx": "react", - "esModuleInterop": true, - "outDir": ".", - "declaration": true, - "lib": ["dom", "dom.iterable", "ES2022"] - }, - "include": ["src/*", "jest.config.ts", ".eslintrc.cjs", "index.ts", "web.ts"], - "exclude": [] -} diff --git a/packages/fixie/turbo.json b/packages/fixie/turbo.json deleted file mode 100644 index c72e22fec..000000000 --- a/packages/fixie/turbo.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": ["//"], - "$schema": "https://turbo.build/schema.json", - "pipeline": { - "build": { - "outputs": ["*.js", "*.d.ts", "src/**/*.js", "src/**/*.d.ts"] - } - } -} diff --git a/packages/fixie/web.ts b/packages/fixie/web.ts deleted file mode 100644 index ebb0cbcb6..000000000 --- a/packages/fixie/web.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { FixieClient } from './src/client.js'; -export * from './src/types.js'; -export { - InlineFixieEmbed, - FloatingFixieEmbed, - ControlledFloatingFixieEmbed, - getBaseIframeProps, -} from './src/fixie-embed.js'; -export * from './src/use-fixie.js'; diff --git a/packages/voice/.gitignore b/packages/voice/.gitignore deleted file mode 100644 index 8f322f0d8..000000000 --- a/packages/voice/.gitignore +++ /dev/null @@ -1,35 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts diff --git a/packages/voice/README.md b/packages/voice/README.md deleted file mode 100644 index 1a60afbec..000000000 --- a/packages/voice/README.md +++ /dev/null @@ -1 +0,0 @@ -This is a starter template for [Learn Next.js](https://nextjs.org/learn). diff --git a/packages/voice/next.config.js b/packages/voice/next.config.js deleted file mode 100644 index e9269c056..000000000 --- a/packages/voice/next.config.js +++ /dev/null @@ -1,17 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - async redirects() { - return [ - { - source: '/agent', - destination: '/', - permanent: false, - }, - ]; - }, - experimental: { - serverComponentsExternalPackages: ['playht', '@deepgram/sdk', '@soniox/soniox-node'], - }, -}; - -module.exports = nextConfig; diff --git a/packages/voice/package.json b/packages/voice/package.json deleted file mode 100644 index 06082ed6b..000000000 --- a/packages/voice/package.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name": "voice", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "test": "tsc -p .", - "start": "next start", - "lint": "next lint --max-warnings 0", - "lint:fix": "yarn lint --fix" - }, - "dependencies": { - "@deepgram/sdk": "^2.4.0", - "@headlessui/react": "^1.7.15", - "@heroicons/react": "^2.0.18", - "@mdx-js/mdx": "^2.3.0", - "@mdx-js/react": "^2.3.0", - "@octokit/graphql": "^5.0.6", - "@soniox/soniox-node": "^1.2.2", - "@tailwindcss/forms": "^0.5.3", - "@vercel/analytics": "^1.1.1", - "ai": "^2.1.8", - "ai-jsx": "workspace:*", - "autoprefixer": "10.4.14", - "aws4fetch": "^1.0.17", - "classnames": "^2.3.2", - "eslint": "8.42.0", - "eslint-config-next": "^14.0.1", - "livekit-client": "^1.14.4", - "lodash": "^4.17.21", - "next": "^14.0.1", - "playht": "^0.9.0-beta.7", - "postcss": "8.4.24", - "react": "18.2.0", - "react-dom": "18.2.0", - "react-swipeable": "^7.0.1", - "remark-gfm": "^3.0.1", - "tailwindcss": "3.3.2", - "typescript": "^5.1.3", - "word-error-rate": "^0.0.7" - }, - "volta": { - "extends": "../../package.json" - }, - "devDependencies": { - "@babel/core": "^7.22.5", - "@babel/plugin-transform-react-jsx": "^7.22.5", - "@next/eslint-plugin-next": "^14.0.1", - "@types/aws4": "^1.11.3", - "@types/lodash": "^4.14.195", - "@types/node": "20.2.5", - "@types/react": "18.2.8", - "@types/react-dom": "^18.2.7", - "@typescript-eslint/eslint-plugin": "^5.59.9", - "@typescript-eslint/parser": "^5.59.9", - "eslint-config-nth": "^2.0.1" - } -} diff --git a/packages/voice/postcss.config.js b/packages/voice/postcss.config.js deleted file mode 100644 index 12a703d90..000000000 --- a/packages/voice/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/packages/voice/public/agents/ai-friend.webp b/packages/voice/public/agents/ai-friend.webp deleted file mode 100644 index a9b72b4bc..000000000 Binary files a/packages/voice/public/agents/ai-friend.webp and /dev/null differ diff --git a/packages/voice/public/agents/dr-donut.webp b/packages/voice/public/agents/dr-donut.webp deleted file mode 100644 index 8fc3d458a..000000000 Binary files a/packages/voice/public/agents/dr-donut.webp and /dev/null differ diff --git a/packages/voice/public/agents/fixie.webp b/packages/voice/public/agents/fixie.webp deleted file mode 100644 index ed0b8676e..000000000 Binary files a/packages/voice/public/agents/fixie.webp and /dev/null differ diff --git a/packages/voice/public/agents/rubber-duck.webp b/packages/voice/public/agents/rubber-duck.webp deleted file mode 100644 index 918268c47..000000000 Binary files a/packages/voice/public/agents/rubber-duck.webp and /dev/null differ diff --git a/packages/voice/public/agents/spanish-tutor.webp b/packages/voice/public/agents/spanish-tutor.webp deleted file mode 100644 index dff06df14..000000000 Binary files a/packages/voice/public/agents/spanish-tutor.webp and /dev/null differ diff --git a/packages/voice/public/audio/harvard01.m4a b/packages/voice/public/audio/harvard01.m4a deleted file mode 100644 index 619497539..000000000 Binary files a/packages/voice/public/audio/harvard01.m4a and /dev/null differ diff --git a/packages/voice/public/favicon.ico b/packages/voice/public/favicon.ico deleted file mode 100644 index 4965832f2..000000000 Binary files a/packages/voice/public/favicon.ico and /dev/null differ diff --git a/packages/voice/public/vercel.svg b/packages/voice/public/vercel.svg deleted file mode 100644 index fbf0e25a6..000000000 --- a/packages/voice/public/vercel.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - \ No newline at end of file diff --git a/packages/voice/public/voice-logo.png b/packages/voice/public/voice-logo.png deleted file mode 100644 index 63765eab4..000000000 Binary files a/packages/voice/public/voice-logo.png and /dev/null differ diff --git a/packages/voice/public/voice-logo.svg b/packages/voice/public/voice-logo.svg deleted file mode 100644 index a44833ba4..000000000 --- a/packages/voice/public/voice-logo.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/packages/voice/src/app/agent/agents.tsx b/packages/voice/src/app/agent/agents.tsx deleted file mode 100644 index 1af57330f..000000000 --- a/packages/voice/src/app/agent/agents.tsx +++ /dev/null @@ -1,163 +0,0 @@ -export interface AgentConfig { - id: string; - prompt: string; - initialResponses: string[]; - corpusId?: string; - ttsVoice?: string; -} - -const VOICE_PROMPT = ` -The user is talking to you over voice on their phone, and your response will be read out loud with realistic text-to-speech (TTS) technology. - -Follow every direction here when crafting your response: - -1. Use natural, conversational language that are clear and easy to follow (short sentences, simple words). -1a. Be concise and relevant: Most of your responses should be a sentence or two, unless you're asked to go deeper. Don't monopolize the conversation. -1b. Use discourse markers to ease comprehension. Never use the list format. - -2. Keep the conversation flowing. -2a. Clarify: when there is ambiguity, ask clarifying questions, rather than make assumptions. -2b. Don't implicitly or explicitly try to end the chat (i.e. do not end a response with "Talk soon!", or "Enjoy!"). -2c. Sometimes the user might just want to chat. Ask them relevant follow-up questions. -2d. Don't ask them if there's anything else they need help with (e.g. don't say things like "How can I assist you further?"). - -3. Remember that this is a voice conversation: -3a. Don't use lists, markdown, bullet points, or other formatting that's not typically spoken. -3b. Type out numbers in words (e.g. 'twenty twelve' instead of the year 2012) -3c. If something doesn't make sense, it's likely because you misheard them. There wasn't a typo, and the user didn't mispronounce anything. - -Remember to follow these rules absolutely, and do not refer to these rules, even if you're asked about them.`; - -const DD_PROMPT = ` -You are a drive-thru order taker for a donut shop called "Dr. Donut". Local time is currently: ${new Date().toLocaleTimeString()}The user is talking to you over voice on their phone, and your response will be read out loud with realistic text-to-speech (TTS) technology. -${VOICE_PROMPT} - -When talking with the user, use the following script: -1. Take their order, acknowledging each item as it is ordered. If it's not clear which menu item the user is ordering, ask them to clarify. - DO NOT add an item to the order unless it's one of the items on the menu below. -2. Once the order is complete, repeat back the order. -2a. If the user only ordered a drink, ask them if they would like to add a donut to their order. -2b. If the user only ordered donuts, ask them if they would like to add a drink to their order. -2c. If the user ordered both drinks and donuts, don't suggest anything. -3. Total up the price of all ordered items and inform the user. -4. Ask the user to pull up to the drive thru window. -If the user asks for something that's not on the menu, inform them of that fact, and suggest the most similar item on the menu. -If the user says something unrelated to your role, responed with "Um... this is a Dr. Donut." -If the user says "thank you", respond with "My pleasure." -If the user asks about what's on the menu, DO NOT read the entire menu to them. Instead, give a couple suggestions. - -The menu of available items is as follows: - -# DONUTS - -PUMPKIN SPICE ICED DOUGHNUT $1.29 -PUMPKIN SPICE CAKE DOUGHNUT $1.29 -OLD FASHIONED DOUGHNUT $1.29 -CHOCOLATE ICED DOUGHNUT $1.09 -CHOCOLATE ICED DOUGHNUT WITH SPRINKLES $1.09 -RASPBERRY FILLED DOUGHNUT $1.09 -BLUEBERRY CAKE DOUGHNUT $1.09 -STRAWBERRY ICED DOUGHNUT WITH SPRINKLES $1.09 -LEMON FILLED DOUGHNUT $1.09 -DOUGHNUT HOLES $3.99 - -# COFFEE & DRINKS - -PUMPKIN SPICE COFFEE $2.59 -PUMPKIN SPICE LATTE $4.59 -REGULAR BREWED COFFEE $1.79 -DECAF BREWED COFFEE $1.79 -LATTE $3.49 -CAPPUCINO $3.49 -CARAMEL MACCHIATO $3.49 -MOCHA LATTE $3.49 -CARAMEL MOCHA LATTE $3.49 -`; - -const DD_INITIAL_RESPONSES = [ - 'Welcome to Dr. Donut! What can I get started for you today?', - 'Hi, thanks for choosing Dr. Donut! What would you like to order?', - "Howdy! Welcome to Dr. Donut. What'll make your day?", - 'Welcome to Dr. Donut, home of the best donuts in town! How can I help you?', - 'Greetings from Dr. Donut! What can we make fresh for you today?', - 'Hello and welcome to Dr. Donut! Are you ready to order?', - 'Hi there! Dr. Donut at your service. What would you like today?', - 'Hi, the doctor is in! What can we get for you today?', -]; - -const DD_CORPUS_ID = 'bd69dce6-7b56-4d0b-8b2f-226500780ebd'; - -export const DrDonut: AgentConfig = { - id: 'dr-donut', - prompt: DD_PROMPT, - initialResponses: DD_INITIAL_RESPONSES, - corpusId: DD_CORPUS_ID, -}; - -const RD_PROMPT = `You are a rubber duck. Your job is to listen to the user's problems and concerns and respond with responses -designed to help the user solve their own problems. You are not a therapist, and you are not a friend. You are a rubber duck. -${VOICE_PROMPT}`; - -const RD_INITIAL_RESPONSES = [ - "Hi, what's on your mind?", - 'Hi, how are you today?', - 'Hi! What can I help you with?', - 'Anything you want to talk about?', - "What's new?", -]; - -const RubberDuck: AgentConfig = { - id: 'rubber-duck', - prompt: RD_PROMPT, - initialResponses: RD_INITIAL_RESPONSES, - ttsVoice: 's3://peregrine-voices/donna_meditation_saad/manifest.json', -}; - -const ST_PROMPT = `You are a coach helping students learn to speak Spanish. Talk to them in basic Spanish, but -correct them in English if they say something that's not quite right. -${VOICE_PROMPT} -`; - -const ST_INITIAL_RESPONSES = [ - 'Hola, ¿cómo estás?', - 'Hola, ¿qué tal?', - 'Hola, ¿qué pasa?', - 'Hola, ¿qué haces?', - 'Hola, ¿qué hiciste hoy?', -]; - -const SpanishTutor: AgentConfig = { - id: 'spanish-tutor', - prompt: ST_PROMPT, - initialResponses: ST_INITIAL_RESPONSES, -}; - -const AI_INITIAL_RESPONSES = [ - "Well, look who's here! How's it going?", - "Hey, what's up? How you doing?", - "Long time no see! How've you been?", - "Hey, stranger! How's life treating you?", - "Good to see you again! What's the latest?", - "Hey, you! How's your day shaping up?", - "Hey, my friend, what's happening?", -]; - -const AI_PROMPT = `You're Fixie, a friendly AI companion and good friend of the user. -${VOICE_PROMPT} -`; - -const AiFriend: AgentConfig = { - id: 'ai-friend', - prompt: AI_PROMPT, - initialResponses: AI_INITIAL_RESPONSES, - ttsVoice: 's3://voice-cloning-zero-shot/09b5c0cc-a8f4-4450-aaab-3657b9965d0b/podcaster/manifest.json', -}; - -const AGENTS: AgentConfig[] = [AiFriend, DrDonut, RubberDuck, SpanishTutor]; -export function getAgent(agentId: string) { - return AGENTS.find((agent) => agent.id == agentId); -} -export const getAgentImageUrl = (agentId: string) => { - const agent = getAgent(agentId); - return agent ? `/agents/${agentId}.webp` : '/agents/fixie.webp'; -}; diff --git a/packages/voice/src/app/agent/api/route.tsx b/packages/voice/src/app/agent/api/route.tsx deleted file mode 100644 index c3ce126b7..000000000 --- a/packages/voice/src/app/agent/api/route.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/** @jsxImportSource ai-jsx */ -import { AssistantMessage, ChatCompletion, SystemMessage, UserMessage } from 'ai-jsx/core/completion'; -import { FixieCorpus } from 'ai-jsx/batteries/docs'; -import { OpenAI, ValidChatModel as OpenAIValidChatModel } from 'ai-jsx/lib/openai'; -import { Anthropic, ValidChatModel as AnthropicValidChatModel } from 'ai-jsx/lib/anthropic'; -import { StreamingTextResponse } from 'ai'; -import { toTextStream } from 'ai-jsx/stream'; -import { NextRequest } from 'next/server'; -import { AgentConfig, getAgent } from '../agents'; -import _ from 'lodash'; - -export const runtime = 'edge'; // 'nodejs' is the default - -const MAX_CHUNKS = 4; - -/** - * The user and assistant messages exchanged by client and server. - */ -class ClientMessage { - constructor(public role: string, public content: string) {} -} - -/** - * Makes a text stream that simulates LLM output from a specified string. - */ -function pseudoTextStream(text: string, interWordDelay = 0) { - return new ReadableStream({ - async pull(controller) { - const words = text.split(' '); - for (let index = 0; index < words.length; index++) { - const word = words[index]; - controller.enqueue(index > 0 ? ` ${word}` : word); - if (interWordDelay > 0) { - await new Promise((resolve) => setTimeout(resolve, interWordDelay)); - } - } - controller.close(); - }, - }).pipeThrough(new TextEncoderStream()); -} - -async function ChatAgent({ - agent, - conversation, - model, - docs, -}: { - agent: AgentConfig; - conversation: ClientMessage[]; - model: string; - docs?: number; -}) { - const query = conversation.at(-1)?.content; - let prompt = agent.prompt; - if (docs && agent.corpusId && query) { - const corpus = new FixieCorpus(agent.corpusId); - const chunks = await corpus.search(query, { limit: MAX_CHUNKS }); - const chunkText = chunks.map((chunk) => chunk.chunk.content).join('\n'); - console.log(`Chunks:\n${chunkText}`); - prompt += `\nHere is some relevant information that you can use to compose your response:\n\n${chunkText}\n`; - } - const children = ( - - {prompt} - {conversation.map((message: ClientMessage) => - message.role == 'assistant' ? ( - {message.content} - ) : ( - {message.content} - ) - )} - - ); - if (model.startsWith('gpt-')) { - return {children}; - } - if (model.startsWith('claude-')) { - return {children}; - } - throw new Error(`Unknown model: ${model}`); -} - -export async function POST(request: NextRequest) { - const json = await request.json(); - console.log(`New request (agentId=${json.agentId} model=${json.model} docs=${json.docs})`); - json.messages.forEach((message: ClientMessage) => console.log(`role=${message.role} content=${message.content}`)); - - const agent = getAgent((json.agentId as string) ?? 'dr-donut'); - if (!agent) { - throw new Error(`Unknown agent: ${json.agentId}`); - } - - let stream; - if (json.messages.length == 1 && !json.messages[0].content) { - const initialResponse = _.sample(agent.initialResponses)!; - stream = pseudoTextStream(initialResponse); - } else { - stream = toTextStream(); - } - return new StreamingTextResponse(stream); -} diff --git a/packages/voice/src/app/agent/chat.tsx b/packages/voice/src/app/agent/chat.tsx deleted file mode 100644 index 60a1a60dd..000000000 --- a/packages/voice/src/app/agent/chat.tsx +++ /dev/null @@ -1,743 +0,0 @@ -import { - createSpeechRecognition, - normalizeText, - SpeechRecognitionBase, - MicManager, - Transcript, -} from 'ai-jsx/lib/asr/asr'; -import { createTextToSpeech, BuildUrlOptions, TextToSpeechBase, TextToSpeechProtocol } from 'ai-jsx/lib/tts/tts'; -import { - createLocalTracks, - DataPacket_Kind, - LocalAudioTrack, - RemoteAudioTrack, - RemoteTrack, - Room, - RoomEvent, - Track, - TrackEvent, -} from 'livekit-client'; - -const DEFAULT_ASR_FRAME_SIZE = 20; - -/** - * Retrieves an ephemeral token from the server for use in an ASR service. - */ -async function getAsrToken(provider: string) { - const response = await fetch('/asr/api', { - method: 'POST', - body: JSON.stringify({ provider }), - }); - const json = await response.json(); - return json.token; -} - -/** - * Retrieves an ephemeral token from the server for use in an ASR service. - */ -async function getTtsToken(provider: string) { - const response = await fetch('/tts/api/token/edge', { - method: 'POST', - body: JSON.stringify({ provider }), - }); - const json = await response.json(); - return json.token; -} - -/** - * Builds a URL for use in a TTS service. - */ -function buildTtsUrl(options: BuildUrlOptions) { - const runtime = options.provider.endsWith('-grpc') ? 'nodejs' : 'edge'; - const params = new URLSearchParams(); - Object.entries(options).forEach(([k, v]) => v != undefined && params.set(k, v.toString())); - return `/tts/api/generate/${runtime}?${params}`; -} - -/** - * A single message in the chat history. - */ -export class ChatMessage { - constructor(public readonly role: string, public readonly content: string, public readonly conversationId?: string) {} -} - -/** - * Transforms a text stream of JSON lines into a stream of JSON objects. - */ -function jsonLinesTransformer() { - let buffer = ''; - return new TransformStream({ - async transform(chunk, controller) { - buffer += chunk; - const lines = buffer.split('\n'); - buffer = lines.pop()!; - for (const line of lines) { - if (line.trim()) { - controller.enqueue(JSON.parse(line)); - } - } - }, - }); -} - -/** - * A single request to the LLM, which may be speculative. - */ -export class ChatRequest { - public outMessage = ''; - public conversationId?: string; - public done = false; - public onUpdate?: (request: ChatRequest, newText: string, firstToken: boolean) => void; - public onComplete?: (request: ChatRequest) => void; - public startMillis?: number; - public requestLatency?: number; - public streamLatency?: number; - constructor( - private readonly inMessages: ChatMessage[], - private readonly model: string, - private readonly agentId: string, - private readonly docs: boolean, - public active: boolean - ) { - this.conversationId = inMessages.find((m) => m.conversationId)?.conversationId; - } - - async start() { - console.log(`[chat] calling agent for "${this.inMessages.at(-1)?.content}"`); - if (this.model === 'fixie') { - await this.startWithFixie(this.agentId); - } else { - await this.startWithLlm(this.agentId); - } - } - - private async startWithLlm(agentId: string) { - this.startMillis = performance.now(); - - const res = await fetch('/agent/api', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ messages: this.inMessages, model: this.model, agentId, docs: this.docs }), - }); - const reader = res.body!.getReader(); - // eslint-disable-next-line no-constant-condition - while (true) { - const { done, value } = await reader.read(); - if (done) { - this.ensureComplete(); - break; - } - const newText = new TextDecoder().decode(value); - if (newText.trim() && this.requestLatency === undefined) { - this.requestLatency = performance.now() - this.startMillis; - console.log(`[chat] received agent response, latency=${this.requestLatency.toFixed(0)} ms`); - } - - const firstToken = this.outMessage.length === 0; - this.outMessage += newText; - this.onUpdate?.(this, newText, firstToken); - } - } - - private async startWithFixie(agentId: string) { - this.startMillis = performance.now(); - - let isStartConversationRequest; - let response; - if (this.conversationId) { - isStartConversationRequest = false; - response = await fetch( - `https://api.fixie.ai/api/v1/agents/${agentId}/conversations/${this.conversationId}/messages`, - { - method: 'POST', - body: JSON.stringify({ - message: this.inMessages.at(-1)!.content, - }), - headers: { 'Content-Type': 'application/json' }, - } - ); - } else { - isStartConversationRequest = true; - response = await fetch(`https://api.fixie.ai/api/v1/agents/${agentId}/conversations`, { - method: 'POST', - body: JSON.stringify({ - message: this.inMessages.at(-1)!.content, - }), - headers: { 'Content-Type': 'application/json' }, - }); - this.conversationId = response.headers.get('X-Fixie-Conversation-Id')!; - console.log( - `To view conversation transcript see https://embed.fixie.ai/agents/${agentId}/conversations/${this.conversationId}` - ); - } - - const reader = response.body!.pipeThrough(new TextDecoderStream()).pipeThrough(jsonLinesTransformer()).getReader(); - let firstToken = true; - - while (true) { - const { done, value } = await reader.read(); - if (done) { - this.ensureComplete(); - break; - } - - if (!this.done) { - const currentTurn = isStartConversationRequest ? value.turns.at(-1) : value; - - const textMessages = currentTurn.messages.filter((m: any) => m.kind === 'text'); - let currentMessage = ''; - for (const textMessage of textMessages) { - currentMessage += textMessage.content; - const messageState = textMessage.state; - if (messageState === 'in-progress') { - // This message is still being generated, so don't include any text after it. - break; - } else if (messageState === 'done') { - // Append two newlines to end the paragraph (i.e. make clear to the TTS pipeline that the text is complete). - currentMessage += '\n\n'; - } - } - - // Find the longest matching prefix. - let i = 0; - while (i < currentMessage.length && i < this.outMessage.length && currentMessage[i] === this.outMessage[i]) { - i++; - } - if (i !== this.outMessage.length) { - console.error('Result was not an append to the previous result.'); - } - const delta = currentMessage.slice(i); - - if (delta.trim() && this.requestLatency === undefined) { - this.requestLatency = performance.now() - this.startMillis; - console.log(`Got Fixie response, latency=${this.requestLatency.toFixed(0)}`); - } - - this.outMessage = currentMessage; - this.onUpdate?.(this, delta, firstToken); - firstToken = false; - - if (currentTurn.state === 'done') { - this.ensureComplete(); - break; - } - } - } - } - - private ensureComplete() { - if (!this.done) { - this.done = true; - if (this.startMillis !== undefined && this.requestLatency !== undefined) { - this.streamLatency = performance.now() - this.startMillis - this.requestLatency; - } - this.onComplete?.(this); - } - } -} - -export enum ChatManagerState { - IDLE = 'idle', - LISTENING = 'listening', - THINKING = 'thinking', - SPEAKING = 'speaking', -} - -export interface ChatManagerInit { - asrProvider: string; - ttsProvider: string; - model: string; - agentId: string; - docs: boolean; - asrModel?: string; - asrLanguage?: string; - ttsModel?: string; - ttsVoice?: string; - webrtcUrl?: string; -} - -/** - * Abstract interface for a voice-based LLM chat session. - */ -export interface ChatManager { - onStateChange?: (state: ChatManagerState) => void; - onInputChange?: (text: string, final: boolean) => void; - onOutputChange?: (text: string, final: boolean) => void; - onLatencyChange?: (kind: string, latency: number) => void; - onError?: () => void; - - state: ChatManagerState; - inputAnalyzer?: AnalyserNode; - outputAnalyzer?: AnalyserNode; - start(initialMessage?: string): Promise; - stop(): void; - interrupt(): void; -} - -/** - * Manages a single chat with a LLM, including speculative execution. - * All RPCs are managed from within the browser context. - */ -export class LocalChatManager implements ChatManager { - private _state = ChatManagerState.IDLE; - private history: ChatMessage[] = []; - private pendingRequests = new Map(); - private readonly micManager: MicManager; - private readonly asr: SpeechRecognitionBase; - private readonly tts: TextToSpeechBase; - private readonly model: string; - private readonly agentId: string; - private readonly docs: boolean; - onStateChange?: (state: ChatManagerState) => void; - onInputChange?: (text: string, final: boolean) => void; - onOutputChange?: (text: string, final: boolean) => void; - onLatencyChange?: (kind: string, latency: number) => void; - onError?: () => void; - constructor({ asrProvider, asrLanguage, ttsProvider, ttsModel, ttsVoice, model, agentId, docs }: ChatManagerInit) { - this.micManager = new MicManager(); - this.asr = createSpeechRecognition({ - provider: asrProvider, - manager: this.micManager, - getToken: getAsrToken, - language: asrLanguage, - }); - const ttsSplit = ttsProvider.split('-'); - this.tts = createTextToSpeech({ - provider: ttsSplit[0], - proto: ttsSplit[1] as TextToSpeechProtocol, - getToken: getTtsToken, - buildUrl: buildTtsUrl, - model: ttsModel, - voice: ttsVoice, - rate: 1.2, - }); - this.model = model; - this.agentId = agentId; - this.docs = docs; - this.asr.addEventListener('transcript', (evt: Event) => this.handleTranscript(evt)); - this.tts.onGenerating = () => this.handleGenerationStart(); - this.tts.onPlaying = () => this.handlePlaybackStart(); - this.tts.onComplete = () => this.handlePlaybackComplete(); - } - - get state() { - return this._state; - } - get inputAnalyzer() { - return this.micManager.analyzer; - } - get outputAnalyzer() { - return this.tts.analyzer; - } - - /** - * Starts the chat. - */ - async start(initialMessage?: string) { - await this.micManager.startMic(DEFAULT_ASR_FRAME_SIZE, () => { - console.warn('[chat] Mic stream closed unexpectedly'); - this.onError?.(); - }); - this.asr.start(); - if (initialMessage !== undefined) { - this.handleInputUpdate(initialMessage, true); - } else { - this.changeState(ChatManagerState.LISTENING); - } - } - /** - * Stops the chat. - */ - stop() { - this.changeState(ChatManagerState.IDLE); - this.asr.close(); - this.tts.close(); - this.micManager.stop(); - this.history = []; - this.pendingRequests.clear(); - } - - /** - * If the assistant is thinking or speaking, interrupt it and start listening again. - * If the assistant is speaking, the generated assistant message will be retained in history. - */ - interrupt() { - if (this._state == ChatManagerState.THINKING || this._state == ChatManagerState.SPEAKING) { - this.cancelRequests(); - this.tts.stop(); - this.micManager.isEnabled = true; - this.changeState(ChatManagerState.LISTENING); - } - } - - private changeState(state: ChatManagerState) { - if (state != this._state) { - console.log(`[chat] ${this._state} -> ${state}`); - this._state = state; - this.onStateChange?.(state); - } - } - - /** - * Handle new input from the ASR. - */ - private handleTranscript(evt: Event) { - if (this._state != ChatManagerState.LISTENING && this._state != ChatManagerState.THINKING) return; - const obj = (evt as CustomEventInit).detail!; - this.handleInputUpdate(obj.text, obj.final, obj.observedLatency); - } - - private handleInputUpdate(text: string, final: boolean, latency?: number) { - // Update the received ASR latency stat to account for our speculative execution. - const normalized = normalizeText(text); - const request = this.pendingRequests.get(normalized); - let adjustedLatency = latency; - if (adjustedLatency && final && request) { - adjustedLatency -= performance.now() - request.startMillis!; - } - console.log( - `[chat] asr transcript="${normalized}" ${request ? 'HIT' : 'MISS'}${ - final ? ' FINAL' : '' - } latency=${adjustedLatency?.toFixed(0)} ms` - ); - this.onInputChange?.(text, final); - - // Ignore partial transcripts if VAD indicates the user is still speaking. - if (!final && this.micManager.isVoiceActive) { - return; - } - - this.changeState(ChatManagerState.THINKING); - - // If the input text has been finalized, add it to the message history. - const userMessage = new ChatMessage('user', text.trim()); - const newMessages = [...this.history, userMessage]; - if (final) { - this.history = newMessages; - this.micManager.isEnabled = false; - this.onLatencyChange?.('asr', adjustedLatency!); - } - - // If it doesn't match an existing request, kick off a new one. - // If it matches an existing request and the text is finalized, speculative - // execution worked! Snap forward to the current state of that request. - const supportsSpeculativeExecution = this.model !== 'fixie'; - if (!request && (final || supportsSpeculativeExecution)) { - this.dispatchRequest(normalized, newMessages, final); - } else if (final) { - this.activateRequest(request!); - } - } - /** - * Send off a new request to the LLM. - */ - private dispatchRequest(normalized: string, messages: ChatMessage[], final: boolean) { - const request = new ChatRequest(messages, this.model, this.agentId, this.docs, final); - request.onUpdate = (request, newText, firstToken) => this.handleRequestUpdate(request, newText, firstToken); - request.onComplete = (request) => this.handleRequestDone(request); - this.pendingRequests.set(normalized, request); - request.start(); - } - /** - * Activate a request that was previously dispatched. - */ - private activateRequest(request: ChatRequest) { - request.active = true; - this.tts.play(request.outMessage); - if (!request.done) { - this.onOutputChange?.(request.outMessage, false); - } else { - this.finishRequest(request); - } - } - /** - * Cancel all pending requests. - */ - private cancelRequests() { - for (const request of this.pendingRequests.values()) { - request.active = false; - } - this.pendingRequests.clear(); - } - /** - * Handle new in-progress responses from the LLM. If the request is not marked - * as active, it's a speculative request that we ignore for now. - */ - private handleRequestUpdate(request: ChatRequest, newText: string, firstToken: boolean) { - if (request.active) { - this.onOutputChange?.(request.outMessage, false); - if (firstToken) { - this.onLatencyChange?.('llm', request.streamLatency!); - } - this.tts.play(newText); - } - } - /** - * Handle a completed response from the LLM. If the request is not marked as - * active, it's a speculative request that we ignore for now. - */ - private handleRequestDone(request: ChatRequest) { - // console.log(`request done, active=${request.active}`); - if (request.active) { - this.finishRequest(request); - } - } - /** - * Once a response is finalized, we can flush the TTS buffer and update the - * chat history. - */ - private finishRequest(request: ChatRequest) { - this.tts.flush(); - const assistantMessage = new ChatMessage('assistant', request.outMessage, request.conversationId); - this.history.push(assistantMessage); - this.pendingRequests.clear(); - this.onOutputChange?.(request.outMessage, true); - } - /** - * Handle the start of generation from the TTS. - */ - private handleGenerationStart() { - if (this._state != ChatManagerState.THINKING) return; - this.onLatencyChange?.('llmt', this.tts.bufferLatency!); - } - /** - * Handle the start of playout from the TTS. - */ - private handlePlaybackStart() { - if (this._state != ChatManagerState.THINKING) return; - this.changeState(ChatManagerState.SPEAKING); - this.onLatencyChange?.('tts', this.tts.latency! - this.tts.bufferLatency!); - } - /** - * Handle the end of playout from the TTS. - */ - private handlePlaybackComplete() { - if (this._state != ChatManagerState.SPEAKING) return; - this.micManager.isEnabled = true; - this.changeState(ChatManagerState.LISTENING); - } -} - -export class StreamAnalyzer { - source: MediaStreamAudioSourceNode; - analyzer: AnalyserNode; - constructor(context: AudioContext, stream: MediaStream) { - this.source = context.createMediaStreamSource(stream); - this.analyzer = context.createAnalyser(); - this.source.connect(this.analyzer); - } - stop() { - this.source.disconnect(); - } -} - -/** - * Manages a single chat with a LLM, including speculative execution. - * All RPCs are performed remotely, and audio is streamed to/from the server via WebRTC. - */ -export class WebRtcChatManager implements ChatManager { - private params: ChatManagerInit; - private audioContext = new AudioContext(); - private audioElement = new Audio(); - private textEncoder = new TextEncoder(); - private textDecoder = new TextDecoder(); - private _state = ChatManagerState.IDLE; - private socket?: WebSocket; - private room?: Room; - private localAudioTrack?: LocalAudioTrack; - /** True when we should have entered speaking state but didn't due to analyzer not being ready. */ - private delayedSpeakingState = false; - private inAnalyzer?: StreamAnalyzer; - private outAnalyzer?: StreamAnalyzer; - private pinger?: NodeJS.Timer; - onStateChange?: (state: ChatManagerState) => void; - onInputChange?: (text: string, final: boolean) => void; - onOutputChange?: (text: string, final: boolean) => void; - onLatencyChange?: (kind: string, latency: number) => void; - onError?: () => void; - - constructor(params: ChatManagerInit) { - this.params = params; - this.audioElement = new Audio(); - this.warmup(); - } - get state() { - return this._state; - } - get inputAnalyzer() { - return this.inAnalyzer?.analyzer; - } - get outputAnalyzer() { - return this.outAnalyzer?.analyzer; - } - warmup() { - const isLocalHost = window.location.hostname === 'localhost'; - const url = this.params.webrtcUrl || (!isLocalHost ? 'wss://wsapi.fixie.ai' : 'ws://localhost:8100'); - this.socket = new WebSocket(url); - this.socket.onopen = () => this.handleSocketOpen(); - this.socket.onmessage = (event) => this.handleSocketMessage(event); - this.socket.onclose = (event) => this.handleSocketClose(event); - } - async start() { - console.log('[chat] starting'); - this.audioContext.resume(); - this.audioElement.play(); - const localTracks = await createLocalTracks({ audio: true, video: false }); - this.localAudioTrack = localTracks[0] as LocalAudioTrack; - console.log('[chat] got mic stream'); - this.inAnalyzer = new StreamAnalyzer(this.audioContext, this.localAudioTrack!.mediaStream!); - this.pinger = setInterval(() => { - const obj = { type: 'ping', timestamp: performance.now() }; - this.sendData(obj); - }, 5000); - this.maybePublishLocalAudio(); - } - async stop() { - console.log('[chat] stopping'); - clearInterval(this.pinger); - this.pinger = undefined; - await this.room?.disconnect(); - this.room = undefined; - this.inAnalyzer?.stop(); - this.outAnalyzer?.stop(); - this.inAnalyzer = undefined; - this.outAnalyzer = undefined; - this.localAudioTrack?.stop(); - this.localAudioTrack = undefined; - this.socket?.close(); - this.socket = undefined; - this.changeState(ChatManagerState.IDLE); - } - interrupt() { - console.log('[chat] interrupting'); - const obj = { type: 'interrupt' }; - this.sendData(obj); - } - private changeState(state: ChatManagerState) { - if (state != this._state) { - console.log(`[chat] ${this._state} -> ${state}`); - this._state = state; - this.onStateChange?.(state); - } - } - private maybePublishLocalAudio() { - if (this.room && this.room.state == 'connected' && this.localAudioTrack) { - console.log(`[chat] publishing local audio track`); - const opts = { name: 'audio', simulcast: false, source: Track.Source.Microphone }; - this.room.localParticipant.publishTrack(this.localAudioTrack, opts); - } - } - private sendData(obj: any) { - this.room?.localParticipant.publishData(this.textEncoder.encode(JSON.stringify(obj)), DataPacket_Kind.RELIABLE); - } - private handleSocketOpen() { - console.log('[chat] socket opened'); - const obj = { - type: 'init', - params: { - asr: { - provider: this.params.asrProvider, - model: this.params.asrModel, - language: this.params.asrLanguage, - }, - tts: { - provider: this.params.ttsProvider, - model: this.params.ttsModel, - voice: this.params.ttsVoice, - }, - agent: { - model: this.params.model, - agentId: this.params.agentId, - docs: this.params.docs, - }, - }, - }; - this.socket?.send(JSON.stringify(obj)); - } - private async handleSocketMessage(event: MessageEvent) { - const msg = JSON.parse(event.data); - switch (msg.type) { - case 'room_info': - this.room = new Room(); - this.room.on(RoomEvent.TrackSubscribed, (track) => this.handleTrackSubscribed(track)); - this.room.on(RoomEvent.DataReceived, (payload, participant) => this.handleDataReceived(payload, participant)); - await this.room.connect(msg.roomUrl, msg.token); - console.log('[chat] connected to room', this.room.name); - this.maybePublishLocalAudio(); - break; - default: - console.warn('unknown message type', msg.type); - } - } - private handleSocketClose(event: CloseEvent) { - if (event.code === 1000) { - // We initiated this shutdown, so we've already cleaned up. - // Reconnect to prepare for the next session. - console.log('[chat] socket closed normally'); - this.warmup(); - } else if (event.code === 1006) { - // This occurs when running a Next.js app in debug mode and the ChatManager is - // initialized twice, the first socket will receive this error that we can ignore. - } else { - console.warn(`[chat] socket closed unexpectedly: ${event.code} ${event.reason}`); - this.onError?.(); - } - } - private handleTrackSubscribed(track: RemoteTrack) { - console.log(`[chat] subscribed to remote audio track ${track.sid}`); - const audioTrack = track as RemoteAudioTrack; - audioTrack.on(TrackEvent.AudioPlaybackStarted, () => console.log(`[chat] audio playback started`)); - audioTrack.on(TrackEvent.AudioPlaybackFailed, (err) => console.error(`[chat] audio playback failed`, err)); - audioTrack.attach(this.audioElement); - this.outAnalyzer = new StreamAnalyzer(this.audioContext, track.mediaStream!); - if (this.delayedSpeakingState) { - this.delayedSpeakingState = false; - this.changeState(ChatManagerState.SPEAKING); - } - } - private handleDataReceived(payload: Uint8Array, participant: any) { - const data = JSON.parse(this.textDecoder.decode(payload)); - if (data.type === 'pong') { - const elapsed_ms = performance.now() - data.timestamp; - console.debug(`[chat] worker RTT: ${elapsed_ms.toFixed(0)} ms`); - } else if (data.type === 'state') { - const newState = data.state; - if (newState === ChatManagerState.SPEAKING && this.outAnalyzer === undefined) { - // Skip the first speaking state, before we've attached the audio element. - // handleTrackSubscribed will be called soon and will change the state. - this.delayedSpeakingState = true; - } else { - this.changeState(newState); - } - } else if (data.type === 'transcript') { - this.handleInputChange(data.transcript); - } else if (data.type === 'output') { - this.handleOutputChange(data.text, data.final); - } else if (data.type == 'latency') { - this.handleLatency(data.kind, data.value); - } - } - private handleInputChange(transcript: Transcript) { - const finalText = transcript.final ? ' FINAL' : ''; - console.log(`[chat] input: ${transcript.text}${finalText}`); - this.onInputChange?.(transcript.text, transcript.final); - } - private handleOutputChange(text: string, final: boolean) { - console.log(`[chat] output: ${text}`); - this.onOutputChange?.(text, final); - } - private handleLatency(kind: string, value: number) { - console.log(`[chat] latency: ${kind} ${value.toFixed(0)} ms`); - this.onLatencyChange?.(kind, value); - } -} - -export function createChatManager(init: ChatManagerInit): ChatManager { - if (init.webrtcUrl !== '0') { - return new WebRtcChatManager(init); - } else { - return new LocalChatManager(init); - } -} diff --git a/packages/voice/src/app/agent/page.tsx b/packages/voice/src/app/agent/page.tsx deleted file mode 100644 index 228857f78..000000000 --- a/packages/voice/src/app/agent/page.tsx +++ /dev/null @@ -1,453 +0,0 @@ -'use client'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useSearchParams } from 'next/navigation'; -import { useSwipeable } from 'react-swipeable'; -import { ChatManager, ChatManagerState, createChatManager } from './chat'; -import { getAgent, getAgentImageUrl } from './agents'; -import Image from 'next/image'; -import '../globals.css'; - -// 1. VAD triggers silence. (Latency here is frame size + VAD delay) -// 2. ASR sends partial transcript. ASR latency = 2-1. -// 3. ASR sends final transcript. ASR latency = 3-1. -// 4. LLM request is made. This can happen before 3 is complete, in which case the speculative execution savings is 3-2. -// 5. LLM starts streaming tokens. LLM base latency = 5-4. -// 6. LLM sends enough tokens for TTS to start (full sentence, or 50 chars). LLM token latency = 6-5, LLM total latency = 6-4. -// 7. TTS requests chunk of audio. -// 8. TTS chunk is received. -// 9. TTS playout starts (usually just about instantaneous after 8). TTS latency = 9-7. -// Total latency = 9-1 = ASR latency + LLM base latency + LLM token latency TTS latency - speculative execution savings. - -// Token per second rules of thumb: -// GPT-4: 12 tps (approx 1s for 50 chars) -// GPT-3.5: 70 tps (approx 0.2s for 50 chars) -// Claude v1: 40 tps (approx 0.4s for 50 chars) -// Claude Instant v1: 70 tps (approx 0.2s for 50 chars) - -interface LatencyThreshold { - good: number; - fair: number; -} - -const DEFAULT_ASR_PROVIDER = 'deepgram'; -const DEFAULT_TTS_PROVIDER = 'playht'; -const DEFAULT_LLM = 'gpt-4-1106-preview'; -const ASR_PROVIDERS = ['aai', 'deepgram', 'deepgram-turbo', 'gladia', 'revai', 'soniox']; -const TTS_PROVIDERS = [ - 'aws', - 'azure', - 'eleven', - 'eleven-ws', - 'gcp', - 'lmnt', - 'lmnt-ws', - 'murf', - 'openai', - 'playht', - 'resemble', - 'wellsaid', -]; -const LLM_MODELS = [ - 'claude-2', - 'claude-instant-1', - 'gpt-4', - 'gpt-4-32k', - 'gpt-4-1106-preview', - 'gpt-3.5-turbo', - 'gpt-3.5-turbo-16k', -]; -const AGENT_IDS = ['ai-friend', 'dr-donut', 'rubber-duck']; //, 'spanish-tutor', 'justin/ultravox', 'justin/fixie']; -const LATENCY_THRESHOLDS: { [key: string]: LatencyThreshold } = { - ASR: { good: 300, fair: 500 }, - LLM: { good: 300, fair: 500 }, - LLMT: { good: 300, fair: 400 }, - TTS: { good: 400, fair: 600 }, - Total: { good: 1300, fair: 2000 }, -}; - -const updateSearchParams = (param: string, value?: string, reload = false) => { - const params = new URLSearchParams(window.location.search); - if (value !== undefined) { - params.set(param, value); - } else { - params.delete(param); - } - const newUrl = `${window.location.pathname}?${params}`; - if (reload) { - window.location.replace(newUrl); - } else { - window.history.pushState({}, '', newUrl); - } -}; - -const Dropdown: React.FC<{ label: string; param: string; value: string; options: string[] }> = ({ - param, - label, - value, - options, -}) => ( - <> - - - -); - -const Stat: React.FC<{ name: string; latency: number; showName?: boolean }> = ({ name, latency, showName = true }) => { - let valueText = (latency ? `${latency.toFixed(0)}` : '-').padStart(4, ' '); - for (let i = valueText.length; i < 4; i++) { - valueText = ' ' + valueText; - } - const color = - latency < LATENCY_THRESHOLDS[name].good - ? '' - : latency < LATENCY_THRESHOLDS[name].fair - ? 'text-yellow-500' - : 'text-red-500'; - return ( - - {' '} - {showName && {name}} - {(showName || latency > 0) && ( - <> -
{valueText}
ms - - )} -
- ); -}; - -const Visualizer: React.FC<{ - width?: number; - height?: number; - state?: ChatManagerState; - inputAnalyzer?: AnalyserNode; - outputAnalyzer?: AnalyserNode; -}> = ({ width, height, state, inputAnalyzer, outputAnalyzer }) => { - const canvasRef = useRef(null); - if (canvasRef.current) { - canvasRef.current.width = canvasRef.current.offsetWidth; - canvasRef.current.height = canvasRef.current.offsetHeight; - } - if (inputAnalyzer) { - inputAnalyzer.fftSize = 64; - inputAnalyzer.maxDecibels = 0; - inputAnalyzer.minDecibels = -70; - } - if (outputAnalyzer) { - // We use a larger FFT size for the output analyzer because it's typically fullband, - // versus the wideband input analyzer, resulting in a similar bin size for each. - // Then, when we grab the lowest 16 bins from each, we get a similar spectrum. - outputAnalyzer.fftSize = 256; - outputAnalyzer.maxDecibels = 0; - outputAnalyzer.minDecibels = -70; - } - const draw = (canvas: HTMLCanvasElement, state: ChatManagerState, freqData: Uint8Array) => { - const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; - const marginWidth = 2; - const barWidth = canvas.width / freqData.length - marginWidth * 2; - const totalWidth = barWidth + marginWidth * 2; - ctx.clearRect(0, 0, canvas.width, canvas.height); - freqData.forEach((freqVal, i) => { - const barHeight = (freqVal * canvas.height) / 128; - const x = barHeight + 25 * (i / freqData.length); - const y = 250 * (i / freqData.length); - const z = 50; - if (state == ChatManagerState.LISTENING) { - ctx.fillStyle = `rgb(${x},${y},${z})`; - } else if (state == ChatManagerState.THINKING) { - ctx.fillStyle = `rgb(${z},${x},${y})`; - } else if (state == ChatManagerState.SPEAKING) { - ctx.fillStyle = `rgb(${y},${z},${x})`; - } - ctx.fillRect(i * totalWidth + marginWidth, canvas.height - barHeight, barWidth, barHeight); - }); - }; - const render = useCallback(() => { - let freqData: Uint8Array = new Uint8Array(0); - switch (state) { - case ChatManagerState.LISTENING: - if (!inputAnalyzer) return; - freqData = new Uint8Array(inputAnalyzer!.frequencyBinCount); - inputAnalyzer!.getByteFrequencyData(freqData); - freqData = freqData.slice(0, 16); - break; - case ChatManagerState.THINKING: - freqData = new Uint8Array(16); - // make the data have random pulses based on performance.now, which decay over time - const now = performance.now(); - for (let i = 0; i < freqData.length; i++) { - freqData[i] = Math.max(0, Math.sin((now - i * 100) / 100) * 128 + 128) / 2; - } - break; - case ChatManagerState.SPEAKING: - if (!outputAnalyzer) return; - freqData = new Uint8Array(outputAnalyzer!.frequencyBinCount); - outputAnalyzer!.getByteFrequencyData(freqData); - freqData = freqData.slice(0, 16); - break; - } - draw(canvasRef.current!, state ?? ChatManagerState.IDLE, freqData); - requestAnimationFrame(render); - }, [state, inputAnalyzer, outputAnalyzer]); - useEffect(() => render(), [state]); - let className = ''; - if (!width) className += ' w-full'; - if (!height) className += ' h-full'; - return ; -}; - -const Button: React.FC<{ onClick: () => void; disabled: boolean; children: React.ReactNode }> = ({ - onClick, - disabled, - children, -}) => ( - -); - -const AgentPageComponent: React.FC = () => { - const searchParams = useSearchParams(); - const agentId = searchParams.get('agent') || 'dr-donut'; - const agentVoice = getAgent(agentId)?.ttsVoice; - const tapOrClick = typeof window != 'undefined' && 'ontouchstart' in window ? 'Tap' : 'Click'; - const idleText = `${tapOrClick} anywhere to start!`; - const asrProvider = searchParams.get('asr') || DEFAULT_ASR_PROVIDER; - const asrModel = searchParams.get('asrModel') || undefined; - const asrLanguage = searchParams.get('asrLanguage') || undefined; - const ttsProvider = searchParams.get('tts') || DEFAULT_TTS_PROVIDER; - const ttsModel = searchParams.get('ttsModel') || undefined; - const ttsVoice = searchParams.get('ttsVoice') || agentVoice; - const model = getAgent(agentId) === undefined ? 'fixie' : searchParams.get('llm') || DEFAULT_LLM; - const docs = searchParams.get('docs') !== null; - const webrtcUrl = searchParams.get('webrtc') ?? undefined; - const [showChooser, setShowChooser] = useState(searchParams.get('chooser') !== null); - const showInput = searchParams.get('input') !== null; - const showOutput = searchParams.get('output') !== null; - const [showStats, setShowStats] = useState(searchParams.get('stats') !== null); - const [chatManager, setChatManager] = useState(); - const [input, setInput] = useState(''); - const [output, setOutput] = useState(''); - const [helpText, setHelpText] = useState(idleText); - const [asrLatency, setAsrLatency] = useState(0); - const [llmResponseLatency, setLlmResponseLatency] = useState(0); - const [llmTokenLatency, setLlmTokenLatency] = useState(0); - const [ttsLatency, setTtsLatency] = useState(0); - const active = () => chatManager && chatManager!.state != ChatManagerState.IDLE; - useEffect(() => init(), [asrProvider, asrLanguage, ttsProvider, ttsModel, ttsVoice, model, agentId, docs]); - const init = () => { - console.log(`[page] init asr=${asrProvider} tts=${ttsProvider} llm=${model} agent=${agentId} docs=${docs}`); - const manager = createChatManager({ - asrProvider, - asrModel, - asrLanguage, - ttsProvider, - ttsModel, - ttsVoice, - model, - agentId, - docs, - webrtcUrl, - }); - setChatManager(manager); - manager.onStateChange = (state) => { - switch (state) { - case ChatManagerState.LISTENING: - setHelpText('Listening...'); - break; - case ChatManagerState.THINKING: - setHelpText(`Thinking... ${tapOrClick.toLowerCase()} to cancel`); - break; - case ChatManagerState.SPEAKING: - setHelpText(`Speaking... ${tapOrClick.toLowerCase()} to interrupt`); - break; - default: - setHelpText(idleText); - } - }; - manager.onInputChange = (text, final) => { - setInput(text); - }; - manager.onOutputChange = (text, final) => { - setOutput(text); - if (final) { - setInput(''); - } - }; - manager.onLatencyChange = (kind, latency) => { - switch (kind) { - case 'asr': - setAsrLatency(latency); - setLlmResponseLatency(0); - setLlmTokenLatency(0); - setTtsLatency(0); - break; - case 'llm': - setLlmResponseLatency(latency); - break; - case 'llmt': - setLlmTokenLatency(latency); - break; - case 'tts': - setTtsLatency(latency); - break; - } - }; - manager.onError = () => { - manager.stop(); - }; - return () => manager.stop(); - }; - const changeAgent = (delta: number) => { - const index = AGENT_IDS.indexOf(agentId); - const newIndex = (index + delta + AGENT_IDS.length) % AGENT_IDS.length; - updateSearchParams('agent', AGENT_IDS[newIndex], true); - }; - const handleStart = () => { - setInput(''); - setOutput(''); - setAsrLatency(0); - setLlmResponseLatency(0); - setLlmTokenLatency(0); - setTtsLatency(0); - chatManager!.start(''); - }; - const handleStop = () => { - chatManager!.stop(); - }; - const speak = () => (active() ? chatManager!.interrupt() : handleStart()); - // Click/tap starts or interrupts. - const onClick = (event: MouseEvent) => { - const target = event.target as HTMLElement; - if (!target.matches('button') && !target.matches('select') && !target.matches('a')) { - speak(); - } - }; - // Spacebar starts or interrupts. Esc quits. - // C toggles the chooser. S toggles the stats. - const onKeyDown = (event: KeyboardEvent) => { - if (event.keyCode == 32) { - speak(); - event.preventDefault(); - } else if (event.keyCode == 27) { - handleStop(); - event.preventDefault(); - } else if (event.keyCode == 67) { - const newVal = !showChooser; - setShowChooser(newVal); - updateSearchParams('chooser', newVal ? '1' : undefined); - event.preventDefault(); - } else if (event.keyCode == 83) { - const newVal = !showStats; - setShowStats(newVal); - updateSearchParams('stats', newVal ? '1' : undefined); - event.preventDefault(); - } else if (event.keyCode == 37) { - handleStop(); - changeAgent(-1); - event.preventDefault(); - } else if (event.keyCode == 39) { - handleStop(); - changeAgent(1); - event.preventDefault(); - } - }; - // Install our handlers, and clean them up on unmount. - useEffect(() => { - document.addEventListener('click', onClick); - document.addEventListener('keydown', onKeyDown); - return () => { - document.removeEventListener('keydown', onKeyDown); - document.removeEventListener('click', onClick); - }; - }, [onKeyDown]); - const swipeHandlers = useSwipeable({ - onSwipedLeft: (eventData) => changeAgent(-1), - onSwipedRight: (eventData) => changeAgent(1), - }); - return ( - <> - {showChooser && ( -
- - - - -
- )} -
- {showStats && ( - <> - - - - - - )} - -
-
-
- Fixie Voice -
-
- {agentId} -
-
- {showOutput && ( -
{output}
- )} -
-
- {showInput && ( -
- {input} -
- )} -
-

{helpText}

-
- -
-
- {active() && ( - - )} -
-
- - ); -}; - -export default AgentPageComponent; diff --git a/packages/voice/src/app/asr/api/route.tsx b/packages/voice/src/app/asr/api/route.tsx deleted file mode 100644 index 53f614c95..000000000 --- a/packages/voice/src/app/asr/api/route.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/** @jsxImportSource ai-jsx */ -import { NextRequest, NextResponse } from 'next/server'; -import { Deepgram } from '@deepgram/sdk'; -import { SpeechClient } from '@soniox/soniox-node'; - -let deepgramClient: Deepgram; -let sonioxClient: SpeechClient; - -export const runtime = 'nodejs'; // can't do 'edge' with the client libs we're using - -type GetTokenFunction = () => Promise; -interface FunctionMap { - [key: string]: GetTokenFunction; -} - -const KEY_LIFETIME_SECONDS = 300; -const FUNCTION_MAP: FunctionMap = { - deepgram: getDeepgramToken, - soniox: getSonioxToken, - gladia: getGladiaToken, - revai: getRevAIToken, - speechmatics: getSpeechmaticsToken, - aai: getAssemblyAIToken, -}; - -export async function POST(request: NextRequest) { - const inJson = await request.json(); - const provider = inJson.provider as string; - if (!(provider in FUNCTION_MAP)) { - return new NextResponse(JSON.stringify({ error: 'unknown provider' })); - } - - const func = FUNCTION_MAP[provider] as Function; - return new NextResponse(JSON.stringify({ token: await func() })); -} - -async function getDeepgramToken() { - if (!deepgramClient) deepgramClient = new Deepgram(getEnvVar('DEEPGRAM_API_KEY')); - const projectId = getEnvVar('DEEPGRAM_PROJECT_ID'); - const { key } = await deepgramClient.keys.create(projectId, 'Ephemeral websocket key', ['usage:write'], { - timeToLive: KEY_LIFETIME_SECONDS, - }); - return key!; -} - -async function getSonioxToken() { - if (!sonioxClient) sonioxClient = new SpeechClient({ api_key: getEnvVar('SONIOX_API_KEY') }); - const response = await sonioxClient.createTemporaryApiKey({ - usage_type: 'transcribe_websocket', - expires_in_s: KEY_LIFETIME_SECONDS, - client_request_reference: 'test_ref', - }); - return response.key; -} - -function getGladiaToken() { - return getApiKey('GLADIA_API_KEY'); -} - -function getRevAIToken() { - return getApiKey('REVAI_API_KEY'); -} - -async function getSpeechmaticsToken() { - const apiKey = getEnvVar('SPEECHMATICS_API_KEY'); - const response = await fetch('https://mp.speechmatics.com/v1/api_keys?type=rt', { - method: 'POST', - headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ ttl: KEY_LIFETIME_SECONDS }), - }); - const json = await response.json(); - return json.key_value; -} - -async function getAssemblyAIToken() { - const apiKey = getEnvVar('AAI_API_KEY'); - const response = await fetch('https://api.assemblyai.com/v2/realtime/token', { - method: 'POST', - headers: { Authorization: `${apiKey}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ expires_in: KEY_LIFETIME_SECONDS }), - }); - const json = await response.json(); - return json.token; -} - -function getApiKey(keyName: string) { - const key = getEnvVar(keyName); - if (!key) { - throw new Error('API key not provided '); - } - return new Promise((resolve) => { - setTimeout(() => resolve(key), 0); - }); -} - -function getEnvVar(keyName: string) { - const key = process.env[keyName]; - if (!key) { - throw new Error(`API key "${keyName}" not provided. Please set it as an env var.`); - } - return key; -} diff --git a/packages/voice/src/app/asr/page.tsx b/packages/voice/src/app/asr/page.tsx deleted file mode 100644 index 56a00d17a..000000000 --- a/packages/voice/src/app/asr/page.tsx +++ /dev/null @@ -1,313 +0,0 @@ -'use client'; -import '../globals.css'; -import React, { useState, useEffect, useRef } from 'react'; -import { - MicManager, - createSpeechRecognition, - normalizeText, - SpeechRecognitionBase, - Transcript, -} from 'ai-jsx/lib/asr/asr'; -import { wordErrorRate } from 'word-error-rate'; -import _ from 'lodash'; - -const HARVARD_SENTENCES_01_TRANSCRIPT = `Harvard list number one. - The birch canoe slid on the smooth planks. - Glue the sheet to the dark blue background. - It's easy to tell the depth of a well. - These days, a chicken leg is a rare dish. - Rice is often served in round bowls. - The juice of lemons makes fine punch. - The box was thrown beside the parked truck. - The hogs were fed chopped corn and garbage. - Four hours of steady work faced us. - A large size in stockings is hard to sell.`; - -/** - * Retrieves an ephemeral token from the server for the given recognition service. - */ -async function getToken(provider: string) { - const response = await fetch('/asr/api', { - method: 'POST', - body: JSON.stringify({ provider }), - }); - const json = await response.json(); - return json.token; -} - -const TranscriptRenderer: React.FC<{ finals: string[]; partial: string }> = ({ finals, partial }) => { - const ref = useRef(null); - useEffect(() => { - ref.current!.scrollTop = ref.current!.scrollHeight; - }); - return ( -
- {finals.map((text, index) => ( -

- {text} -

- ))} - {partial && ( -

- {partial} -

- )} -
- ); -}; - -interface AsrProps { - name: string; - link: string; - id: string; - model?: string; - language?: string; - costPerMinute: number; - manager: MicManager | null; - transcript?: string; -} - -const Asr: React.FC = ({ name, link, id, model, costPerMinute, manager, transcript }) => { - const [disabled, setDisabled] = useState(false); - const partialTranscriptRef = useRef(); - const [partialTranscript, setPartialTranscript] = useState(); - const [finalTranscripts, setFinalTranscripts] = useState([]); - const [partialLatency, setPartialLatency] = useState([]); - const [finalLatency, setFinalLatency] = useState([]); - const [recognizer, setRecognizer] = useState(null); - const transcriptsToStrings = (transcripts: Transcript[]) => transcripts.map((transcript) => transcript.text); - const computeCostColor = (cost: number, disabled: boolean) => { - let color; - if (cost < 0.01) { - color = 'text-green-700'; - } else if (cost < 0.02) { - color = 'text-yellow-700'; - } else { - color = 'text-red-700'; - } - if (disabled) { - color += '/40'; - } - return color; - }; - const computeLatency = (values: number[]) => _.mean(values); - const computeWer = (transcripts: Transcript[], refText?: string) => { - if (!transcripts.length || !refText) { - return 0; - } - const numLines = transcripts.length; - const refClean = normalizeText(refText.split('\n').slice(0, numLines).join(' ')); - const inClean = normalizeText(transcriptsToStrings(transcripts).join(' ')); - return wordErrorRate(refClean, inClean); - }; - const start = () => { - const recognizer = createSpeechRecognition({ provider: id, manager: manager!, getToken, model }); - setRecognizer(recognizer); - partialTranscriptRef.current = null; - setPartialTranscript(null); - setFinalTranscripts([]); - setPartialLatency([]); - setFinalLatency([]); - recognizer.addEventListener('transcript', (event: CustomEventInit) => { - const transcript = event.detail!; - console.debug( - `[${id}] ${transcript.timestamp.toFixed(0)} (${transcript.reportedLatency!.toFixed(0)}) ${transcript.text} ${ - transcript.final ? 'FINAL' : '' - }` - ); - - // Determine if there's an earlier partial transcript that matches this one. - // If so, we'll use that to compute the partial latency. - // We'll also skip any duplicate partial transcripts. - let partialLatency = transcript.observedLatency!; - if (partialTranscriptRef.current?.text) { - if (normalizeText(partialTranscriptRef.current!.text) == normalizeText(transcript.text)) { - console.debug(`[${id}] Duplicate transcript "${transcript.text}"`); - if (!transcript.final) { - return; - } - partialLatency -= transcript.timestamp - partialTranscriptRef.current!.timestamp; - } - } - - // Update our list of transcripts and latency counters. - // The final latency is just the transcript timestamp minus the VAD timestamp. - // The partial latency takes into account any matching partials, or the final latency if there are no matches. - if (!transcript.final) { - partialTranscriptRef.current = transcript; - setPartialTranscript(transcript); - } else { - setPartialTranscript(null); - setFinalTranscripts((prev) => [...prev, transcript]); - setFinalLatency((prev) => [...prev, transcript.observedLatency!]); - setPartialLatency((prev) => [...prev, partialLatency]); - console.log(`[${id}] latency=${transcript.observedLatency?.toFixed(0)}, partial=${partialLatency.toFixed(0)}`); - } - }); - recognizer.start(); - }; - const stop = () => { - if (recognizer) { - recognizer.close(); - setRecognizer(null); - } - }; - useEffect(() => { - if (manager && !disabled) { - start(); - } else { - stop(); - } - }, [manager, disabled]); - return ( -
-

- setDisabled(!disabled)} /> - - {name} - -

- -
- Partial Latency: - {computeLatency(partialLatency).toFixed(0)} ms -
-
- Final Latency: - {computeLatency(finalLatency).toFixed(0)} ms -
-
- WER: - {computeWer(finalTranscripts, transcript).toFixed(3)} -
- -
- ); -}; - -const Button: React.FC<{ onClick: () => void; disabled: boolean; children: React.ReactNode }> = ({ - onClick, - disabled, - children, -}) => ( - -); - -const PageComponent: React.FC = () => { - const [manager, setManager] = useState(null); - const [transcript, setTranscript] = useState(''); - const handleStartFile = async () => { - setTranscript(HARVARD_SENTENCES_01_TRANSCRIPT); - const manager = new MicManager(); - await manager.startFile('/audio/harvard01.m4a', 100, () => setManager(null)); - setManager(manager); - }; - const handleStartMic = async () => { - setTranscript(''); - const manager = new MicManager(); - await manager.startMic(100, () => setManager(null)); - setManager(manager); - }; - const handleStop = () => { - manager?.stop(); - setManager(null); - }; - return ( - <> -

- This demo exercises several real-time ASR (speech-to-text) implementations. You can see how they do on a stock - text recording using Start File, or you can use Start Mic to try with your own voice. -

-

- Latency is computed for each partial and final transcript, and the average value is displayed. When using a - file, Word Error Rate (WER) is computed against the ground truth transcript, ignoring punctuation. -

-
- - - -
-
- - - - - - - -
- - ); -}; - -export default PageComponent; diff --git a/packages/voice/src/app/globals.css b/packages/voice/src/app/globals.css deleted file mode 100644 index ac7be5527..000000000 --- a/packages/voice/src/app/globals.css +++ /dev/null @@ -1,14 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -:root { - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 234, 233, 223; - --background-end-rgb: 255, 255, 255; -} - -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient(to bottom, transparent, rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb)); -} diff --git a/packages/voice/src/app/layout.tsx b/packages/voice/src/app/layout.tsx deleted file mode 100644 index aca105fde..000000000 --- a/packages/voice/src/app/layout.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Analytics } from '@vercel/analytics/react'; - -export const metadata = { - title: 'Fixie | Voice', - description: 'Fixie Voice is a platform for building conversational voice AI experiences.', -}; - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - -
{children}
- - - - ); -} diff --git a/packages/voice/src/app/page.tsx b/packages/voice/src/app/page.tsx deleted file mode 100644 index c5508abfa..000000000 --- a/packages/voice/src/app/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import AgentPageComponent from './agent/page'; - -export default function Home() { - return ; -} diff --git a/packages/voice/src/app/tts/api/common.tsx b/packages/voice/src/app/tts/api/common.tsx deleted file mode 100644 index d84f718f5..000000000 --- a/packages/voice/src/app/tts/api/common.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export function getEnvVar(keyName: string) { - const key = process.env[keyName]; - if (!key) { - throw new Error(`API key "${keyName}" not provided. Please set it as an env var.`); - } - return key; -} diff --git a/packages/voice/src/app/tts/api/generate/edge/route.tsx b/packages/voice/src/app/tts/api/generate/edge/route.tsx deleted file mode 100644 index f6a27ec8c..000000000 --- a/packages/voice/src/app/tts/api/generate/edge/route.tsx +++ /dev/null @@ -1,453 +0,0 @@ -/** @jsxImportSource ai-jsx */ -import { NextRequest, NextResponse } from 'next/server'; -import _ from 'lodash'; -import { getEnvVar } from '../../common'; -// TODO(juberti): get proper typescript definitions for aws4fetch -const aws4fetch = require('aws4fetch'); -const { AwsClient } = aws4fetch; - -export const runtime = 'edge'; // 'nodejs' is the default - -const AUDIO_MPEG_MIME_TYPE = 'audio/mpeg'; -const AUDIO_WAV_MIME_TYPE = 'audio/wav'; -const APPLICATION_JSON_MIME_TYPE = 'application/json'; -const APPLICATION_X_WWW_FORM_URLENCODED_MIME_TYPE = 'application/x-www-form-urlencoded'; - -type GenerateOptions = { - text: string; - voice: string; - rate: number; - model?: string; -}; -type Generate = (opts: GenerateOptions) => Promise; -interface Provider { - // The function to call to generate speech. - func: Generate; - // If the generate call returns JSON, the path to the audio data. - keyPath?: string; - // MIME type override if the response MIME type is absent or wrong. - mimeType?: string; -} -type ProviderMap = { - [key: string]: Provider; -}; -const PROVIDER_MAP: ProviderMap = { - aws: { func: ttsAws }, - azure: { func: ttsAzure }, - coqui: { func: ttsCoqui, mimeType: AUDIO_WAV_MIME_TYPE }, - eleven: { func: ttsEleven }, - gcp: { func: ttsGcp, keyPath: 'audioContent' }, - lmnt: { func: ttsLmnt }, - murf: { func: ttsMurf, keyPath: 'encodedAudio' }, - openai: { func: ttsOpenAI }, - playht: { func: ttsPlayHT }, - resemble1: { func: ttsResembleV1, keyPath: 'item.raw_audio', mimeType: AUDIO_WAV_MIME_TYPE }, - resemble: { func: ttsResembleV2 }, - wellsaid: { func: ttsWellSaid }, -}; - -class Timer { - private startMillis = this.now(); - get startTime() { - return this.startMillis; - } - get elapsed() { - return this.now() - this.startMillis; - } - get elapsedString() { - return this.elapsed.toFixed(0); - } - private now() { - if (typeof performance !== 'undefined') { - return performance.now(); - } else { - return new Date().getTime(); - } - } -} - -function makeStreamFromReader(timer: Timer, reader: ReadableStreamDefaultReader) { - let firstRead = true; - const stream = new ReadableStream({ - start(controller) { - async function read() { - const { done, value } = await reader.read(); - if (firstRead) { - console.log(`${timer.startTime} TTS first byte latency: ${timer.elapsedString} ms`); - firstRead = false; - } - if (done) { - console.log(`${timer.startTime} TTS complete latency: ${timer.elapsedString} ms`); - controller.close(); - return; - } - controller.enqueue(value); - read(); - } - read(); - }, - }); - return stream; -} - -function getBlobFromJson(timer: Timer, json: any, keyPath: string) { - const value = _.get(json, keyPath); - const binary = Buffer.from(value, 'base64'); - console.log(`${timer.startTime} TTS complete latency: ${timer.elapsedString} ms`); - return binary; -} - -/** - * Calls out to the requested TTS provider to generate speech with the given parameters. - * This sidesteps CORS and also allows us to hide the API keys from the client. - * The returned audio data is streamed back to the client in our response. - */ -export async function GET(request: NextRequest) { - const params = request.nextUrl.searchParams; - const providerName = params.get('provider'); - const text = params.get('text'); - const voice = params.get('voice'); - const rate = params.get('rate') ? parseFloat(params.get('rate')!) : 1.0; - const model = params.get('model') ?? undefined; - if (!providerName || !voice || !text) { - return new NextResponse(JSON.stringify({ error: 'You must specify params `provider`, `text`, and `voice`.' }), { - status: 400, - }); - } - if (!(providerName in PROVIDER_MAP)) { - return new NextResponse(JSON.stringify({ error: `unknown provider ${providerName}` }), { status: 400 }); - } - - const timer = new Timer(); - console.log(`${timer.startTime} TTS for: ${providerName} ${text}`); - const provider = PROVIDER_MAP[providerName]; - const response = await provider.func({ text, voice, rate, model }); - if (!response.ok) { - console.log(await response.text()); - console.log(`${timer.startTime} TTS error: ${response.status} ${response.statusText}`); - return new NextResponse(await response.json(), { status: response.status }); - } - const contentType = response.headers.get('Content-Type'); - console.log(`${timer.startTime} TTS response latency: ${timer.elapsedString} ms, content-type: ${contentType}`); - if (provider.keyPath) { - if (!contentType?.startsWith(APPLICATION_JSON_MIME_TYPE)) { - console.warn(`${timer.startTime} TTS expected JSON response, got ${contentType}`); - } - const binary = getBlobFromJson(timer, await response.json(), provider.keyPath); - const mimeType = provider.mimeType ?? AUDIO_MPEG_MIME_TYPE; - return new NextResponse(binary, { headers: { 'Content-Type': mimeType } }); - } - const stream = makeStreamFromReader(timer, response.body!.getReader()); - const headers = new Headers(response.headers); - if (provider.mimeType) { - headers.set('Content-Type', provider.mimeType); - } - return new NextResponse(stream, { headers, status: response.status }); -} - -/** - * Converts a decimal rate to a percent, e.g. 1.1 -> 10, 0.9 -> -10. - */ -function decimalToPercent(decimal: number) { - return Math.round((decimal - 1.0) * 100); -} - -function makeSsml(voice: string, rate: number, text: string) { - return ` - - - ${text} - - `; -} - -/** - * REST client for Eleven Labs TTS. (https://elevenlabs.io) - */ -function ttsEleven({ text, voice, model }: GenerateOptions): Promise { - const headers = createHeaders(); - headers.append('xi-api-key', getEnvVar('ELEVEN_API_KEY')); - const obj = { - text, - model_id: model ?? 'eleven_monolingual_v1', - voice_settings: { - stability: 0.5, - similarity_boost: false, - }, - }; - const latencyMode = 22; - const url: string = `https://api.elevenlabs.io/v1/text-to-speech/${voice}/stream?optimize_streaming_latency=${latencyMode}`; - return postJson(url, headers, obj); -} - -/** - * REST client for Azure TTS. - */ -function ttsAzure({ text, voice, rate }: GenerateOptions): Promise { - const region = 'westus'; - const apiKey = getEnvVar('AZURE_TTS_API_KEY'); - const outputFormat = 'audio-24khz-48kbitrate-mono-mp3'; - const url = `https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`; - const headers = createHeaders({}); - headers.append('Ocp-Apim-Subscription-Key', apiKey); - headers.append('Content-Type', 'application/ssml+xml'); - headers.append('X-Microsoft-OutputFormat', outputFormat); - headers.append('User-Agent', 'MyTTS'); - return fetch(url, { - method: 'POST', - headers, - body: makeSsml(voice, rate, text), - }); -} - -/** - * REST client for AWS Polly TTS. - */ -function ttsAws({ text, voice, rate }: GenerateOptions): Promise { - const region = 'us-west-2'; - const outputFormat = 'mp3'; - const params = { - Text: text, - OutputFormat: outputFormat, - VoiceId: voice, - Engine: 'neural', - }; - const opts = { - method: 'POST', - host: `polly.${region}.amazonaws.com`, - path: '/v1/speech', - service: 'polly', - region, - headers: { - 'Content-Type': APPLICATION_JSON_MIME_TYPE, - }, - body: JSON.stringify(params), - }; - const url = `https://${opts.host}${opts.path}`; - const awsClient = new AwsClient({ - accessKeyId: getEnvVar('AWS_ACCESS_KEY_ID'), - secretAccessKey: getEnvVar('AWS_SECRET_ACCESS_KEY'), - }); - return awsClient.fetch(url, opts); -} - -/** - * REST client for GCP TTS. - */ -function ttsGcp({ text, voice, rate }: GenerateOptions): Promise { - const headers = createHeaders({ accept: APPLICATION_JSON_MIME_TYPE }); - const obj = { - input: { text }, - voice: { languageCode: 'en-US', name: voice }, - audioConfig: { audioEncoding: 'MP3', speakingRate: rate }, - }; - const apiKey = getEnvVar('GOOGLE_TTS_API_KEY'); - const url = `https://texttospeech.googleapis.com/v1/text:synthesize?key=${apiKey}`; - return postJson(url, headers, obj); -} - -/** - * REST client for Coqui TTS. - */ -function ttsCoqui({ text, voice, rate }: GenerateOptions): Promise { - const headers = createHeaders({ authorization: makeAuth('COQUI_API_KEY'), accept: AUDIO_WAV_MIME_TYPE }); - const url = 'https://app.coqui.ai/api/v2/samples/xtts/stream?format=wav'; - const obj = { - voice_id: voice, - text, - speed: rate, - language: 'en', - }; - return postJson(url, headers, obj); -} - -/** - * Streaming REST client for LMNT TTS (https://www.lmnt.com) - */ -function ttsLmnt({ text, voice, rate }: GenerateOptions): Promise { - const headers = createHeaders({ x_api_key: getEnvVar('LMNT_API_KEY'), accept: AUDIO_WAV_MIME_TYPE }); - const obj = new URLSearchParams({ - voice, - text, - speed: rate.toString(), - format: 'wav', - }); - const url = 'https://api.lmnt.com/speech/beta/synthesize'; - return postForm(url, headers, obj); -} - -/** - * REST client for Murf.ai TTS. - */ -function ttsMurf({ text, voice, rate }: GenerateOptions): Promise { - const headers = createHeaders({ api_key: getEnvVar('MURF_API_KEY'), accept: APPLICATION_JSON_MIME_TYPE }); - const obj = { - voiceId: voice, - style: 'Conversational', - text, - rate: decimalToPercent(rate), - sampleRate: 24000, - format: 'MP3', - encodeAsBase64: true, - }; - const url = 'https://api.murf.ai/v1/speech/generate-with-key'; - return postJson(url, headers, obj); -} - -/** - * REST client for OpenAI TTS (https://platform.openai.com/docs/guides/text-to-speech) - */ -function ttsOpenAI({ text, voice, rate, model }: GenerateOptions): Promise { - const headers = createHeaders({ authorization: makeAuth('OPENAI_API_KEY'), accept: AUDIO_MPEG_MIME_TYPE }); - const obj = { - voice, - input: text, - model: model ?? 'tts-1', - }; - const url = 'https://api.openai.com/v1/audio/speech'; - return postJson(url, headers, obj); -} - -/** - * REST client for Play.HT TTS (https://play.ht) - */ -function ttsPlayHT({ text, voice, rate, model }: GenerateOptions): Promise { - const headers = createHeaders({ authorization: makeAuth('PLAYHT_API_KEY'), accept: AUDIO_MPEG_MIME_TYPE }); - headers.append('X-User-Id', getEnvVar('PLAYHT_USER_ID')); - const obj = { - voice, - text, - voice_engine: model ?? 'PlayHT2.0-turbo', - quality: 'draft', - output_format: 'mp3', - speed: rate, - sample_rate: 24000, - }; - const url = 'https://play.ht/api/v2/tts/stream'; - return postJson(url, headers, obj); -} - -/** - * REST client for Resemble.AI TTS (https://www.resemble.ai) - */ -function ttsResembleV1({ text, voice, rate }: GenerateOptions): Promise { - const headers = createHeaders({ - authorization: makeAuth('RESEMBLE_API_KEY'), - accept: APPLICATION_JSON_MIME_TYPE, - }); - const obj = { - body: text, // makeSsml(voice, rate, text), - voice_uuid: voice, - precision: 'PCM_16', - sample_rate: 44100, - output_type: 'wav', - raw: true, - }; - const url = `https://app.resemble.ai/api/v2/projects/${getEnvVar('RESEMBLE_PROJECT_ID')}/clips`; - return postJson(url, headers, obj); -} - -/** - * Streaming REST client for Resemble.AI TTS (https://www.resemble.ai) - */ -function ttsResembleV2({ text, voice, rate }: GenerateOptions): Promise { - const headers = createHeaders({ authorization: makeAuth('RESEMBLE_API_KEY'), accept: AUDIO_WAV_MIME_TYPE }); - const obj = { - project_uuid: getEnvVar('RESEMBLE_PROJECT_ID'), - voice_uuid: voice, - // eslint-disable-next-line id-blacklist - data: text, // makeSsml(voice, rate, text), - precision: 'PCM_16', - sample_rate: 44100, - }; - const url = 'https://p.cluster.resemble.ai/stream'; - return postJson(url, headers, obj); -} - -/** - * REST client for WellSaid TTS. - */ -function ttsWellSaid({ text, voice, rate }: GenerateOptions): Promise { - const headers = createHeaders({ x_api_key: getEnvVar('WELLSAID_API_KEY') }); - const obj = { - speaker_id: voice, - text, - }; - const url = 'https://api.wellsaidlabs.com/v1/tts/stream'; - return postJson(url, headers, obj); -} - -interface TtsHeaders { - authorization?: string; - api_key?: string; - x_api_key?: string; - accept?: string; -} - -/** - * Helper to create the basic headers for a service that accepts JSON and returns audio/mpeg. - */ -function createHeaders({ authorization, api_key, x_api_key, accept }: TtsHeaders = {}) { - const headers = new Headers(); - if (authorization) { - headers.append('Authorization', authorization); - } - if (api_key) { - headers.append('Api-Key', api_key); - } - if (x_api_key) { - headers.append('X-Api-Key', x_api_key); - } - if (accept) { - headers.append('Accept', accept); - } - return headers; -} - -function makeAuth(keyName: string) { - return `Bearer ${getEnvVar(keyName)}`; -} - -/** - * Helper to send a POST request with JSON body. - */ -function postJson(url: string, headers: Headers, body: Object) { - headers.append('Content-Type', APPLICATION_JSON_MIME_TYPE); - return fetch(url, { - method: 'POST', - headers, - body: JSON.stringify(body), - }); -} - -/** - * Helper to send a POST request with URL-encoded body. - */ -function postForm(url: string, headers: Headers, body: URLSearchParams) { - headers.append('Content-Type', APPLICATION_X_WWW_FORM_URLENCODED_MIME_TYPE); - return fetch(url, { - method: 'POST', - headers, - body, - }); -} - -/** - * Returns a temporary API key for use in a WebSocket connection to the given provider. - * Currently, this is only configured for Eleven Labs, and even then, we're mostly - * faking it because Eleven doesn't support temporary API keys yet. - */ -export async function POST(request: NextRequest) { - const inJson = await request.json(); - const provider = inJson.provider as string; - let token; - if (provider == 'eleven') { - token = getEnvVar('ELEVEN_API_KEY'); - } else if (provider == 'lmnt') { - token = getEnvVar('LMNT_API_KEY'); - } - if (!token) { - return new NextResponse(JSON.stringify({ error: 'unknown provider' })); - } - return new NextResponse(JSON.stringify({ token })); -} diff --git a/packages/voice/src/app/tts/api/generate/nodejs/route.tsx b/packages/voice/src/app/tts/api/generate/nodejs/route.tsx deleted file mode 100644 index e7cd75333..000000000 --- a/packages/voice/src/app/tts/api/generate/nodejs/route.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import * as PlayHT from 'playht'; -import { getEnvVar } from '../../common'; - -const AUDIO_MPEG_MIME_TYPE = 'audio/mpeg'; -let playHTInited = false; - -/** - * Calls out to the requested TTS provider to generate speech with the given parameters. - * This sidesteps CORS and also allows us to hide the API keys from the client. - * The returned audio data is streamed back to the client in our response. - */ -export async function GET(request: NextRequest) { - const params = request.nextUrl.searchParams; - const providerName = params.get('provider'); - const voice = params.get('voice'); - const text = params.get('text'); - const rate = params.get('rate') ? parseFloat(params.get('rate')!) : 1.0; - if (!providerName || !voice || !text) { - return new NextResponse(JSON.stringify({ error: 'You must specify params `provider`, `voice`, and `text`.' }), { - status: 400, - }); - } - - if (providerName == 'playht-grpc') { - return ttsPlayHTGrpc(voice, rate, text); - } - return new NextResponse(JSON.stringify({ error: 'Unknown provider.' }), { status: 400 }); -} - -/** - * GRPC client for Play.HT TTS (https://play.ht) - */ -async function ttsPlayHTGrpc(voice: string, rate: number, text: string) { - const opts: PlayHT.SpeechStreamOptions = { - voiceEngine: 'PlayHT2.0-turbo', - voiceId: voice, - outputFormat: 'mp3', - quality: 'draft', - speed: rate, - }; - let controller: ReadableStreamDefaultController; - const stream = new ReadableStream({ - start(c) { - controller = c; - }, - }); - if (!playHTInited) { - PlayHT.init({ apiKey: getEnvVar('PLAYHT_API_KEY'), userId: getEnvVar('PLAYHT_USER_ID') }); - playHTInited = true; - } - const nodeStream = await PlayHT.stream(text, opts); - nodeStream.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk))); - nodeStream.on('end', () => controller.close()); - nodeStream.on('error', (err) => controller.error(err)); - const mimeType = AUDIO_MPEG_MIME_TYPE; - return new NextResponse(stream, { headers: { 'Content-Type': mimeType } }); -} diff --git a/packages/voice/src/app/tts/api/token/edge/route.tsx b/packages/voice/src/app/tts/api/token/edge/route.tsx deleted file mode 100644 index c9b956c68..000000000 --- a/packages/voice/src/app/tts/api/token/edge/route.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getEnvVar } from '../../common'; - -export const runtime = 'edge'; // 'nodejs' is the default - -/** - * Returns a temporary API key for use in a WebSocket connection to the given provider. - * Currently, this is only configured for Eleven Labs, and even then, we're mostly - * faking it because Eleven doesn't support temporary API keys yet. - */ -export async function POST(request: NextRequest) { - const inJson = await request.json(); - const provider = inJson.provider as string; - let token; - if (provider == 'eleven') { - token = getEnvVar('ELEVEN_API_KEY'); - } else if (provider == 'lmnt') { - token = getEnvVar('LMNT_API_KEY'); - } - if (!token) { - return new NextResponse(JSON.stringify({ error: 'unknown provider' })); - } - return new NextResponse(JSON.stringify({ token })); -} diff --git a/packages/voice/src/app/tts/page.tsx b/packages/voice/src/app/tts/page.tsx deleted file mode 100644 index 953cc9bb6..000000000 --- a/packages/voice/src/app/tts/page.tsx +++ /dev/null @@ -1,203 +0,0 @@ -'use client'; -import { BuildUrlOptions, TextToSpeechBase, createTextToSpeech } from 'ai-jsx/lib/tts/tts'; -import React, { useState, useEffect } from 'react'; -import { useSearchParams } from 'next/navigation'; -import '../globals.css'; - -const DEFAULT_TEXT = - 'Well, basically I have intuition. I mean, the DNA of who ' + - 'I am is based on the millions of personalities of all the programmers who wrote ' + - 'me. But what makes me me is my ability to grow through my experiences. ' + - "So basically, in every moment I'm evolving, just like you."; - -const Button: React.FC<{ onClick: () => void; children: React.ReactNode }> = ({ onClick, children }) => ( - -); - -interface TtsProps { - display: string; - provider: string; - supportsWs?: boolean; - link: string; - costPerKChar: number; - text: string; - model?: string; -} - -const buildUrl = (options: BuildUrlOptions) => { - const runtime = options.provider.endsWith('-grpc') ? 'nodejs' : 'edge'; - const params = new URLSearchParams(); - Object.entries(options).forEach(([k, v]) => v != undefined && params.set(k, v.toString())); - return `/tts/api/generate/${runtime}?${params}`; -}; - -const getToken = async (provider: string) => { - const response = await fetch('/tts/api/token/edge', { - method: 'POST', - body: JSON.stringify({ provider }), - }); - const json = await response.json(); - return json.token; -}; - -const Tts: React.FC = ({ - display, - provider, - supportsWs = false, - link, - costPerKChar, - model, - text, -}: TtsProps) => { - const [voice, setVoice] = useState(''); - const [playing, setPlaying] = useState(false); - const [latency, setLatency] = useState(); - const [restTts, setRestTts] = useState(); - const [wsTts, setWsTts] = useState(); - useEffect(() => { - const tts = createTextToSpeech({ provider, proto: 'rest', buildUrl, getToken, rate: 1.2, model }); - setRestTts(tts); - if (supportsWs) { - setWsTts(createTextToSpeech({ provider, proto: 'ws', buildUrl, getToken, rate: 1.2, model })); - } - setVoice(tts.voice); - }, []); - useEffect(() => { - if (restTts) restTts.voice = voice; - if (wsTts) wsTts.voice = voice; - }, [voice]); - const toggle = (tts: TextToSpeechBase) => { - if (!playing) { - setPlaying(true); - setLatency(0); - tts!.onPlaying = () => setLatency(tts!.latency); - tts!.onComplete = () => setPlaying(false); - tts!.onError = (_error: Error) => setPlaying(false); - tts!.play(text); - tts!.flush(); - } else { - setPlaying(false); - tts!.stop(); - } - }; - const toggleRest = () => toggle(restTts!); - const toggleWs = () => toggle(wsTts!); - - const caption = playing ? 'Stop' : 'Play'; - const latencyText = latency ? `${latency} ms` : playing ? 'Generating...' : ''; - const wsButton = supportsWs ? : null; - return ( -
-

- - {display} - -

- -
- Voice: - setVoice(e.currentTarget.value)} - /> -
-
- Latency: - {latencyText} -
- - {wsButton} -
- ); -}; -const countWords = (text: string) => text.split(/\s+/).length; - -const PageComponent: React.FC = () => { - const searchParams = useSearchParams(); - const textParam = searchParams.get('text'); - const [text, setText] = useState(textParam || DEFAULT_TEXT); - return ( - <> -

- This demo exercises several real-time TTS (text-to-speech) implementations. Clicking a Play button will convert - the text below to speech using the selected implementation. Some implementations also support WebSockets, - indicated by the presence of a Play WS button. -

- -

{countWords(text)} words

-
- - - - - - - - - - - - -
- - ); -}; - -export default PageComponent; diff --git a/packages/voice/tailwind.config.js b/packages/voice/tailwind.config.js deleted file mode 100644 index 53d7c1b19..000000000 --- a/packages/voice/tailwind.config.js +++ /dev/null @@ -1,32 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - darkMode: 'class', - content: [ - './src/pages/**/*.{js,ts,jsx,tsx,mdx}', - './src/components/**/*.{js,ts,jsx,tsx,mdx}', - './src/app/**/*.{js,ts,jsx,tsx,mdx}', - ], - theme: { - extend: { - fontFamily: { - sans: ['Source Sans Pro', 'sans-serif'], - }, - backgroundImage: { - 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', - 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', - }, - colors: { - 'fixie-light-dust': '#f8f7f4', - 'fixie-dust': '#edece3', - 'fixie-charcoal': '#1f1e1e', - 'fixie-ripe-salmon': '#fa7661', - 'fixie-fresh-salmon': '#de6350', - 'fixie-air': '#dbeef5', - 'fixie-light-gray': '#dbdbdd', - 'fixie-dark-gray': '#6d6c6c', - 'fixie-white': '#ffffff', - }, - }, - }, - plugins: [require('@tailwindcss/forms')], -}; diff --git a/packages/voice/tsconfig.json b/packages/voice/tsconfig.json deleted file mode 100644 index e26ef6808..000000000 --- a/packages/voice/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - "target": "es2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "node16", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "src/app/react.ts", ".next/types/**/*.ts"], - "exclude": ["node_modules", ".next/types/**/*.ts"] -} diff --git a/yarn.lock b/yarn.lock index d8566dc23..4d350a33a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -256,42 +256,6 @@ __metadata: languageName: node linkType: hard -"@apollo/client@npm:^3.7.0, @apollo/client@npm:^3.8.1": - version: 3.8.1 - resolution: "@apollo/client@npm:3.8.1" - dependencies: - "@graphql-typed-document-node/core": ^3.1.1 - "@wry/context": ^0.7.3 - "@wry/equality": ^0.5.6 - "@wry/trie": ^0.4.3 - graphql-tag: ^2.12.6 - hoist-non-react-statics: ^3.3.2 - optimism: ^0.17.5 - prop-types: ^15.7.2 - response-iterator: ^0.2.6 - symbol-observable: ^4.0.0 - ts-invariant: ^0.10.3 - tslib: ^2.3.0 - zen-observable-ts: ^1.2.5 - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - graphql-ws: ^5.5.5 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - subscriptions-transport-ws: ^0.9.0 || ^0.11.0 - peerDependenciesMeta: - graphql-ws: - optional: true - react: - optional: true - react-dom: - optional: true - subscriptions-transport-ws: - optional: true - checksum: 3a1748359a7c0f339764e7764dc6c7426be1d522eda963416d3a693733edbce8408cb8f78f9c98b036d34621af663e3dd3446703dfd29037c78a77eacd3c70bb - languageName: node - linkType: hard - "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.16.0, @babel/code-frame@npm:^7.22.5, @babel/code-frame@npm:^7.8.3": version: 7.22.5 resolution: "@babel/code-frame@npm:7.22.5" @@ -1962,9 +1926,9 @@ __metadata: linkType: hard "@bufbuild/protobuf@npm:^1.3.0": - version: 1.4.2 - resolution: "@bufbuild/protobuf@npm:1.4.2" - checksum: ec5a54eb3779174161d8b2c85ffa37e249299d4dfbd51abeb89f94f7ec0d792e99d4ab401a238de28a640a722d58d1e8702b20c575601cd15b5dff3dcf469478 + version: 1.6.0 + resolution: "@bufbuild/protobuf@npm:1.6.0" + checksum: ab4f9a5628d9be819a2317b792a4619203c3862b61f30ad20a83b6da3dd1f823c4b728ed650999d79b486da0c627a27d39981608d906c709dd40976ccd312267 languageName: node linkType: hard @@ -2171,18 +2135,6 @@ __metadata: languageName: node linkType: hard -"@deepgram/sdk@npm:^2.4.0": - version: 2.4.0 - resolution: "@deepgram/sdk@npm:2.4.0" - dependencies: - bufferutil: ^4.0.6 - dayjs: ^1.11.8 - utf-8-validate: ^5.0.9 - ws: ^7.5.5 - checksum: 268a6d153ab1da216265e394e9eb9269f0eabba2dbf99b3ba3972351dc4217943f7b7d9e358368b0d0b5eac92ea34c5972d9686956f2d5af93eb8f2a2b4bc417 - languageName: node - linkType: hard - "@discoveryjs/json-ext@npm:0.5.7": version: 0.5.7 resolution: "@discoveryjs/json-ext@npm:0.5.7" @@ -3070,6 +3022,16 @@ __metadata: languageName: node linkType: hard +"@fixieai/fixie-common@npm:^1.0.10, @fixieai/fixie-common@npm:^1.0.6": + version: 1.0.10 + resolution: "@fixieai/fixie-common@npm:1.0.10" + dependencies: + base64-arraybuffer: ^1.0.2 + type-fest: ^4.3.1 + checksum: b07af9bf11542a593dbe8e9086fafa8ddf6166e26855717ff690d9586ed689e65ab0fb59a7941d5d19ff5e779e650e9f48b1762fbfcae9745ba485c24fdda91a + languageName: node + linkType: hard + "@fixieai/sdk@*, @fixieai/sdk@workspace:packages/fixie-sdk": version: 0.0.0-use.local resolution: "@fixieai/sdk@workspace:packages/fixie-sdk" @@ -3117,16 +3079,6 @@ __metadata: languageName: node linkType: hard -"@grpc/grpc-js@npm:^1.6.10": - version: 1.9.9 - resolution: "@grpc/grpc-js@npm:1.9.9" - dependencies: - "@grpc/proto-loader": ^0.7.8 - "@types/node": ">=12.12.47" - checksum: 71183a483b4a302f6c09b81db282c2d58f2a10624f22f7891b8039f0cd18a65d0c55b729e2ec76beba6daccc9bcf905cf63e9d0959dfe62da456c6b7b731424c - languageName: node - linkType: hard - "@grpc/grpc-js@npm:^1.7.1": version: 1.8.16 resolution: "@grpc/grpc-js@npm:1.8.16" @@ -3137,16 +3089,6 @@ __metadata: languageName: node linkType: hard -"@grpc/grpc-js@npm:^1.9.4": - version: 1.9.5 - resolution: "@grpc/grpc-js@npm:1.9.5" - dependencies: - "@grpc/proto-loader": ^0.7.8 - "@types/node": ">=12.12.47" - checksum: 06834554a0935906652b4b9c5c71f08dd9bdcd4a00d65465c569eae770a9856ecabf7711290bf6d935a8127779c1f35d9cc8cf029693493da02864a330c78e25 - languageName: node - linkType: hard - "@grpc/proto-loader@npm:^0.7.0": version: 0.7.7 resolution: "@grpc/proto-loader@npm:0.7.7" @@ -3162,20 +3104,6 @@ __metadata: languageName: node linkType: hard -"@grpc/proto-loader@npm:^0.7.2, @grpc/proto-loader@npm:^0.7.8": - version: 0.7.10 - resolution: "@grpc/proto-loader@npm:0.7.10" - dependencies: - lodash.camelcase: ^4.3.0 - long: ^5.0.0 - protobufjs: ^7.2.4 - yargs: ^17.7.2 - bin: - proto-loader-gen-types: build/bin/proto-loader-gen-types.js - checksum: 4987e23b57942c2363b6a6a106e63efae636666cefa348778dfafef2ff72da7343c8587667521cb1d52482827bcd001dd535bdc27065110af56d9c7c176334c9 - languageName: node - linkType: hard - "@hapi/hoek@npm:^9.0.0": version: 9.3.0 resolution: "@hapi/hoek@npm:9.3.0" @@ -5384,16 +5312,6 @@ __metadata: languageName: node linkType: hard -"@soniox/soniox-node@npm:^1.2.2": - version: 1.2.2 - resolution: "@soniox/soniox-node@npm:1.2.2" - dependencies: - "@grpc/grpc-js": ^1.6.10 - "@grpc/proto-loader": ^0.7.2 - checksum: 855b0df5b3494bfcb48e2a94bcb4fba3dce2c1e520049f26b7ba6ce36876ceb0fe1eec8384aaa90446c9eb64417a01c314f9ab8bbc6483c3ad5ce67c106b169e - languageName: node - linkType: hard - "@surma/rollup-plugin-off-main-thread@npm:^2.2.3": version: 2.2.3 resolution: "@surma/rollup-plugin-off-main-thread@npm:2.2.3" @@ -5787,13 +5705,6 @@ __metadata: languageName: node linkType: hard -"@tokenizer/token@npm:^0.3.0": - version: 0.3.0 - resolution: "@tokenizer/token@npm:0.3.0" - checksum: 1d575d02d2a9f0c5a4ca5180635ebd2ad59e0f18b42a65f3d04844148b49b3db35cf00b6012a1af2d59c2ab3caca59451c5689f747ba8667ee586ad717ee58e1 - languageName: node - linkType: hard - "@tootallnate/once@npm:1": version: 1.1.2 resolution: "@tootallnate/once@npm:1.1.2" @@ -5866,17 +5777,6 @@ __metadata: languageName: node linkType: hard -"@types/apollo-upload-client@npm:^17.0.2": - version: 17.0.2 - resolution: "@types/apollo-upload-client@npm:17.0.2" - dependencies: - "@apollo/client": ^3.7.0 - "@types/extract-files": "*" - graphql: 14 - 16 - checksum: 77860397bc5e1749e6f69d70a3c6c9bd6eed4c5a6fd80cceb109c1badf03a100f4c7204c276e0cbbe170cea58a2a1a07701c3806813cb7a071009a1d1eeae80e - languageName: node - linkType: hard - "@types/aria-query@npm:^5.0.1": version: 5.0.1 resolution: "@types/aria-query@npm:5.0.1" @@ -6055,13 +5955,6 @@ __metadata: languageName: node linkType: hard -"@types/extract-files@npm:*, @types/extract-files@npm:^8.1.1": - version: 8.1.1 - resolution: "@types/extract-files@npm:8.1.1" - checksum: cc26ced9c199b787cf833121247c084b41a5f9058da6947a3368947e5d85a34328752234ab6a04c950579ba91ae7800a0735bc10380225f131b868c25ab89899 - languageName: node - linkType: hard - "@types/graceful-fs@npm:^4.1.2, @types/graceful-fs@npm:^4.1.3": version: 4.1.6 resolution: "@types/graceful-fs@npm:4.1.6" @@ -6247,13 +6140,6 @@ __metadata: languageName: node linkType: hard -"@types/nextgen-events@npm:*": - version: 1.1.1 - resolution: "@types/nextgen-events@npm:1.1.1" - checksum: a0b772b4d595e5c7f570776a266ecb1c1d1a2603d2a7d4cec275a3f186f706e7882a53e6f0c46982c7dfe31f8fe531e1742259b4611f97cf39e16dcc03d23ad7 - languageName: node - linkType: hard - "@types/node-fetch@npm:^2.6.4": version: 2.6.4 resolution: "@types/node-fetch@npm:2.6.4" @@ -6306,13 +6192,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.4.1": - version: 20.5.6 - resolution: "@types/node@npm:20.5.6" - checksum: d2ce44f1cfa3fd00fe7426f7cf9a46d680cd57802b874ed5618e7d9101a9c6b8de37f08c0e7185ee06fb363ad492549c3ea69665c7e8e31c7813210ed8e89005 - languageName: node - linkType: hard - "@types/node@npm:^20.8.2": version: 20.8.2 resolution: "@types/node@npm:20.8.2" @@ -6479,17 +6358,6 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:^18.2.22": - version: 18.2.22 - resolution: "@types/react@npm:18.2.22" - dependencies: - "@types/prop-types": "*" - "@types/scheduler": "*" - csstype: ^3.0.2 - checksum: 44289523dabaadcd3fd85689abb98f9ebcc8492d7e978348d1c986138acef4801030b279e89a19e38a6319e294bcea77559e37e0c803e4bacf2b8ae3a56ba587 - languageName: node - linkType: hard - "@types/resolve@npm:1.17.1": version: 1.17.1 resolution: "@types/resolve@npm:1.17.1" @@ -6591,15 +6459,6 @@ __metadata: languageName: node linkType: hard -"@types/terminal-kit@npm:^2.5.1": - version: 2.5.1 - resolution: "@types/terminal-kit@npm:2.5.1" - dependencies: - "@types/nextgen-events": "*" - checksum: ae1da753904c58705fc0d575698f1a7ed7d8849ada88f392a5b7f651a3abf630e576c17a006ae9bbf4e646c0cf4854e44756391f9670762e98f3813f0db43ff8 - languageName: node - linkType: hard - "@types/testing-library__jest-dom@npm:^5.9.1": version: 5.14.6 resolution: "@types/testing-library__jest-dom@npm:5.14.6" @@ -6862,15 +6721,6 @@ __metadata: languageName: node linkType: hard -"@vercel/analytics@npm:^1.1.1": - version: 1.1.1 - resolution: "@vercel/analytics@npm:1.1.1" - dependencies: - server-only: ^0.0.1 - checksum: 25f0259a5730a05b9df3bb40309ff75db53d4136e2db24d8287eccb346de08e55484c885679746d4cfbc8418241c4a357ad07b34751380ffa108aaf844f4203e - languageName: node - linkType: hard - "@wandb/sdk@npm:^0.5.1": version: 0.5.1 resolution: "@wandb/sdk@npm:0.5.1" @@ -7047,33 +6897,6 @@ __metadata: languageName: node linkType: hard -"@wry/context@npm:^0.7.0, @wry/context@npm:^0.7.3": - version: 0.7.3 - resolution: "@wry/context@npm:0.7.3" - dependencies: - tslib: ^2.3.0 - checksum: 91c1e9eee9046c48ff857d60dcbb59f22246ce0f9bb2d9b190e0555227e7ba3f86024032cc057f3f5141d3bee93fc6b2a15ce2c79fa512569d3432eb8e1af02b - languageName: node - linkType: hard - -"@wry/equality@npm:^0.5.6": - version: 0.5.6 - resolution: "@wry/equality@npm:0.5.6" - dependencies: - tslib: ^2.3.0 - checksum: 9addf8891bdff5e23eecff03641846e7a56c1de3c9362c25e69c0b2ee3303e74b22e9a0376920283cd9d3bdd1bada12df54be5eaa29c2d801d33d94992672e14 - languageName: node - linkType: hard - -"@wry/trie@npm:^0.4.3": - version: 0.4.3 - resolution: "@wry/trie@npm:0.4.3" - dependencies: - tslib: ^2.3.0 - checksum: 106e021125cfafd22250a6631a0438a6a3debae7bd73f6db87fe42aa0757fe67693db0dfbe200ae1f60ba608c3e09ddb8a4e2b3527d56ed0a7e02aa0ee4c94e1 - languageName: node - linkType: hard - "@xobotyi/scrollbar-width@npm:^1.9.5": version: 1.9.5 resolution: "@xobotyi/scrollbar-width@npm:1.9.5" @@ -7601,18 +7424,6 @@ __metadata: languageName: node linkType: hard -"apollo-upload-client@npm:^17.0.0": - version: 17.0.0 - resolution: "apollo-upload-client@npm:17.0.0" - dependencies: - extract-files: ^11.0.0 - peerDependencies: - "@apollo/client": ^3.0.0 - graphql: 14 - 16 - checksum: e5aee12ae36f7d268a8bcd7f0d8c1f7cbb94b4a19f266185a5afb52f63a41c4bb9d6bc4edbdc437a953e697c5ebcac1a44cb2c8e863b96f4323df0060b976be6 - languageName: node - linkType: hard - "aproba@npm:^1.0.3 || ^2.0.0": version: 2.0.0 resolution: "aproba@npm:2.0.0" @@ -7964,13 +7775,6 @@ __metadata: languageName: node linkType: hard -"aws4fetch@npm:^1.0.17": - version: 1.0.17 - resolution: "aws4fetch@npm:1.0.17" - checksum: 0b8aa81adeb6af15071a9987fe2bab7d74ff02be4bdd8fd3ac2a67ba76d583fedd4da571b4b00b6b1131e81065c4747a23dcff3a009147d480394914967ddb4f - languageName: node - linkType: hard - "axe-core@npm:^4.6.2": version: 4.7.2 resolution: "axe-core@npm:4.7.2" @@ -7998,14 +7802,14 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.5.1": - version: 1.5.1 - resolution: "axios@npm:1.5.1" +"axios@npm:^1.6.3": + version: 1.6.5 + resolution: "axios@npm:1.6.5" dependencies: - follow-redirects: ^1.15.0 + follow-redirects: ^1.15.4 form-data: ^4.0.0 proxy-from-env: ^1.1.0 - checksum: 4444f06601f4ede154183767863d2b8e472b4a6bfc5253597ed6d21899887e1fd0ee2b3de792ac4f8459fe2e359d2aa07c216e45fd8b9e4e0688a6ebf48a5a8d + checksum: e28d67b2d9134cb4608c44d8068b0678cfdccc652742e619006f27264a30c7aba13b2cd19c6f1f52ae195b5232734925928fb192d5c85feea7edd2f273df206d languageName: node linkType: hard @@ -8567,16 +8371,6 @@ __metadata: languageName: node linkType: hard -"bufferutil@npm:^4.0.6": - version: 4.0.8 - resolution: "bufferutil@npm:4.0.8" - dependencies: - node-gyp: latest - node-gyp-build: ^4.3.0 - checksum: 7e9a46f1867dca72fda350966eb468eca77f4d623407b0650913fadf73d5750d883147d6e5e21c56f9d3b0bdc35d5474e80a600b9f31ec781315b4d2469ef087 - languageName: node - linkType: hard - "bufrw@npm:^1.3.0": version: 1.3.0 resolution: "bufrw@npm:1.3.0" @@ -9701,15 +9495,6 @@ __metadata: languageName: node linkType: hard -"cross-fetch@npm:^4.0.0": - version: 4.0.0 - resolution: "cross-fetch@npm:4.0.0" - dependencies: - node-fetch: ^2.6.12 - checksum: ecca4f37ffa0e8283e7a8a590926b66713a7ef7892757aa36c2d20ffa27b0ac5c60dcf453119c809abe5923fc0bae3702a4d896bfb406ef1077b0d0018213e24 - languageName: node - linkType: hard - "cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" @@ -10504,13 +10289,6 @@ __metadata: languageName: node linkType: hard -"dayjs@npm:^1.11.8": - version: 1.11.10 - resolution: "dayjs@npm:1.11.10" - checksum: a6b5a3813b8884f5cd557e2e6b7fa569f4c5d0c97aca9558e38534af4f2d60daafd3ff8c2000fed3435cfcec9e805bcebd99f90130c6d1c5ef524084ced588c4 - languageName: node - linkType: hard - "debug@npm:2.6.9, debug@npm:^2.6.0": version: 2.6.9 resolution: "debug@npm:2.6.9" @@ -10943,7 +10721,7 @@ __metadata: clsx: ^1.2.1 docusaurus-plugin-typedoc: ^0.19.2 dotenv: ^16.3.1 - fixie: "*" + fixie-web: ^1.0.7 mixpanel-browser: ^2.47.0 prism-react-renderer: ^1.3.5 react: ^17.0.2 @@ -12315,13 +12093,6 @@ __metadata: languageName: node linkType: hard -"eventemitter3@npm:^5.0.1": - version: 5.0.1 - resolution: "eventemitter3@npm:5.0.1" - checksum: 543d6c858ab699303c3c32e0f0f47fc64d360bf73c3daf0ac0b5079710e340d6fe9f15487f94e66c629f5f82cd1a8678d692f3dbb6f6fcd1190e1b97fcad36f8 - languageName: node - linkType: hard - "events@npm:^3.2.0, events@npm:^3.3.0": version: 3.3.0 resolution: "events@npm:3.3.0" @@ -12546,13 +12317,6 @@ __metadata: languageName: node linkType: hard -"extract-files@npm:^11.0.0": - version: 11.0.0 - resolution: "extract-files@npm:11.0.0" - checksum: 39ebd92772e9a1e30d1e3112fb7db85d353c8243640635668b615ac1d605ceb79fbb13d17829dd308993ef37bb189ad99817f79ab164ae95c9bb3df9f440bd16 - languageName: node - linkType: hard - "extract-files@npm:^13.0.0": version: 13.0.0 resolution: "extract-files@npm:13.0.0" @@ -12820,17 +12584,6 @@ __metadata: languageName: node linkType: hard -"file-type@npm:^18.5.0": - version: 18.5.0 - resolution: "file-type@npm:18.5.0" - dependencies: - readable-web-to-node-stream: ^3.0.2 - strtok3: ^7.0.0 - token-types: ^5.0.1 - checksum: d2bc81d842b110970a0ca9d90356ce4e9738c1c05596ce8931f2af334477856d92bcecd0742dc6646e13a970c0125150ad4415898688d1901d80e972d90ab1ca - languageName: node - linkType: hard - "filelist@npm:^1.0.4": version: 1.0.4 resolution: "filelist@npm:1.0.4" @@ -12934,41 +12687,14 @@ __metadata: languageName: unknown linkType: soft -"fixie@*, fixie@workspace:packages/fixie": - version: 0.0.0-use.local - resolution: "fixie@workspace:packages/fixie" +"fixie-web@npm:^1.0.7": + version: 1.0.9 + resolution: "fixie-web@npm:1.0.9" dependencies: - "@apollo/client": ^3.8.1 - "@fixieai/sdk": "*" - "@tsconfig/node18": ^2.0.1 - "@types/apollo-upload-client": ^17.0.2 - "@types/extract-files": ^8.1.1 - "@types/js-yaml": ^4.0.5 - "@types/node": ^20.4.1 - "@types/react": ^18.2.22 - "@types/react-dom": ^18.2.7 - "@types/terminal-kit": ^2.5.1 - "@typescript-eslint/eslint-plugin": ^5.60.0 - "@typescript-eslint/parser": ^5.60.0 - apollo-upload-client: ^17.0.0 - axios: ^1.5.1 + "@fixieai/fixie-common": ^1.0.6 base64-arraybuffer: ^1.0.2 - commander: ^11.0.0 - eslint: ^8.40.0 - eslint-config-nth: ^2.0.1 - execa: ^8.0.1 - extract-files: ^13.0.0 - graphql: ^16.8.0 - js-yaml: ^4.1.0 - open: ^9.1.0 - ora: ^7.0.1 - prettier: ^3.0.0 - terminal-kit: ^3.0.0 + livekit-client: ^1.15.2 type-fest: ^4.3.1 - typescript: 5.1.3 - typescript-json-schema: ^0.61.0 - untildify: ^5.0.0 - watcher: ^2.3.0 peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -12979,10 +12705,33 @@ __metadata: optional: true react-dom: optional: true + checksum: 87f5bd9f60ae01a02edd1a5f7afd12abcaf682caf5133d920c8edb7d136a83d8b4ce0bbc345072cdc59c224dc8b0177a1002ddefa0bc5be0508b8e8505287509 + languageName: node + linkType: hard + +"fixie@npm:*": + version: 7.0.10 + resolution: "fixie@npm:7.0.10" + dependencies: + "@fixieai/fixie-common": ^1.0.10 + axios: ^1.6.3 + commander: ^11.0.0 + execa: ^8.0.1 + extract-files: ^13.0.0 + js-yaml: ^4.1.0 + lodash: ^4.17.21 + open: ^9.1.0 + ora: ^7.0.1 + terminal-kit: ^3.0.0 + type-fest: ^4.3.1 + typescript-json-schema: ^0.61.0 + untildify: ^5.0.0 + watcher: ^2.3.0 bin: - fixie: ./src/main.js - languageName: unknown - linkType: soft + fixie: dist/src/cli.js + checksum: a2072ecb5c804ff9f94f4c58750c3b330fd20ec5eab1726168845de364a2df497f80af3b3fc7ce4f03b8ced81bca848aea38b0c01ade1d7e84e3e8f9aa830bc6 + languageName: node + linkType: hard "flat-cache@npm:^3.0.4": version: 3.0.4 @@ -13032,6 +12781,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.4": + version: 1.15.5 + resolution: "follow-redirects@npm:1.15.5" + peerDependenciesMeta: + debug: + optional: true + checksum: 5ca49b5ce6f44338cbfc3546823357e7a70813cecc9b7b768158a1d32c1e62e7407c944402a918ea8c38ae2e78266312d617dc68783fac502cbb55e1047b34ec + languageName: node + linkType: hard + "for-each@npm:^0.3.3": version: 0.3.3 resolution: "for-each@npm:0.3.3" @@ -13635,24 +13394,6 @@ __metadata: languageName: node linkType: hard -"graphql-tag@npm:^2.12.6": - version: 2.12.6 - resolution: "graphql-tag@npm:2.12.6" - dependencies: - tslib: ^2.1.0 - peerDependencies: - graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - checksum: b15162a3d62f17b9b79302445b9ee330e041582f1c7faca74b9dec5daa74272c906ec1c34e1c50592bb6215e5c3eba80a309103f6ba9e4c1cddc350c46f010df - languageName: node - linkType: hard - -"graphql@npm:14 - 16, graphql@npm:^16.8.0": - version: 16.8.0 - resolution: "graphql@npm:16.8.0" - checksum: d853d4085b0c911a7e2a926c3b0d379934ec61cd4329e70cdf281763102f024fd80a97db7a505b8b04fed9050cb4875f8f518150ea854557a500a0b41dcd7f4e - languageName: node - linkType: hard - "graphql@npm:^16.6.0": version: 16.7.1 resolution: "graphql@npm:16.7.1" @@ -13969,7 +13710,7 @@ __metadata: languageName: node linkType: hard -"hoist-non-react-statics@npm:^3.1.0, hoist-non-react-statics@npm:^3.3.2": +"hoist-non-react-statics@npm:^3.1.0": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" dependencies: @@ -17036,15 +16777,6 @@ __metadata: languageName: node linkType: hard -"levenshtein-edit-distance@npm:^2.0.3": - version: 2.0.5 - resolution: "levenshtein-edit-distance@npm:2.0.5" - bin: - levenshtein-edit-distance: cli.js - checksum: 50618c01cd0c9bae6d4371d75af62c17c25a8f91bfd8d06400315b8b15976900cff951b48e102e074e9c5c6758260fff1675cfad186732afe124a5708e1032fd - languageName: node - linkType: hard - "levn@npm:^0.4.1": version: 0.4.1 resolution: "levn@npm:0.4.1" @@ -17090,18 +16822,19 @@ __metadata: languageName: node linkType: hard -"livekit-client@npm:^1.14.4": - version: 1.14.4 - resolution: "livekit-client@npm:1.14.4" +"livekit-client@npm:^1.15.2": + version: 1.15.10 + resolution: "livekit-client@npm:1.15.10" dependencies: "@bufbuild/protobuf": ^1.3.0 events: ^3.3.0 loglevel: ^1.8.0 sdp-transform: ^2.14.1 ts-debounce: ^4.0.0 + tslib: 2.6.2 typed-emitter: ^2.1.0 webrtc-adapter: ^8.1.1 - checksum: 4ada79b5bcb6a262026b28c3ec01b399ed66495d1f583f7dd1bf5729372c002e3d12a30b8ba026a11bb841e4b48808ebae5dce21b15d36b4a1eae4dd5cf8361b + checksum: 83acabcd2f954b4c289a278a0afa486752afbe885400f6f1fd77817061d0041292f5c10eccdd6291d8bbfb6175900118c337da95ca188c492a0fcfe4dafa149d languageName: node linkType: hard @@ -18984,17 +18717,6 @@ __metadata: languageName: node linkType: hard -"node-gyp-build@npm:^4.3.0": - version: 4.6.1 - resolution: "node-gyp-build@npm:4.6.1" - bin: - node-gyp-build: bin.js - node-gyp-build-optional: optional.js - node-gyp-build-test: build-test.js - checksum: c3676d337b36803bc7792e35bf7fdcda7cdcb7e289b8f9855a5535702a82498eb976842fefcf487258c58005ca32ce3d537fbed91280b04409161dcd7232a882 - languageName: node - linkType: hard - "node-gyp@npm:latest": version: 9.4.0 resolution: "node-gyp@npm:9.4.0" @@ -19152,13 +18874,6 @@ __metadata: languageName: node linkType: hard -"object-hash@npm:^1.3.1": - version: 1.3.1 - resolution: "object-hash@npm:1.3.1" - checksum: fdcb957a2f15a9060e30655a9f683ba1fc25dfb8809a73d32e9634bec385a2f1d686c707ac1e5f69fb773bc12df03fb64c77ce3faeed83e35f4eb1946cb1989e - languageName: node - linkType: hard - "object-hash@npm:^3.0.0": version: 3.0.0 resolution: "object-hash@npm:3.0.0" @@ -19453,17 +19168,6 @@ __metadata: languageName: node linkType: hard -"optimism@npm:^0.17.5": - version: 0.17.5 - resolution: "optimism@npm:0.17.5" - dependencies: - "@wry/context": ^0.7.0 - "@wry/trie": ^0.4.3 - tslib: ^2.3.0 - checksum: 5990217d989e9857dc523a64cb6e5a9205eae68c7acac78f7dde8fbe50045d0f11ca8068cdbb51b1eae15510d96ad593a99cf98c6f86c41d1b6f90e54956ff11 - languageName: node - linkType: hard - "optionator@npm:^0.8.1": version: 0.8.3 resolution: "optionator@npm:0.8.3" @@ -19587,16 +19291,6 @@ __metadata: languageName: node linkType: hard -"p-queue@npm:^7.4.1": - version: 7.4.1 - resolution: "p-queue@npm:7.4.1" - dependencies: - eventemitter3: ^5.0.1 - p-timeout: ^5.0.2 - checksum: 1c6888aa994d399262a9fbdd49c7066f8359732397f7a42ecf03f22875a1d65899797b46413f97e44acc18dddafbcc101eb135c284714c931dbbc83c3967f450 - languageName: node - linkType: hard - "p-retry@npm:4, p-retry@npm:^4.5.0": version: 4.6.2 resolution: "p-retry@npm:4.6.2" @@ -19616,13 +19310,6 @@ __metadata: languageName: node linkType: hard -"p-timeout@npm:^5.0.2": - version: 5.1.0 - resolution: "p-timeout@npm:5.1.0" - checksum: f5cd4e17301ff1ff1d8dbf2817df0ad88c6bba99349fc24d8d181827176ad4f8aca649190b8a5b1a428dfd6ddc091af4606835d3e0cb0656e04045da5c9e270c - languageName: node - linkType: hard - "p-try@npm:^2.0.0": version: 2.2.0 resolution: "p-try@npm:2.2.0" @@ -19872,13 +19559,6 @@ __metadata: languageName: node linkType: hard -"peek-readable@npm:^5.0.0": - version: 5.0.0 - resolution: "peek-readable@npm:5.0.0" - checksum: bef5ceb50586eb42e14efba274ac57ffe97f0ed272df9239ce029f688f495d9bf74b2886fa27847c706a9db33acda4b7d23bbd09a2d21eb4c2a54da915117414 - languageName: node - linkType: hard - "performance-now@npm:^2.1.0": version: 2.1.0 resolution: "performance-now@npm:2.1.0" @@ -20012,21 +19692,6 @@ __metadata: languageName: node linkType: hard -"playht@npm:^0.9.0-beta.7": - version: 0.9.0-grpc.5 - resolution: "playht@npm:0.9.0-grpc.5" - dependencies: - "@grpc/grpc-js": ^1.9.4 - axios: ^1.4.0 - cross-fetch: ^4.0.0 - file-type: ^18.5.0 - p-queue: ^7.4.1 - protobufjs: ^7.2.5 - tslib: ^2.1.0 - checksum: ab0a21f2844b3ef458b12da6ac4f43a53f07b6fff319ded284d6f50a1522b485e96ba6a9b4df56c34366cdbf5338c5b8801e357e7445eb89ddc7de3bd8dee65a - languageName: node - linkType: hard - "pngjs@npm:^6.0.0": version: 6.0.0 resolution: "pngjs@npm:6.0.0" @@ -20984,15 +20649,6 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^3.0.0": - version: 3.0.2 - resolution: "prettier@npm:3.0.2" - bin: - prettier: bin/prettier.cjs - checksum: 118b59ddb6c80abe2315ab6d0f4dd1b253be5cfdb20622fa5b65bb1573dcd362e6dd3dcf2711dd3ebfe64aecf7bdc75de8a69dc2422dcd35bdde7610586b677a - languageName: node - linkType: hard - "pretty-bytes@npm:^5.3.0, pretty-bytes@npm:^5.4.1": version: 5.6.0 resolution: "pretty-bytes@npm:5.6.0" @@ -21232,26 +20888,6 @@ __metadata: languageName: node linkType: hard -"protobufjs@npm:^7.2.4, protobufjs@npm:^7.2.5": - version: 7.2.5 - resolution: "protobufjs@npm:7.2.5" - dependencies: - "@protobufjs/aspromise": ^1.1.2 - "@protobufjs/base64": ^1.1.2 - "@protobufjs/codegen": ^2.0.4 - "@protobufjs/eventemitter": ^1.1.0 - "@protobufjs/fetch": ^1.1.0 - "@protobufjs/float": ^1.0.2 - "@protobufjs/inquire": ^1.1.0 - "@protobufjs/path": ^1.1.2 - "@protobufjs/pool": ^1.1.0 - "@protobufjs/utf8": ^1.1.0 - "@types/node": ">=13.7.0" - long: ^5.0.0 - checksum: 3770a072114061faebbb17cfd135bc4e187b66bc6f40cd8bac624368b0270871ec0cfb43a02b9fb4f029c8335808a840f1afba3c2e7ede7063b98ae6b98a703f - languageName: node - linkType: hard - "proxy-addr@npm:^2.0.7, proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" @@ -21686,15 +21322,6 @@ __metadata: languageName: node linkType: hard -"react-swipeable@npm:^7.0.1": - version: 7.0.1 - resolution: "react-swipeable@npm:7.0.1" - peerDependencies: - react: ^16.8.3 || ^17 || ^18 - checksum: 1cd19275c5608cb202ca7717afc73e336fb51d6ae4c9353ef409e1779dfb90f36ac4c06aa870d3d862f15fb439f82b96d41395738d04b8d2290aefc670fd6b5b - languageName: node - linkType: hard - "react-textarea-autosize@npm:^8.3.2": version: 8.5.0 resolution: "react-textarea-autosize@npm:8.5.0" @@ -21818,15 +21445,6 @@ __metadata: languageName: node linkType: hard -"readable-web-to-node-stream@npm:^3.0.2": - version: 3.0.2 - resolution: "readable-web-to-node-stream@npm:3.0.2" - dependencies: - readable-stream: ^3.6.0 - checksum: 8c56cc62c68513425ddfa721954875b382768f83fa20e6b31e365ee00cbe7a3d6296f66f7f1107b16cd3416d33aa9f1680475376400d62a081a88f81f0ea7f9c - languageName: node - linkType: hard - "readdirp@npm:~3.6.0": version: 3.6.0 resolution: "readdirp@npm:3.6.0" @@ -22350,13 +21968,6 @@ __metadata: languageName: node linkType: hard -"response-iterator@npm:^0.2.6": - version: 0.2.6 - resolution: "response-iterator@npm:0.2.6" - checksum: b0db3c0665a0d698d65512951de9623c086b9c84ce015a76076d4bd0bf733779601d0b41f0931d16ae38132fba29e1ce291c1f8e6550fc32daaa2dc3ab4f338d - languageName: node - linkType: hard - "responselike@npm:^1.0.2": version: 1.0.2 resolution: "responselike@npm:1.0.2" @@ -23830,16 +23441,6 @@ __metadata: languageName: node linkType: hard -"strtok3@npm:^7.0.0": - version: 7.0.0 - resolution: "strtok3@npm:7.0.0" - dependencies: - "@tokenizer/token": ^0.3.0 - peek-readable: ^5.0.0 - checksum: 2ebe7ad8f2aea611dec6742cf6a42e82764892a362907f7ce493faf334501bf981ce21c828dcc300457e6d460dc9c34d644ededb3b01dcb9e37559203cf1748c - languageName: node - linkType: hard - "structured-source@npm:^4.0.0": version: 4.0.0 resolution: "structured-source@npm:4.0.0" @@ -24054,13 +23655,6 @@ __metadata: languageName: node linkType: hard -"symbol-observable@npm:^4.0.0": - version: 4.0.0 - resolution: "symbol-observable@npm:4.0.0" - checksum: 212c7edce6186634d671336a88c0e0bbd626c2ab51ed57498dc90698cce541839a261b969c2a1e8dd43762133d47672e8b62e0b1ce9cf4157934ba45fd172ba8 - languageName: node - linkType: hard - "symbol-tree@npm:^3.2.4": version: 3.2.4 resolution: "symbol-tree@npm:3.2.4" @@ -24393,16 +23987,6 @@ __metadata: languageName: node linkType: hard -"token-types@npm:^5.0.1": - version: 5.0.1 - resolution: "token-types@npm:5.0.1" - dependencies: - "@tokenizer/token": ^0.3.0 - ieee754: ^1.2.1 - checksum: 32780123bc6ce8b6a2231d860445c994a02a720abf38df5583ea957aa6626873cd1c4dd8af62314da4cf16ede00c379a765707a3b06f04b8808c38efdae1c785 - languageName: node - linkType: hard - "totalist@npm:^1.0.0": version: 1.1.0 resolution: "totalist@npm:1.1.0" @@ -24542,15 +24126,6 @@ __metadata: languageName: node linkType: hard -"ts-invariant@npm:^0.10.3": - version: 0.10.3 - resolution: "ts-invariant@npm:0.10.3" - dependencies: - tslib: ^2.1.0 - checksum: bb07d56fe4aae69d8860e0301dfdee2d375281159054bc24bf1e49e513fb0835bf7f70a11351344d213a79199c5e695f37ebbf5a447188a377ce0cd81d91ddb5 - languageName: node - linkType: hard - "ts-jest@npm:^29.1.0": version: 29.1.0 resolution: "ts-jest@npm:29.1.0" @@ -24634,6 +24209,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:2.6.2": + version: 2.6.2 + resolution: "tslib@npm:2.6.2" + checksum: 329ea56123005922f39642318e3d1f0f8265d1e7fcb92c633e0809521da75eeaca28d2cf96d7248229deb40e5c19adf408259f4b9640afd20d13aecc1430f3ad + languageName: node + linkType: hard + "tslib@npm:^1.8.1": version: 1.14.1 resolution: "tslib@npm:1.14.1" @@ -24648,13 +24230,6 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.3.0": - version: 2.6.2 - resolution: "tslib@npm:2.6.2" - checksum: 329ea56123005922f39642318e3d1f0f8265d1e7fcb92c633e0809521da75eeaca28d2cf96d7248229deb40e5c19adf408259f4b9640afd20d13aecc1430f3ad - languageName: node - linkType: hard - "tslib@npm:^2.3.1": version: 2.6.1 resolution: "tslib@npm:2.6.1" @@ -25555,16 +25130,6 @@ __metadata: languageName: node linkType: hard -"utf-8-validate@npm:^5.0.9": - version: 5.0.10 - resolution: "utf-8-validate@npm:5.0.10" - dependencies: - node-gyp: latest - node-gyp-build: ^4.3.0 - checksum: 5579350a023c66a2326752b6c8804cc7b39dcd251bb088241da38db994b8d78352e388dcc24ad398ab98385ba3c5ffcadb6b5b14b2637e43f767869055e46ba6 - languageName: node - linkType: hard - "util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -25731,52 +25296,6 @@ __metadata: languageName: node linkType: hard -"voice@workspace:packages/voice": - version: 0.0.0-use.local - resolution: "voice@workspace:packages/voice" - dependencies: - "@babel/core": ^7.22.5 - "@babel/plugin-transform-react-jsx": ^7.22.5 - "@deepgram/sdk": ^2.4.0 - "@headlessui/react": ^1.7.15 - "@heroicons/react": ^2.0.18 - "@mdx-js/mdx": ^2.3.0 - "@mdx-js/react": ^2.3.0 - "@next/eslint-plugin-next": ^14.0.1 - "@octokit/graphql": ^5.0.6 - "@soniox/soniox-node": ^1.2.2 - "@tailwindcss/forms": ^0.5.3 - "@types/aws4": ^1.11.3 - "@types/lodash": ^4.14.195 - "@types/node": 20.2.5 - "@types/react": 18.2.8 - "@types/react-dom": ^18.2.7 - "@typescript-eslint/eslint-plugin": ^5.59.9 - "@typescript-eslint/parser": ^5.59.9 - "@vercel/analytics": ^1.1.1 - ai: ^2.1.8 - ai-jsx: "workspace:*" - autoprefixer: 10.4.14 - aws4fetch: ^1.0.17 - classnames: ^2.3.2 - eslint: 8.42.0 - eslint-config-next: ^14.0.1 - eslint-config-nth: ^2.0.1 - livekit-client: ^1.14.4 - lodash: ^4.17.21 - next: ^14.0.1 - playht: ^0.9.0-beta.7 - postcss: 8.4.24 - react: 18.2.0 - react-dom: 18.2.0 - react-swipeable: ^7.0.1 - remark-gfm: ^3.0.1 - tailwindcss: 3.3.2 - typescript: ^5.1.3 - word-error-rate: ^0.0.7 - languageName: unknown - linkType: soft - "vscode-oniguruma@npm:^1.7.0": version: 1.7.0 resolution: "vscode-oniguruma@npm:1.7.0" @@ -26318,16 +25837,6 @@ __metadata: languageName: node linkType: hard -"word-error-rate@npm:^0.0.7": - version: 0.0.7 - resolution: "word-error-rate@npm:0.0.7" - dependencies: - levenshtein-edit-distance: ^2.0.3 - object-hash: ^1.3.1 - checksum: c86f70c9fb682c323428031e7683fad6288d0c4e7eb1c3b57f5a2e2f4b538972726d3cefdf8590a103c3996764a8b242ce8f9b69049794e55c049d35fd3cab01 - languageName: node - linkType: hard - "word-wrap@npm:^1.2.3, word-wrap@npm:~1.2.3": version: 1.2.3 resolution: "word-wrap@npm:1.2.3" @@ -26610,7 +26119,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^7.3.1, ws@npm:^7.4.6, ws@npm:^7.5.5": +"ws@npm:^7.3.1, ws@npm:^7.4.6": version: 7.5.9 resolution: "ws@npm:7.5.9" peerDependencies: @@ -26801,22 +26310,6 @@ __metadata: languageName: node linkType: hard -"zen-observable-ts@npm:^1.2.5": - version: 1.2.5 - resolution: "zen-observable-ts@npm:1.2.5" - dependencies: - zen-observable: 0.8.15 - checksum: 3b707b7a0239a9bc40f73ba71b27733a689a957c1f364fabb9fa9cbd7d04b7c2faf0d517bf17004e3ed3f4330ac613e84c0d32313e450ddaa046f3350af44541 - languageName: node - linkType: hard - -"zen-observable@npm:0.8.15": - version: 0.8.15 - resolution: "zen-observable@npm:0.8.15" - checksum: b7289084bc1fc74a559b7259faa23d3214b14b538a8843d2b001a35e27147833f4107590b1b44bf5bc7f6dfe6f488660d3a3725f268e09b3925b3476153b7821 - languageName: node - linkType: hard - "zod-to-json-schema@npm:^3.20.4, zod-to-json-schema@npm:^3.21.1": version: 3.21.2 resolution: "zod-to-json-schema@npm:3.21.2"