From 04afd5700fe8e8d3e90aee841570a15466745633 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 28 Oct 2026 14:51:07 +0000 Subject: [PATCH 1/2] feat: AI copilot that sits in the call (paid tier, free models first) An optional in-call AI agent for one owner, on web, desktop, CLI, API and MCP (plans/MEETING-COPILOT.md). Roofle parity: role presets, live transcript by speaker, suggested next question with jump-to-line, rolling notes (summary, key points, decisions, action items), open questions tracked until answered, key figures and dates, 30-second recap, private "ask the copilot", one-click follow-ups and a conversation review that says when the call is too thin. Everyone in the meeting sees "AI copilot is listening for ". - Pro/Team (or admin) only. Free self-hosted open-source models are the default path (Whisper via speaches, qwen2.5 via Ollama); paid providers are the fallback or override: pay as you go at provider cost + 10% from a prepaid CoinPay wallet, or the owner's own Anthropic/OpenAI/ElevenLabs keys (AES-256-GCM at rest). Modes: auto, free, byok, cloud. - Audio rides the existing translation uploads: the copilot is an invisible listener in its own live-translate RoomTranslator, so current clients feed it. - Local models get a bounded JSON schema and every provider's output passes guards (one question per suggestion, length caps, no invented answers). - New: @pairux/meeting-copilot (types, roles, hook, shared panel), API under /api/sessions/:id/copilot and /api/copilot, /api/v1/mcp, /copilot pages, /settings/copilot, `pairux copilot ...` CLI, migration 20261028120000_meeting_copilot, strings in all 19 locales. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/electron.vite.config.ts | 4 +- apps/desktop/package.json | 1 + apps/desktop/src/main/agent-cli/api.ts | 174 ++++- apps/desktop/src/main/agent-cli/commands.ts | 19 +- .../src/main/agent-cli/copilot.test.ts | 582 ++++++++++++++ apps/desktop/src/main/agent-cli/copilot.ts | 730 ++++++++++++++++++ apps/desktop/src/main/agent-cli/errors.ts | 2 + apps/desktop/src/main/agent-cli/index.test.ts | 8 + apps/desktop/src/main/agent-cli/index.ts | 3 +- .../components/capture/CapturePreview.tsx | 4 + .../components/copilot/MeetingCopilot.tsx | 40 + apps/desktop/src/renderer/routes/viewer.tsx | 2 + apps/desktop/tailwind.config.js | 6 +- apps/web/messages/ar.json | 137 ++++ apps/web/messages/de.json | 137 ++++ apps/web/messages/en.json | 137 ++++ apps/web/messages/es.json | 137 ++++ apps/web/messages/fr.json | 137 ++++ apps/web/messages/hi.json | 137 ++++ apps/web/messages/id.json | 137 ++++ apps/web/messages/it.json | 137 ++++ apps/web/messages/ja.json | 137 ++++ apps/web/messages/ko.json | 137 ++++ apps/web/messages/nl.json | 137 ++++ apps/web/messages/pl.json | 137 ++++ apps/web/messages/pt.json | 137 ++++ apps/web/messages/ru.json | 137 ++++ apps/web/messages/sv.json | 137 ++++ apps/web/messages/tr.json | 137 ++++ apps/web/messages/uk.json | 137 ++++ apps/web/messages/vi.json | 137 ++++ apps/web/messages/zh.json | 137 ++++ apps/web/next.config.ts | 6 +- apps/web/package.json | 1 + .../app/api/billing/copilot-topup/route.ts | 64 ++ .../app/api/copilot/[id]/[action]/route.ts | 47 ++ apps/web/src/app/api/copilot/[id]/route.ts | 30 + apps/web/src/app/api/copilot/route.ts | 18 + .../web/src/app/api/copilot/settings/route.ts | 76 ++ apps/web/src/app/api/copilot/wallet/route.ts | 39 + .../sessions/[sessionId]/copilot/ask/route.ts | 29 + .../[sessionId]/copilot/questions/route.ts | 24 + .../[sessionId]/copilot/recap/route.ts | 28 + .../api/sessions/[sessionId]/copilot/route.ts | 136 ++++ .../[sessionId]/copilot/stream/route.ts | 72 ++ .../[sessionId]/translation/feed/route.ts | 16 +- .../sessions/[sessionId]/translation/route.ts | 7 +- apps/web/src/app/api/v1/mcp/route.ts | 104 +++ .../web/src/app/api/webhooks/coinpay/route.ts | 16 +- .../src/app/copilot/[id]/copilot-report.tsx | 258 +++++++ apps/web/src/app/copilot/[id]/page.tsx | 16 + apps/web/src/app/copilot/copilot-list.tsx | 87 +++ apps/web/src/app/copilot/page.tsx | 15 + apps/web/src/app/globals.css | 2 + apps/web/src/app/host/[id]/page.tsx | 4 + apps/web/src/app/session/[id]/page.tsx | 2 + .../app/settings/copilot/copilot-settings.tsx | 284 +++++++ apps/web/src/app/settings/copilot/page.tsx | 15 + apps/web/src/app/view/[sessionId]/page.tsx | 2 + .../src/components/copilot/MeetingCopilot.tsx | 17 + apps/web/src/lib/billing/plan-checkout.ts | 21 +- apps/web/src/lib/copilot/access.ts | 65 ++ apps/web/src/lib/copilot/account.ts | 249 ++++++ apps/web/src/lib/copilot/agent.ts | 370 +++++++++ apps/web/src/lib/copilot/analysis.ts | 191 +++++ apps/web/src/lib/copilot/copilot.test.ts | 440 +++++++++++ apps/web/src/lib/copilot/http.ts | 43 ++ apps/web/src/lib/copilot/llm.ts | 261 +++++++ apps/web/src/lib/copilot/mcp.ts | 145 ++++ apps/web/src/lib/copilot/meeting.ts | 150 ++++ apps/web/src/lib/copilot/pricing.ts | 41 + apps/web/src/lib/copilot/prompts.ts | 151 ++++ apps/web/src/lib/copilot/providers.ts | 88 +++ apps/web/src/lib/copilot/runtime.ts | 263 +++++++ apps/web/src/lib/copilot/store.ts | 175 +++++ apps/web/src/lib/copilot/voice.ts | 326 ++++++++ packages/meeting-copilot/package.json | 44 ++ packages/meeting-copilot/src/CopilotPanel.tsx | 606 +++++++++++++++ packages/meeting-copilot/src/events.test.ts | 71 ++ packages/meeting-copilot/src/events.ts | 71 ++ packages/meeting-copilot/src/index.ts | 6 + packages/meeting-copilot/src/roles.ts | 131 ++++ packages/meeting-copilot/src/strings.ts | 107 +++ packages/meeting-copilot/src/types.ts | 162 ++++ .../meeting-copilot/src/useMeetingCopilot.ts | 235 ++++++ packages/meeting-copilot/tsconfig.json | 14 + packages/meeting-copilot/vitest.config.ts | 9 + plans/MEETING-COPILOT.md | 181 +++++ pnpm-lock.yaml | 30 +- .../20261028120000_meeting_copilot.sql | 151 ++++ 90 files changed, 10366 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src/main/agent-cli/copilot.test.ts create mode 100644 apps/desktop/src/main/agent-cli/copilot.ts create mode 100644 apps/desktop/src/main/agent-cli/errors.ts create mode 100644 apps/desktop/src/renderer/components/copilot/MeetingCopilot.tsx create mode 100644 apps/web/src/app/api/billing/copilot-topup/route.ts create mode 100644 apps/web/src/app/api/copilot/[id]/[action]/route.ts create mode 100644 apps/web/src/app/api/copilot/[id]/route.ts create mode 100644 apps/web/src/app/api/copilot/route.ts create mode 100644 apps/web/src/app/api/copilot/settings/route.ts create mode 100644 apps/web/src/app/api/copilot/wallet/route.ts create mode 100644 apps/web/src/app/api/sessions/[sessionId]/copilot/ask/route.ts create mode 100644 apps/web/src/app/api/sessions/[sessionId]/copilot/questions/route.ts create mode 100644 apps/web/src/app/api/sessions/[sessionId]/copilot/recap/route.ts create mode 100644 apps/web/src/app/api/sessions/[sessionId]/copilot/route.ts create mode 100644 apps/web/src/app/api/sessions/[sessionId]/copilot/stream/route.ts create mode 100644 apps/web/src/app/api/v1/mcp/route.ts create mode 100644 apps/web/src/app/copilot/[id]/copilot-report.tsx create mode 100644 apps/web/src/app/copilot/[id]/page.tsx create mode 100644 apps/web/src/app/copilot/copilot-list.tsx create mode 100644 apps/web/src/app/copilot/page.tsx create mode 100644 apps/web/src/app/settings/copilot/copilot-settings.tsx create mode 100644 apps/web/src/app/settings/copilot/page.tsx create mode 100644 apps/web/src/components/copilot/MeetingCopilot.tsx create mode 100644 apps/web/src/lib/copilot/access.ts create mode 100644 apps/web/src/lib/copilot/account.ts create mode 100644 apps/web/src/lib/copilot/agent.ts create mode 100644 apps/web/src/lib/copilot/analysis.ts create mode 100644 apps/web/src/lib/copilot/copilot.test.ts create mode 100644 apps/web/src/lib/copilot/http.ts create mode 100644 apps/web/src/lib/copilot/llm.ts create mode 100644 apps/web/src/lib/copilot/mcp.ts create mode 100644 apps/web/src/lib/copilot/meeting.ts create mode 100644 apps/web/src/lib/copilot/pricing.ts create mode 100644 apps/web/src/lib/copilot/prompts.ts create mode 100644 apps/web/src/lib/copilot/providers.ts create mode 100644 apps/web/src/lib/copilot/runtime.ts create mode 100644 apps/web/src/lib/copilot/store.ts create mode 100644 apps/web/src/lib/copilot/voice.ts create mode 100644 packages/meeting-copilot/package.json create mode 100644 packages/meeting-copilot/src/CopilotPanel.tsx create mode 100644 packages/meeting-copilot/src/events.test.ts create mode 100644 packages/meeting-copilot/src/events.ts create mode 100644 packages/meeting-copilot/src/index.ts create mode 100644 packages/meeting-copilot/src/roles.ts create mode 100644 packages/meeting-copilot/src/strings.ts create mode 100644 packages/meeting-copilot/src/types.ts create mode 100644 packages/meeting-copilot/src/useMeetingCopilot.ts create mode 100644 packages/meeting-copilot/tsconfig.json create mode 100644 packages/meeting-copilot/vitest.config.ts create mode 100644 plans/MEETING-COPILOT.md create mode 100644 supabase/migrations/20261028120000_meeting_copilot.sql diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 765e7954..8e09fba9 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -22,7 +22,9 @@ export default defineConfig({ // leaving a bare require() for electron-builder to resolve through a // pnpm symlink at package time. It is pure JS; the native nut.js binding // it loads stays external (and asar-unpacked) as before. - externalizeDepsPlugin({ exclude: ['@profullstack/remote-input'] }), + // @pairux/meeting-copilot (used by `pairux copilot`) is ESM with + // import-only exports, which a CommonJS main chunk cannot require(). + externalizeDepsPlugin({ exclude: ['@profullstack/remote-input', '@pairux/meeting-copilot'] }), ], build: { outDir: 'dist/main', diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c6c530ba..1c8a257f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@nut-tree-fork/nut-js": "^4.2.6", + "@pairux/meeting-copilot": "workspace:*", "@pairux/meeting-translation": "workspace:*", "@profullstack/remote-input": "workspace:*", "@radix-ui/react-label": "^2.1.17", diff --git a/apps/desktop/src/main/agent-cli/api.ts b/apps/desktop/src/main/agent-cli/api.ts index 9f2c4408..554e4f46 100644 --- a/apps/desktop/src/main/agent-cli/api.ts +++ b/apps/desktop/src/main/agent-cli/api.ts @@ -1,7 +1,14 @@ /** - * Thin client for the PairUX agent API (/api/v1/agents/*, /api/v1/cli/me). + * Thin client for the PairUX agent API (/api/v1/agents/*, /api/v1/cli/me) and + * the meeting copilot API (/api/copilot/*, /api/sessions/:id/copilot/*). * Responses come back as { data } or { error }, like every PairUX route. */ +import type { + CopilotMode, + CopilotRole, + CopilotState, + CopilotStatus, +} from '@pairux/meeting-copilot/types'; export interface RosterEntry { id: string; @@ -64,9 +71,14 @@ export class PairuxClient { this.fetchImpl = opts.fetchImpl ?? fetch; } - private async request(path: string, init: RequestInit = {}, auth = false): Promise { + /** The PairUX origin this client talks to, without a trailing slash. */ + get baseUrl(): string { + return this.opts.apiUrl; + } + + private async headers(init: RequestInit, auth: boolean, accept = 'application/json') { const headers: Record = { - Accept: 'application/json', + Accept: accept, 'User-Agent': this.opts.userAgent ?? 'pairux', ...(init.body ? { 'Content-Type': 'application/json' } : {}), }; @@ -74,6 +86,11 @@ export class PairuxClient { const token = await this.opts.getAccessToken(); if (token) headers.Authorization = `Bearer ${token}`; } + return headers; + } + + private async request(path: string, init: RequestInit = {}, auth = false): Promise { + const headers = await this.headers(init, auth); const res = await this.fetchImpl(`${this.opts.apiUrl}${path}`, { ...init, headers }); const body = (await res.json().catch(() => ({}))) as { data?: T; error?: string }; if (!res.ok || body.data === undefined) { @@ -116,6 +133,30 @@ export class PairuxClient { }); } + /** Look up a live session by its 6-character join code. */ + lookupJoinCode(joinCode: string) { + return this.request<{ id: string; status?: string; scheduled?: boolean }>( + `/api/sessions/join/${encodeURIComponent(joinCode)}` + ); + } + + /** The copilot API; every call is signed in. */ + copilot(): CopilotApi { + return new CopilotApi(this, (path, init) => this.request(path, init, true)); + } + + /** POST to a text/event-stream endpoint, signed in; the body or an ApiError. */ + async openStream(path: string, signal?: AbortSignal): Promise> { + const init: RequestInit = { method: 'POST', body: '{}', ...(signal ? { signal } : {}) }; + const headers = await this.headers(init, true, 'text/event-stream'); + const res = await this.fetchImpl(`${this.opts.apiUrl}${path}`, { ...init, headers }); + if (!res.ok || !res.body) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new ApiError(res.status, body.error ?? `Request failed (${String(res.status)})`); + } + return res.body; + } + /** Redeem a credit code (adds days of a paid plan). */ redeem(code: string) { return this.request<{ @@ -140,6 +181,133 @@ export class PairuxClient { } } +/** Signed-in calls to the meeting copilot API. */ +export class CopilotApi { + constructor( + private readonly client: PairuxClient, + private readonly call: (path: string, init?: RequestInit) => Promise + ) {} + + private session(sessionId: string, suffix = '') { + return `/api/sessions/${encodeURIComponent(sessionId)}/copilot${suffix}`; + } + + status(sessionId: string) { + return this.call(this.session(sessionId)); + } + + start(sessionId: string, opts: { role?: CopilotRole; mode?: CopilotMode }) { + return this.call(this.session(sessionId), { + method: 'POST', + body: JSON.stringify(opts), + }); + } + + stop(sessionId: string) { + return this.call(this.session(sessionId), { method: 'DELETE' }); + } + + ask(sessionId: string, question: string) { + return this.call<{ answer: string }>(this.session(sessionId, '/ask'), { + method: 'POST', + body: JSON.stringify({ question }), + }); + } + + recap(sessionId: string, seconds?: number) { + return this.call<{ recap: string; seconds: number }>(this.session(sessionId, '/recap'), { + method: 'POST', + body: JSON.stringify(seconds === undefined ? {} : { seconds }), + }); + } + + /** The live event stream; the caller reads it with readSseEvents. */ + stream(sessionId: string, signal?: AbortSignal) { + return this.client.openStream(this.session(sessionId, '/stream'), signal); + } + + list() { + return this.call<{ copilots: CopilotSummary[] }>('/api/copilot'); + } + + get(id: string) { + return this.call<{ copilot: CopilotState; title: string | null }>( + `/api/copilot/${encodeURIComponent(id)}` + ); + } + + write(id: string, what: 'follow-ups' | 'review') { + return this.call>( + `/api/copilot/${encodeURIComponent(id)}/${what}`, + { method: 'POST', body: '{}' } + ); + } + + settings() { + return this.call('/api/copilot/settings'); + } + + saveSettings(patch: CopilotSettingsPatch) { + return this.call('/api/copilot/settings', { + method: 'PUT', + body: JSON.stringify(patch), + }); + } + + wallet() { + return this.call('/api/copilot/wallet'); + } +} + +export const KEY_PROVIDERS = ['anthropic', 'openai', 'elevenlabs'] as const; +export type KeyProvider = (typeof KEY_PROVIDERS)[number]; + +export interface CopilotSummary { + id: string; + sessionId: string; + role: string; + mode: string; + status: string; + title: string | null; + summary: string; + startedAt: string; + endedAt: string | null; +} + +export interface CopilotSettingsView { + mode: CopilotMode; + role: CopilotRole; + keys: Record; + /** GET only. */ + eligible?: boolean; + byokAvailable?: boolean; + balance?: string; + balanceMicros?: number; +} + +export interface CopilotSettingsPatch { + mode?: CopilotMode; + role?: CopilotRole; + keys?: Partial>; +} + +export interface CopilotWallet { + balance: string; + balanceMicros: number; + markup: number; + topups: { id: string; priceUsd: number }[]; + usage: { + sessionId: string; + provider: string; + kind: string; + units: number; + charge: string; + chargeMicros: number; + status: string; + at: string; + }[]; +} + export interface InvitesOverview { quota: { total: number; used: number; left: number }; invites: { diff --git a/apps/desktop/src/main/agent-cli/commands.ts b/apps/desktop/src/main/agent-cli/commands.ts index 2466fe70..b427a4da 100644 --- a/apps/desktop/src/main/agent-cli/commands.ts +++ b/apps/desktop/src/main/agent-cli/commands.ts @@ -11,6 +11,7 @@ * pairux who who is in the session * pairux leave * pairux room link to a fresh anonymous E2EE room + * pairux copilot ... your AI meeting copilot (see ./copilot.ts) * * Every command takes its I/O through `Deps`, so tests drive it end to end * against a fake server without touching the network, the terminal or ~/. @@ -30,6 +31,8 @@ import { } from './auth'; import { ApiError, PairuxClient, type ChatMessage, type RosterEntry } from './api'; import { newRoomLink } from './room'; +import { COPILOT_HELP, runCopilot } from './copilot'; +import { UsageError } from './errors'; const MAX_MESSAGE = 500; @@ -44,6 +47,8 @@ export interface Deps { readStdin: () => Promise; /** Tests stop `listen` after N polls; production runs until 410 or Ctrl-C. */ maxPolls?: number; + /** Tests stop `copilot watch` after N stream connections; production reconnects. */ + maxStreams?: number; login?: typeof listenForCallback; /** The app version, for the User-Agent and `pairux version`. */ version?: string; @@ -72,6 +77,9 @@ Usage: pairux redeem Redeem a credit code (adds days of a paid plan) pairux invites [--json] Your invites left, invites sent and referral link pairux invites create [--note ] Create an invite link (3 per account) + pairux copilot Your AI meeting copilot (Pro/Team): live notes, + suggested questions, recaps, follow-ups, review. + \`pairux copilot help\` lists its commands. Environment: PAIRUX_API_URL default https://pairux.com @@ -105,8 +113,6 @@ function describe(p: RosterEntry): string { return `${p.display_name}${tags ? ` (${tags}${client})` : ''}`; } -class UsageError extends Error {} - export async function run(argv: string[], deps: Deps): Promise { const [command, ...rest] = argv; let config: CliConfig = await loadConfig(deps.configPath); @@ -421,6 +427,15 @@ export async function run(argv: string[], deps: Deps): Promise { } } + case 'copilot': { + if (!rest[0] || ['help', '--help', '-h'].includes(rest[0])) { + deps.out(COPILOT_HELP); + return 0; + } + if (!config.tokens) throw new UsageError('Sign in first: `pairux login`.'); + return await runCopilot(rest, { deps, client, config }); + } + case 'leave': { const agent = requireAgent(); await client.leave(agent.participantId).catch((error: unknown) => { diff --git a/apps/desktop/src/main/agent-cli/copilot.test.ts b/apps/desktop/src/main/agent-cli/copilot.test.ts new file mode 100644 index 00000000..5878a571 --- /dev/null +++ b/apps/desktop/src/main/agent-cli/copilot.test.ts @@ -0,0 +1,582 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { CopilotEvent, CopilotState } from '@pairux/meeting-copilot/types'; +import { run, type Deps } from './commands'; +import { saveConfig, type CliConfig } from './config'; +import { + WatchPrinter, + formatList, + formatNotes, + formatReview, + parseMeetingRef, + readSseEvents, +} from './copilot'; + +interface Call { + method: string; + path: string; + body: string | null; + auth: string | null; +} + +/** A JSON reply, or an SSE body sent as these chunks, or a network failure. */ +type Reply = { status: number; body: unknown } | { sse: string[] } | { fail: string }; +type Handler = (call: Call) => Reply; + +const SESSION = '22222222-2222-4222-8222-222222222222'; +const COPILOT = '33333333-3333-4333-8333-333333333333'; + +function streamOf(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }); +} + +function fakeFetch(handler: Handler, calls: Call[]): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL( + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + ); + const headers = (init?.headers ?? {}) as Record; + const call: Call = { + method: init?.method ?? 'GET', + path: url.pathname + url.search, + body: typeof init?.body === 'string' ? init.body : null, + auth: 'Authorization' in headers ? headers.Authorization : null, + }; + calls.push(call); + const reply = handler(call); + if ('fail' in reply) throw new TypeError(reply.fail); + if ('sse' in reply) { + return new Response(streamOf(reply.sse), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + return new Response(JSON.stringify(reply.body), { status: reply.status }); + }) as typeof fetch; +} + +let dir: string; +let out: string[]; +let err: string[]; +let calls: Call[]; +let sleeps: number[]; + +function deps(handler: Handler, extra: Partial = {}): Deps { + return { + env: { PAIRUX_API_URL: 'https://pairux.test' }, + configPath: join(dir, 'config.json'), + fetchImpl: fakeFetch(handler, calls), + out: (l) => out.push(l), + err: (l) => err.push(l), + openBrowser: () => undefined, + sleep: (ms) => { + sleeps.push(ms); + return Promise.resolve(); + }, + readStdin: () => Promise.resolve(null), + ...extra, + }; +} + +async function signIn(extra: Partial = {}) { + await saveConfig( + { + tokens: { accessToken: 'pux_at_x', refreshToken: 'pux_rt_x', expiresAt: Date.now() + 3e6 }, + ...extra, + }, + join(dir, 'config.json') + ); +} + +function state(over: Partial = {}): CopilotState { + return { + id: COPILOT, + sessionId: SESSION, + role: 'sales', + mode: 'auto', + status: 'live', + notes: { summary: '', keyPoints: [], decisions: [], actionItems: [] }, + suggestions: [], + openQuestions: [], + figures: [], + transcript: [], + followUps: null, + review: null, + providers: { stt: 'whisper (free)', llm: 'claude (own key)' }, + notice: '', + startedAt: '2026-10-28T10:00:00.000Z', + endedAt: null, + ...over, + }; +} + +const sse = (event: CopilotEvent) => `data: ${JSON.stringify(event)}\n\n`; +const line = (id: string, name: string, text: string) => ({ + id, + participant: 'p', + name, + text, + at: 0, + until: 0, +}); + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'pairux-copilot-')); + out = []; + err = []; + calls = []; + sleeps = []; +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('parseMeetingRef', () => { + it('accepts a session id, a join code and URLs carrying either', () => { + expect(parseMeetingRef(SESSION.toUpperCase())).toEqual({ sessionId: SESSION }); + expect(parseMeetingRef('abc123')).toEqual({ joinCode: 'ABC123' }); + expect(parseMeetingRef('https://pairux.com/join/xyz789')).toEqual({ joinCode: 'XYZ789' }); + expect(parseMeetingRef(`https://pairux.com/session/${SESSION}?x=1`)).toEqual({ + sessionId: SESSION, + }); + expect(parseMeetingRef('pairux://join/ABC123')).toEqual({ joinCode: 'ABC123' }); + }); + + it('rejects anything else', () => { + expect(parseMeetingRef('hello world')).toBeNull(); + expect(parseMeetingRef('ABC12')).toBeNull(); + expect(parseMeetingRef('https://pairux.com/pricing')).toBeNull(); + expect(parseMeetingRef('file:///etc/ABC123')).toBeNull(); + }); +}); + +describe('readSseEvents', () => { + it('reassembles events split across chunks and skips heartbeats and junk', async () => { + const body = streamOf([ + ': copilot\n\n', + 'data: {"type":"notice",', + '"message":"hi"}\r\n\r\n', + 'data: not json\n\n', + 'data: {"type":"notice","message":"tail"}', + ]); + const events: unknown[] = []; + for await (const e of readSseEvents(body)) events.push(e); + expect(events).toEqual([ + { type: 'notice', message: 'hi' }, + { type: 'notice', message: 'tail' }, + ]); + }); +}); + +describe('formatting', () => { + it('prints notes with owners, question status and figures', () => { + expect( + formatNotes( + state({ + notes: { + summary: 'Pricing call.', + keyPoints: ['Wants SSO'], + decisions: [], + actionItems: [{ text: 'Send quote', owner: 'Ana', due: 'Friday' }], + }, + openQuestions: [ + { id: 'q1', text: 'Budget?', status: 'answered', answer: '$40k', at: 0 }, + { id: 'q2', text: 'Who signs?', status: 'open', at: 0 }, + ], + figures: [{ id: 'f1', kind: 'money', value: '$40k', context: 'annual budget' }], + }), + 'Acme call' + ) + ).toEqual([ + 'Acme call', + 'Summary: Pricing call.', + 'Key points:', + ' - Wants SSO', + 'Action items:', + ' - Send quote (Ana, due Friday)', + 'Questions:', + ' [answered] Budget? -> $40k', + ' [open] Who signs?', + 'Key figures:', + ' money: $40k (annual budget)', + ]); + }); + + it('says a thin call is too thin to review', () => { + expect( + formatReview({ + thin: true, + focusArea: '', + feedback: [], + talkListen: { you: 0, others: 0 }, + questions: { asked: 0, answered: 0 }, + followUpClarity: 'unclear', + }) + ).toEqual(['Review: too thin to review.']); + expect( + formatReview({ + thin: false, + focusArea: 'Ask about budget earlier', + feedback: ['Good recap at the end'], + talkListen: { you: 0.42, others: 0.58 }, + questions: { asked: 5, answered: 3 }, + followUpClarity: 'clear', + }) + ).toEqual([ + 'Review:', + ' Focus: Ask about budget earlier', + ' Talk/listen: you 42%, others 58%', + ' Questions: 5 asked, 3 answered', + ' Follow-up clarity: clear', + ' - Good recap at the end', + ]); + }); + + it('lines up the history table', () => { + const rows = formatList([ + { + id: COPILOT, + sessionId: SESSION, + role: 'sales', + mode: 'auto', + status: 'ended', + title: 'Acme', + summary: '', + startedAt: '2026-10-28T10:00:00.000Z', + endedAt: null, + }, + ]); + expect(rows[0]).toMatch(/^STARTED\s+STATUS\s+ROLE\s+ID\s+TITLE$/); + expect(rows[1]).toBe(`2026-10-28 10:00 ended sales ${COPILOT} Acme`); + }); + + it('does not repeat lines or suggestions after a reconnect', () => { + const printer = new WatchPrinter({ backlog: 10 }); + const s = state({ + transcript: [line('l1', 'Ana', 'Hi')], + suggestions: [{ id: 's1', question: 'Budget?', reason: 'not asked', at: 0 }], + }); + expect(printer.print({ type: 'state', state: s })).toEqual([ + '* Copilot live as Sales (mode: auto), 1 lines so far.', + '* Providers: speech: whisper (free), notes: claude (own key)', + 'Ana: Hi', + '? Budget? (why: not asked)', + ]); + expect(printer.print({ type: 'line', line: line('l2', 'Bo', 'Hello') })).toEqual(['Bo: Hello']); + // Reconnected: the fresh state replays l1 and l2; only l3 is new. + expect( + printer.print({ + type: 'state', + state: { + ...s, + transcript: [...s.transcript, line('l2', 'Bo', 'Hello'), line('l3', 'Ana', 'So')], + }, + }) + ).toEqual(['Ana: So']); + }); +}); + +describe('pairux copilot', () => { + it('needs a sign-in', async () => { + expect( + await run( + ['copilot', 'list'], + deps(() => ({ status: 500, body: {} })) + ) + ).toBe(2); + expect(err[0]).toContain('pairux login'); + expect(calls).toHaveLength(0); + }); + + it('prints help without signing in', async () => { + expect( + await run( + ['copilot'], + deps(() => ({ status: 500, body: {} })) + ) + ).toBe(0); + expect(out[0]).toContain('AI meeting copilot'); + }); + + it('starts by join code: looks the code up, then starts with the token', async () => { + await signIn(); + const d = deps((call) => + call.path === '/api/sessions/join/ABC123' + ? { status: 200, body: { data: { id: SESSION, status: 'active' } } } + : { status: 200, body: { data: state() } } + ); + expect(await run(['copilot', 'start', 'abc123', '--role', 'sales'], d)).toBe(0); + expect(calls[1]).toMatchObject({ + method: 'POST', + path: `/api/sessions/${SESSION}/copilot`, + auth: 'Bearer pux_at_x', + }); + expect(JSON.parse(calls[1].body!)).toEqual({ role: 'sales' }); + expect(out[0]).toBe('Copilot listening in abc123 as Sales (mode: auto).'); + expect(out).toContain('Listens for:'); + expect(out).toContain('Providers: speech: whisper (free), notes: claude (own key)'); + expect(out.at(-1)).toBe('Run `pairux copilot watch abc123` to follow it live.'); + }); + + it('rejects an unknown role locally', async () => { + await signIn(); + expect( + await run( + ['copilot', 'start', SESSION, '--role', 'pirate'], + deps(() => ({ status: 500, body: {} })) + ) + ).toBe(2); + expect(err[0]).toContain('--role must be one of'); + expect(calls).toHaveLength(0); + }); + + it('defaults the meeting to the joined agent session', async () => { + await signIn({ + agent: { participantId: 'p', sessionId: SESSION, joinCode: 'ABC123', displayName: 'A' }, + }); + const d = deps(() => ({ status: 200, body: { data: { answer: '$40k a year.' } } })); + expect(await run(['copilot', 'ask', 'What', 'was', 'the', 'budget?'], d)).toBe(0); + expect(calls[0].path).toBe(`/api/sessions/${SESSION}/copilot/ask`); + expect(JSON.parse(calls[0].body!)).toEqual({ question: 'What was the budget?' }); + expect(out).toEqual(['$40k a year.']); + }); + + it('stops and prints follow-ups and the review; --json prints the raw state', async () => { + await signIn(); + const final = state({ + status: 'ended', + followUps: { actionItems: [{ text: 'Send quote', owner: 'Ana' }], message: 'Thanks all.' }, + review: { + thin: true, + focusArea: '', + feedback: [], + talkListen: { you: 0, others: 0 }, + questions: { asked: 0, answered: 0 }, + followUpClarity: 'unclear', + }, + }); + const d = deps(() => ({ status: 200, body: { data: final } })); + expect(await run(['copilot', 'stop', SESSION], d)).toBe(0); + expect(calls[0].method).toBe('DELETE'); + expect(out).toContain(' - Send quote (Ana)'); + expect(out).toContain(' Thanks all.'); + expect(out.at(-1)).toBe('Review: too thin to review.'); + out = []; + expect(await run(['copilot', 'stop', SESSION, '--json'], d)).toBe(0); + expect(JSON.parse(out[0])).toMatchObject({ id: COPILOT, status: 'ended' }); + }); + + it('reports a server refusal as a PairUX error', async () => { + await signIn(); + const d = deps(() => ({ + status: 403, + body: { error: 'The AI copilot is part of PairUX Pro.' }, + })); + expect(await run(['copilot', 'start', SESSION], d)).toBe(1); + expect(err).toEqual(['PairUX: The AI copilot is part of PairUX Pro.']); + }); + + it('watches, reconnects after a dropped stream, and exits on ended', async () => { + await signIn(); + let streams = 0; + const d = deps(() => { + streams += 1; + if (streams === 1) { + return { + sse: [ + sse({ type: 'state', state: state() }), + ': copilot\n\n', + sse({ type: 'line', line: line('l1', 'Ana', 'Our budget is 40k') }), + sse({ + type: 'suggestion', + suggestion: { + id: 's1', + question: 'Who approves it?', + reason: 'no decision-maker', + at: 0, + }, + }), + ], + }; + } + if (streams === 2) return { fail: 'fetch failed' }; + return { + sse: [ + sse({ + type: 'state', + state: state({ transcript: [line('l1', 'Ana', 'Our budget is 40k')] }), + }), + sse({ type: 'notice', message: 'Speech paused: no credit.' }), + sse({ type: 'ended', state: state({ status: 'ended' }) }), + ], + }; + }); + expect(await run(['copilot', 'watch', SESSION], d)).toBe(0); + expect(calls.every((c) => c.path === `/api/sessions/${SESSION}/copilot/stream`)).toBe(true); + expect(calls).toHaveLength(3); + expect(sleeps).toEqual([3000, 3000]); + expect(out).toEqual([ + '* Copilot live as Sales (mode: auto), 0 lines so far.', + '* Providers: speech: whisper (free), notes: claude (own key)', + 'Ana: Our budget is 40k', + '? Who approves it? (why: no decision-maker)', + '! Speech paused: no credit.', + '* Copilot ended.', + expect.stringContaining(`pairux copilot followups ${COPILOT}`), + ]); + expect(err[0]).toContain('stream closed; reconnecting in 3 s'); + expect(err[1]).toContain('fetch failed'); + }); + + it('does not retry when the stream is refused', async () => { + await signIn(); + const d = deps(() => ({ + status: 404, + body: { error: 'No copilot is running for you in this meeting.' }, + })); + expect(await run(['copilot', 'watch', SESSION], d)).toBe(1); + expect(calls).toHaveLength(1); + expect(err).toEqual(['PairUX: No copilot is running for you in this meeting.']); + }); + + it('notes by copilot id, falling back to the meeting when the id is a session', async () => { + await signIn(); + const d = deps((call) => { + if (call.path === `/api/copilot/${SESSION}`) + return { status: 404, body: { error: 'Not found' } }; + if (call.path === '/api/copilot') + return { + status: 200, + body: { data: { copilots: [{ id: COPILOT, sessionId: SESSION, status: 'ended' }] } }, + }; + return { + status: 200, + body: { + data: { + copilot: state({ + notes: { summary: 'Done.', keyPoints: [], decisions: [], actionItems: [] }, + }), + title: 'Acme', + }, + }, + }; + }); + expect(await run(['copilot', 'notes', SESSION], d)).toBe(0); + expect(calls.map((c) => c.path)).toEqual([ + `/api/copilot/${SESSION}`, + '/api/copilot', + `/api/copilot/${COPILOT}`, + ]); + expect(out).toEqual(['Acme', 'Summary: Done.']); + }); + + it('rewrites the review for a copilot id', async () => { + await signIn(); + const d = deps((call) => + call.method === 'GET' + ? { status: 200, body: { data: { copilot: state(), title: null } } } + : { + status: 200, + body: { + data: { + followUps: null, + review: { + thin: false, + focusArea: 'Listen more', + feedback: [], + talkListen: { you: 0.7, others: 0.3 }, + questions: { asked: 1, answered: 1 }, + followUpClarity: 'partial', + }, + }, + }, + } + ); + expect(await run(['copilot', 'review', COPILOT], d)).toBe(0); + expect(calls[1]).toMatchObject({ method: 'POST', path: `/api/copilot/${COPILOT}/review` }); + expect(out).toContain(' Talk/listen: you 70%, others 30%'); + }); + + it('updates settings: sets one key, clears another, masks the result', async () => { + await signIn(); + const view = { + mode: 'byok', + role: 'general', + keys: { + anthropic: { set: true, last4: 'wxyz' }, + openai: { set: false, last4: null }, + elevenlabs: { set: false, last4: null }, + }, + }; + const d = deps(() => ({ status: 200, body: { data: view } }), { + readStdin: () => Promise.resolve('sk-ant-secret-wxyz\n'), + }); + expect( + await run( + ['copilot', 'settings', '--mode', 'byok', '--anthropic-key', '-', '--clear-openai-key'], + d + ) + ).toBe(0); + expect(calls[0].method).toBe('PUT'); + expect(JSON.parse(calls[0].body!)).toEqual({ + mode: 'byok', + keys: { anthropic: 'sk-ant-secret-wxyz', openai: null }, + }); + expect(out).toEqual([ + 'Saved.', + 'Mode: byok', + 'Role: general (General)', + 'Own keys:', + ' anthropic: set (…wxyz)', + ' openai: not set', + ' elevenlabs: not set', + ]); + expect(out.join('\n')).not.toContain('secret'); + }); + + it('refuses a key that is both set and cleared', async () => { + await signIn(); + expect( + await run( + ['copilot', 'settings', '--openai-key', 'sk-x', '--clear-openai-key'], + deps(() => ({ status: 500, body: {} })) + ) + ).toBe(2); + expect(calls).toHaveLength(0); + }); + + it('shows the wallet', async () => { + await signIn(); + const d = deps(() => ({ + status: 200, + body: { + data: { + balance: '$4.20', + balanceMicros: 4_200_000, + markup: 1.1, + topups: [ + { id: 'copilot_credit_5', priceUsd: 5 }, + { id: 'copilot_credit_20', priceUsd: 20 }, + ], + usage: [], + }, + }, + })); + expect(await run(['copilot', 'wallet'], d)).toBe(0); + expect(out).toEqual([ + 'Balance: $4.20', + "Pay-as-you-go charges are the provider's cost + 10%.", + 'Top-ups: $5, $20', + 'No charges yet.', + ]); + }); +}); diff --git a/apps/desktop/src/main/agent-cli/copilot.ts b/apps/desktop/src/main/agent-cli/copilot.ts new file mode 100644 index 00000000..9442856d --- /dev/null +++ b/apps/desktop/src/main/agent-cli/copilot.ts @@ -0,0 +1,730 @@ +/** + * `pairux copilot ...`: drive your AI meeting copilot from the terminal. + * + * pairux copilot start ABC123 --role sales + * pairux copilot watch ABC123 live transcript, suggestions, notices + * pairux copilot ask ABC123 "What did they say the budget was?" + * pairux copilot stop ABC123 follow-ups + conversation review + * + * A is a session id, a 6-character join code or a meeting URL; with + * none, the session of the agent this CLI joined (`pairux join`) is used. Every + * call is signed in with the `pairux login` token; the copilot is per user. + */ +import { parseArgs } from 'node:util'; +import { COPILOT_ROLE_DEFS } from '@pairux/meeting-copilot/roles'; +import { + COPILOT_MODES, + COPILOT_ROLES, + isCopilotMode, + isCopilotRole, + type CopilotEvent, + type CopilotNotes, + type CopilotReview, + type CopilotState, + type CopilotFollowUps, +} from '@pairux/meeting-copilot/types'; +import { + ApiError, + KEY_PROVIDERS, + type CopilotApi, + type CopilotSettingsPatch, + type CopilotSettingsView, + type CopilotSummary, + type CopilotWallet, + type PairuxClient, +} from './api'; +import type { CliConfig } from './config'; +import type { Deps } from './commands'; +import { UsageError } from './errors'; + +export const RECONNECT_MS = 3_000; + +export const COPILOT_HELP = `AI meeting copilot (PairUX Pro/Team): listens to a meeting you are in and +keeps notes, suggests questions, answers questions about the call and writes +the follow-ups. Sign in first with \`pairux login\`. + +Usage: + pairux copilot status [meeting] [--json] Who has a copilot listening; yours + pairux copilot start [meeting] [--role R] [--mode M] [--json] + Start (or resume) your copilot + pairux copilot stop [meeting] [--json] Stop it; print follow-ups and review + pairux copilot watch [meeting] [--json] [--all] + Stream it live: transcript, suggested + questions, notes, notices. Reconnects + on its own; Ctrl-C to stop watching. + pairux copilot notes [meeting|copilot-id] [--json] + Notes, open questions, key figures + pairux copilot ask [meeting] [--json] + Ask about the call so far + pairux copilot recap [meeting] [--seconds N] [--json] + What was said in the last N seconds + pairux copilot followups [--json] + (Re)write the follow-ups + pairux copilot review [--json] + (Re)write the conversation review + pairux copilot list [--json] Your past and live copilots + pairux copilot settings [--json] [--mode M] [--role R] + [--anthropic-key K | --clear-anthropic-key] + [--openai-key K | --clear-openai-key] + [--elevenlabs-key K | --clear-elevenlabs-key] + Show or change defaults and own keys + (a key of "-" is read from stdin) + pairux copilot wallet [--json] Pay-as-you-go balance and charges + +A meeting is a session id, a 6-character join code or a meeting URL. Left out, +it is the session of the agent this CLI joined with \`pairux join\`. + +Roles: ${COPILOT_ROLES.join(', ')} +Modes: auto (free models, paid cloud when they fail), free (never charged), + byok (your own keys first), cloud (own key, else pay as you go, first)`; + +// --------------------------------------------------------------------------- +// Meeting references + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const JOIN_CODE = /^[A-Z0-9]{6}$/i; + +export type MeetingRef = { sessionId: string } | { joinCode: string }; + +/** A session id, a join code, or a URL carrying either; null when it is none of them. */ +export function parseMeetingRef(input: string): MeetingRef | null { + const value = input.trim(); + if (UUID.test(value)) return { sessionId: value.toLowerCase() }; + if (JOIN_CODE.test(value)) return { joinCode: value.toUpperCase() }; + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:' && url.protocol !== 'pairux:') { + return null; + } + // pairux://join/ABC123 puts "join" in the host. + const segments = [url.host, ...url.pathname.split('/')].filter(Boolean); + const id = segments.find((s) => UUID.test(s)); + if (id) return { sessionId: id.toLowerCase() }; + const joinAt = segments.indexOf('join'); + const code = joinAt >= 0 ? segments[joinAt + 1] : segments.at(-1); + if (code && JOIN_CODE.test(code)) return { joinCode: code.toUpperCase() }; + const fromQuery = url.searchParams.get('code') ?? url.searchParams.get('joinCode'); + if (fromQuery && JOIN_CODE.test(fromQuery)) return { joinCode: fromQuery.toUpperCase() }; + return null; +} + +// --------------------------------------------------------------------------- +// Server-sent events + +/** + * Parse a text/event-stream body into its `data:` JSON payloads. Comments + * (`: copilot` heartbeats) and malformed events are skipped. + */ +export async function* readSseEvents(body: ReadableStream): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const flush = function* (block: string): Generator { + const data = block + .split('\n') + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()) + .join('\n'); + if (!data) return; + try { + yield JSON.parse(data) as T; + } catch { + /* a malformed event is skipped, not fatal */ + } + }; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, '\n'); + let split = buffer.indexOf('\n\n'); + while (split >= 0) { + yield* flush(buffer.slice(0, split)); + buffer = buffer.slice(split + 2); + split = buffer.indexOf('\n\n'); + } + } + if (buffer.trim()) yield* flush(buffer); + } finally { + reader.releaseLock(); + } +} + +// --------------------------------------------------------------------------- +// Formatting (pure, so tests check the exact text) + +const pct = (share: number) => `${String(Math.round(share * 100))}%`; + +function roleLabel(role: string): string { + return isCopilotRole(role) ? COPILOT_ROLE_DEFS[role].label : role; +} + +export function formatProviders(state: Pick): string | null { + const parts = [ + state.providers.stt ? `speech: ${state.providers.stt}` : null, + state.providers.llm ? `notes: ${state.providers.llm}` : null, + ].filter(Boolean); + return parts.length ? `Providers: ${parts.join(', ')}` : null; +} + +export function formatStarted(state: CopilotState, meeting: string): string[] { + const lines = [ + `Copilot ${state.status === 'live' ? 'listening' : state.status} in ${meeting} as ${roleLabel(state.role)} (mode: ${state.mode}).`, + ]; + if (isCopilotRole(state.role)) { + lines.push('Listens for:'); + for (const item of COPILOT_ROLE_DEFS[state.role].listensFor) lines.push(` - ${item}`); + } + const providers = formatProviders(state); + if (providers) lines.push(providers); + if (state.notice) lines.push(`! ${state.notice}`); + return lines; +} + +export function formatNotes( + state: Pick, + title?: string | null +): string[] { + const n: CopilotNotes = state.notes; + const lines: string[] = []; + if (title) lines.push(title); + lines.push(`Summary: ${n.summary || '(nothing yet)'}`); + const list = (heading: string, items: string[]) => { + if (!items.length) return; + lines.push(`${heading}:`); + for (const item of items) lines.push(` - ${item}`); + }; + list('Key points', n.keyPoints); + list('Decisions', n.decisions); + list( + 'Action items', + n.actionItems.map((a) => { + const meta = [a.owner, a.due ? `due ${a.due}` : null].filter(Boolean).join(', '); + return meta ? `${a.text} (${meta})` : a.text; + }) + ); + if (state.openQuestions.length) { + lines.push('Questions:'); + for (const q of state.openQuestions) { + lines.push(` [${q.status}] ${q.text}${q.answer ? ` -> ${q.answer}` : ''}`); + } + } + if (state.figures.length) { + lines.push('Key figures:'); + for (const f of state.figures) { + lines.push(` ${f.kind}: ${f.value}${f.context ? ` (${f.context})` : ''}`); + } + } + return lines; +} + +export function formatFollowUps(followUps: CopilotFollowUps | null): string[] { + if (!followUps) return ['Follow-ups: nothing was heard to follow up on.']; + const lines = ['Follow-ups:']; + if (!followUps.actionItems.length) lines.push(' (no action items)'); + for (const a of followUps.actionItems) { + const meta = [a.owner, a.due ? `due ${a.due}` : null].filter(Boolean).join(', '); + lines.push(` - ${a.text}${meta ? ` (${meta})` : ''}`); + } + if (followUps.message) { + lines.push('', 'Recap message:', ...followUps.message.split('\n').map((l) => ` ${l}`)); + } + return lines; +} + +export function formatReview(review: CopilotReview | null): string[] { + if (!review) return ['Review: not written yet.']; + if (review.thin) { + return [`Review: too thin to review${review.note ? ` (${review.note})` : '.'}`]; + } + const lines = [ + 'Review:', + ` Focus: ${review.focusArea}`, + ` Talk/listen: you ${pct(review.talkListen.you)}, others ${pct(review.talkListen.others)}`, + ` Questions: ${String(review.questions.asked)} asked, ${String(review.questions.answered)} answered`, + ` Follow-up clarity: ${review.followUpClarity}`, + ]; + for (const item of review.feedback) lines.push(` - ${item}`); + if (review.note) lines.push(` ${review.note}`); + return lines; +} + +function shortDate(iso: string | null): string { + return iso ? iso.slice(0, 16).replace('T', ' ') : ''; +} + +function clip(text: string, max: number): string { + const flat = text.replace(/\s+/g, ' ').trim(); + return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; +} + +export function formatList(copilots: CopilotSummary[]): string[] { + if (!copilots.length) + return ['No copilots yet. Start one with `pairux copilot start `.']; + const rows = copilots.map((c) => [ + shortDate(c.startedAt), + c.status, + c.role, + c.id, + clip(c.title ?? c.summary, 60), + ]); + const header = ['STARTED', 'STATUS', 'ROLE', 'ID', 'TITLE']; + const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length))); + const line = (cells: string[]) => + cells + .map((cell, i) => (i === cells.length - 1 ? cell : cell.padEnd(widths[i]))) + .join(' ') + .trimEnd(); + return [line(header), ...rows.map(line)]; +} + +export function formatSettings(s: CopilotSettingsView): string[] { + const lines = [`Mode: ${s.mode}`, `Role: ${s.role} (${roleLabel(s.role)})`, 'Own keys:']; + for (const provider of KEY_PROVIDERS) { + const key = s.keys[provider] as { set: boolean; last4: string | null } | undefined; + lines.push( + ` ${provider}: ${key?.set ? `set (…${key.last4 ?? '????'})` : 'not set'}`.trimEnd() + ); + } + if (s.byokAvailable === false) lines.push(' (own keys are not enabled on this server)'); + if (s.eligible === false) lines.push('The copilot is part of PairUX Pro and Team.'); + if (s.balance !== undefined) lines.push(`Pay-as-you-go balance: ${s.balance}`); + return lines; +} + +export function formatWallet(w: CopilotWallet): string[] { + const lines = [ + `Balance: ${w.balance}`, + `Pay-as-you-go charges are the provider's cost + ${String(Math.round((w.markup - 1) * 100))}%.`, + `Top-ups: ${w.topups.map((t) => `$${String(t.priceUsd)}`).join(', ')}`, + ]; + if (!w.usage.length) { + lines.push('No charges yet.'); + return lines; + } + lines.push('Recent charges:'); + for (const u of w.usage) { + lines.push( + ` ${shortDate(u.at)} ${u.charge.padStart(8)} ${u.provider} ${u.kind} (${String(u.units)})${u.status === 'charged' ? '' : ` [${u.status}]`}` + ); + } + return lines; +} + +/** Follows a stream across reconnects and turns events into terminal lines. */ +export class WatchPrinter { + private readonly lines = new Set(); + private readonly suggestions = new Set(); + private notesKey = ''; + private connected = false; + + constructor(private readonly opts: { backlog: number }) {} + + print(event: CopilotEvent): string[] { + switch (event.type) { + case 'state': { + const s = event.state; + const out: string[] = []; + if (!this.connected) { + out.push( + `* Copilot ${s.status} as ${roleLabel(s.role)} (mode: ${s.mode}), ${String(s.transcript.length)} lines so far.` + ); + const providers = formatProviders(s); + if (providers) out.push(`* ${providers}`); + } + const fresh = s.transcript.filter((l) => !this.lines.has(l.id)); + const shown = this.connected ? fresh : fresh.slice(-this.opts.backlog); + for (const l of fresh) this.lines.add(l.id); + for (const l of shown) out.push(`${l.name}: ${l.text}`); + const freshS = s.suggestions.filter((q) => !this.suggestions.has(q.id)); + const shownS = this.connected ? freshS : freshS.slice(-Math.min(3, this.opts.backlog)); + for (const q of freshS) this.suggestions.add(q.id); + for (const q of shownS) out.push(`? ${q.question} (why: ${q.reason})`); + this.notesKey = this.notesSummary(s.notes, s.openQuestions.length); + if (!this.connected && s.notice) out.push(`! ${s.notice}`); + this.connected = true; + return out; + } + case 'line': + if (this.lines.has(event.line.id)) return []; + this.lines.add(event.line.id); + return [`${event.line.name}: ${event.line.text}`]; + case 'suggestion': + if (this.suggestions.has(event.suggestion.id)) return []; + this.suggestions.add(event.suggestion.id); + return [`? ${event.suggestion.question} (why: ${event.suggestion.reason})`]; + case 'notes': { + const key = this.notesSummary(event.notes, event.openQuestions.length); + if (key === this.notesKey) return []; + this.notesKey = key; + return [`~ Notes: ${key}`]; + } + case 'notice': + return [`! ${event.message}`]; + case 'ended': + return [ + '* Copilot ended.', + ` Follow-ups and review: \`pairux copilot notes ${event.state.id}\`, \`pairux copilot followups ${event.state.id}\`, \`pairux copilot review ${event.state.id}\`.`, + ]; + } + } + + private notesSummary(notes: CopilotNotes, openQuestions: number): string { + const count = (n: number, word: string) => `${String(n)} ${word}${n === 1 ? '' : 's'}`; + return [ + count(notes.keyPoints.length, 'key point'), + count(notes.decisions.length, 'decision'), + count(notes.actionItems.length, 'action item'), + count(openQuestions, 'question'), + ].join(', '); + } +} + +// --------------------------------------------------------------------------- +// Commands + +export interface CopilotContext { + deps: Deps; + client: PairuxClient; + config: CliConfig; +} + +const json = { type: 'boolean', default: false } as const; + +export async function runCopilot(argv: string[], ctx: CopilotContext): Promise { + const { deps, client, config } = ctx; + const sub = argv.at(0); + const rest = argv.slice(1); + const api: CopilotApi = client.copilot(); + const out = (lines: string[]) => { + for (const line of lines) deps.out(line); + }; + const emitJson = (value: unknown) => { + deps.out(JSON.stringify(value)); + }; + + /** The meeting a command is about: the argument, else the joined agent's session. */ + const resolveSession = async (arg: string | undefined): Promise => { + if (!arg) { + if (config.agent) return config.agent.sessionId; + throw new UsageError( + `Which meeting? Pass a session id, join code or meeting URL: pairux copilot ${sub ?? ''} ` + ); + } + const ref = parseMeetingRef(arg); + if (!ref) throw new UsageError(`"${arg}" is not a session id, join code or meeting URL.`); + if ('sessionId' in ref) return ref.sessionId; + const found = await client.lookupJoinCode(ref.joinCode).catch((error: unknown) => { + if (error instanceof ApiError && error.status === 404) { + throw new UsageError(`No live meeting has the join code ${ref.joinCode}.`); + } + throw error; + }); + if (found.scheduled) throw new UsageError(`Meeting ${ref.joinCode} has not started yet.`); + return found.id; + }; + + /** A copilot id, or the newest copilot of yours in the meeting the argument names. */ + const resolveCopilot = async ( + arg: string | undefined + ): Promise<{ id: string; copilot?: CopilotState; title?: string | null }> => { + if (arg && UUID.test(arg.trim())) { + try { + const got = await api.get(arg.trim().toLowerCase()); + return { id: got.copilot.id, copilot: got.copilot, title: got.title }; + } catch (error) { + if (!(error instanceof ApiError && error.status === 404)) throw error; + } + } + const sessionId = await resolveSession(arg); + const { copilots } = await api.list(); + const mine = copilots.find((c) => c.sessionId === sessionId); + if (!mine) throw new UsageError('You have no copilot in that meeting.'); + return { id: mine.id }; + }; + + switch (sub) { + case 'status': { + const { values, positionals } = parseArgs({ + args: rest, + allowPositionals: true, + options: { json }, + }); + const status = await api.status(await resolveSession(positionals[0])); + if (values.json) { + emitJson(status); + return 0; + } + if (!status.available) { + deps.out('The AI copilot is not available on this server.'); + return 1; + } + deps.out( + status.active.length + ? `Listening: ${status.active.map((a) => a.name).join(', ')}` + : 'No copilot is listening in this meeting.' + ); + if (status.mine) { + deps.out( + `Yours: ${status.mine.status} as ${roleLabel(status.mine.role)} (mode: ${status.mine.mode}), id ${status.mine.id}` + ); + } else if (!status.eligible) { + deps.out( + `The copilot is part of PairUX Pro and Team.${status.upgradeUrl ? ` Upgrade: ${absolute(client, status.upgradeUrl)}` : ''}` + ); + } else { + deps.out(`Yours: not running. Defaults: ${status.role}, mode ${status.mode}.`); + } + return 0; + } + + case 'start': { + const { values, positionals } = parseArgs({ + args: rest, + allowPositionals: true, + options: { role: { type: 'string' }, mode: { type: 'string' }, json }, + }); + if (values.role !== undefined && !isCopilotRole(values.role)) { + throw new UsageError(`--role must be one of: ${COPILOT_ROLES.join(', ')}`); + } + if (values.mode !== undefined && !isCopilotMode(values.mode)) { + throw new UsageError(`--mode must be one of: ${COPILOT_MODES.join(', ')}`); + } + const sessionId = await resolveSession(positionals[0]); + const state = await api.start(sessionId, { + ...(values.role ? { role: values.role } : {}), + ...(values.mode ? { mode: values.mode } : {}), + }); + if (values.json) { + emitJson(state); + return 0; + } + out(formatStarted(state, positionals[0] ?? sessionId)); + const target = positionals[0] ? ` ${positionals[0]}` : ''; + deps.out(`Run \`pairux copilot watch${target}\` to follow it live.`); + return 0; + } + + case 'stop': { + const { values, positionals } = parseArgs({ + args: rest, + allowPositionals: true, + options: { json }, + }); + const state = await api.stop(await resolveSession(positionals[0])); + if (values.json) { + emitJson(state); + return 0; + } + deps.out(`Copilot stopped (id ${state.id}).`); + out(formatFollowUps(state.followUps)); + deps.out(''); + out(formatReview(state.review)); + return 0; + } + + case 'watch': { + const { values, positionals } = parseArgs({ + args: rest, + allowPositionals: true, + options: { json, all: { type: 'boolean', default: false } }, + }); + const sessionId = await resolveSession(positionals[0]); + const printer = new WatchPrinter({ backlog: values.all ? Infinity : 10 }); + let streams = 0; + for (;;) { + streams += 1; + try { + const body = await api.stream(sessionId); + for await (const event of readSseEvents(body)) { + if (values.json) emitJson(event); + else out(printer.print(event)); + if (event.type === 'ended') return 0; + } + } catch (error) { + if (error instanceof ApiError && error.status === 404 && streams > 1) { + // It stopped (or was stopped elsewhere) while the stream was down. + if (values.json) emitJson({ type: 'ended', reason: error.message }); + else deps.out(`* ${error.message}`); + return 0; + } + if (error instanceof ApiError && !retryable(error.status)) throw error; + if (deps.maxStreams !== undefined && streams >= deps.maxStreams) throw error; + deps.err( + `Copilot stream dropped (${error instanceof Error ? error.message : String(error)}); reconnecting in ${String(RECONNECT_MS / 1000)} s.` + ); + await deps.sleep(RECONNECT_MS); + continue; + } + if (deps.maxStreams !== undefined && streams >= deps.maxStreams) return 0; + deps.err(`Copilot stream closed; reconnecting in ${String(RECONNECT_MS / 1000)} s.`); + await deps.sleep(RECONNECT_MS); + } + } + + case 'notes': { + const { values, positionals } = parseArgs({ + args: rest, + allowPositionals: true, + options: { json }, + }); + const found = await resolveCopilot(positionals[0]); + const { copilot: state, title } = found.copilot + ? { copilot: found.copilot, title: found.title ?? null } + : await api.get(found.id); + if (values.json) { + emitJson({ ...state, title }); + return 0; + } + out(formatNotes(state, title)); + return 0; + } + + case 'ask': { + const { values, positionals } = parseArgs({ + args: rest, + allowPositionals: true, + options: { json }, + }); + // `ask "question"` uses the joined session; `ask question...` names one. + const first = positionals.at(0); + const named = first !== undefined && positionals.length > 1 && parseMeetingRef(first); + const words = named ? positionals.slice(1) : positionals; + let question = words.join(' ').trim(); + if (question === '-') question = ((await deps.readStdin()) ?? '').trim(); + if (!question) throw new UsageError('Usage: pairux copilot ask [meeting] '); + const sessionId = await resolveSession(named ? first : undefined); + const { answer } = await api.ask(sessionId, question); + if (values.json) emitJson({ question, answer }); + else deps.out(answer); + return 0; + } + + case 'recap': { + const { values, positionals } = parseArgs({ + args: rest, + allowPositionals: true, + options: { json, seconds: { type: 'string' } }, + }); + let seconds: number | undefined; + if (values.seconds !== undefined) { + seconds = Number(values.seconds); + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new UsageError('--seconds must be a positive number (10 to 300).'); + } + } + const r = await api.recap(await resolveSession(positionals[0]), seconds); + if (values.json) emitJson(r); + else deps.out(`Last ${String(r.seconds)} s: ${r.recap}`); + return 0; + } + + case 'followups': + case 'follow-ups': + case 'review': { + const { values, positionals } = parseArgs({ + args: rest, + allowPositionals: true, + options: { json }, + }); + if (!positionals[0] && !config.agent) { + throw new UsageError(`Usage: pairux copilot ${sub} `); + } + const { id } = await resolveCopilot(positionals[0]); + const written = await api.write(id, sub === 'review' ? 'review' : 'follow-ups'); + if (values.json) { + emitJson(written); + return 0; + } + out(sub === 'review' ? formatReview(written.review) : formatFollowUps(written.followUps)); + return 0; + } + + case 'list': { + const { values } = parseArgs({ args: rest, options: { json } }); + const { copilots } = await api.list(); + if (values.json) emitJson(copilots); + else out(formatList(copilots)); + return 0; + } + + case 'settings': { + const keyOptions = { + 'anthropic-key': { type: 'string' }, + 'clear-anthropic-key': { type: 'boolean', default: false }, + 'openai-key': { type: 'string' }, + 'clear-openai-key': { type: 'boolean', default: false }, + 'elevenlabs-key': { type: 'string' }, + 'clear-elevenlabs-key': { type: 'boolean', default: false }, + } as const; + const { values } = parseArgs({ + args: rest, + options: { json, mode: { type: 'string' }, role: { type: 'string' }, ...keyOptions }, + }); + const v = values as Record; + const patch: CopilotSettingsPatch = {}; + if (v.mode !== undefined) { + if (!isCopilotMode(v.mode)) { + throw new UsageError(`--mode must be one of: ${COPILOT_MODES.join(', ')}`); + } + patch.mode = v.mode; + } + if (v.role !== undefined) { + if (!isCopilotRole(v.role)) { + throw new UsageError(`--role must be one of: ${COPILOT_ROLES.join(', ')}`); + } + patch.role = v.role; + } + const keys: NonNullable = {}; + for (const provider of KEY_PROVIDERS) { + const set = v[`${provider}-key`]; + const clear = v[`clear-${provider}-key`] === true; + if (typeof set === 'string' && clear) { + throw new UsageError( + `Pass either --${provider}-key or --clear-${provider}-key, not both.` + ); + } + if (clear) keys[provider] = null; + else if (typeof set === 'string') { + const key = set === '-' ? ((await deps.readStdin()) ?? '').trim() : set.trim(); + if (!key) throw new UsageError(`--${provider}-key is empty.`); + keys[provider] = key; + } + } + if (Object.keys(keys).length) patch.keys = keys; + const changing = Object.keys(patch).length > 0; + const settings = changing ? await api.saveSettings(patch) : await api.settings(); + if (v.json) { + emitJson(settings); + return 0; + } + if (changing) deps.out('Saved.'); + out(formatSettings(settings)); + return 0; + } + + case 'wallet': { + const { values } = parseArgs({ args: rest, options: { json } }); + const wallet = await api.wallet(); + if (values.json) emitJson(wallet); + else out(formatWallet(wallet)); + return 0; + } + + default: + throw new UsageError(`Unknown copilot command "${sub ?? ''}". Run \`pairux copilot help\`.`); + } +} + +/** Worth reconnecting: server trouble, rate limits and timeouts, not a refusal. */ +function retryable(status: number): boolean { + return status >= 500 || status === 429 || status === 408; +} + +function absolute(client: PairuxClient, path: string): string { + return path.startsWith('http') ? path : `${client.baseUrl}${path}`; +} diff --git a/apps/desktop/src/main/agent-cli/errors.ts b/apps/desktop/src/main/agent-cli/errors.ts new file mode 100644 index 00000000..34b0999b --- /dev/null +++ b/apps/desktop/src/main/agent-cli/errors.ts @@ -0,0 +1,2 @@ +/** A mistake in how a command was called: printed as-is, exit code 2. */ +export class UsageError extends Error {} diff --git a/apps/desktop/src/main/agent-cli/index.test.ts b/apps/desktop/src/main/agent-cli/index.test.ts index f00bf713..5b76c574 100644 --- a/apps/desktop/src/main/agent-cli/index.test.ts +++ b/apps/desktop/src/main/agent-cli/index.test.ts @@ -26,6 +26,14 @@ describe('parseAgentArgv', () => { expect(parseAgentArgv(['/opt/PairUX/pairux', 'room'], true)).toEqual(['room']); }); + it('runs `pairux copilot ...` headless', () => { + expect(parseAgentArgv(['/opt/PairUX/pairux', 'copilot', 'watch', 'ABC123'], true)).toEqual([ + 'copilot', + 'watch', + 'ABC123', + ]); + }); + it('leaves normal launches, the daemon and deep links alone', () => { expect(parseAgentArgv(['/opt/PairUX/pairux'], true)).toBeNull(); expect(parseAgentArgv(['/opt/PairUX/pairux', 'daemon'], true)).toBeNull(); diff --git a/apps/desktop/src/main/agent-cli/index.ts b/apps/desktop/src/main/agent-cli/index.ts index 74e5d5f5..8f83af7a 100644 --- a/apps/desktop/src/main/agent-cli/index.ts +++ b/apps/desktop/src/main/agent-cli/index.ts @@ -1,5 +1,5 @@ /** - * `pairux login|logout|whoami|join|listen|say|who|status|leave|room|redeem|invites` + * `pairux login|logout|whoami|join|listen|say|who|status|leave|room|redeem|invites|copilot` * * The installed `pairux` launcher passes any argument it does not handle * itself (update, uninstall, --help, --version) straight to the app, the same @@ -24,6 +24,7 @@ export const AGENT_COMMANDS = new Set([ 'room', 'redeem', 'invites', + 'copilot', ]); /** diff --git a/apps/desktop/src/renderer/components/capture/CapturePreview.tsx b/apps/desktop/src/renderer/components/capture/CapturePreview.tsx index aa78e3bb..aaba768c 100644 --- a/apps/desktop/src/renderer/components/capture/CapturePreview.tsx +++ b/apps/desktop/src/renderer/components/capture/CapturePreview.tsx @@ -43,6 +43,7 @@ import { PlanCapBanner } from '@/components/billing/PlanCapBanner'; import { StreamControls, StreamIndicator } from '@/components/streaming'; import { useSession } from '@/hooks/useSession'; import { MeetingTranslation } from '@/components/translation/MeetingTranslation'; +import { MeetingCopilot } from '@/components/copilot/MeetingCopilot'; import { useRecording, formatDuration, type RecordingQuality } from '@/hooks/useRecording'; import { useCamera } from '@/hooks/useCamera'; import { useScreenCameraCompositor, type BubbleGeometry } from '@/hooks/useScreenCameraCompositor'; @@ -1341,6 +1342,9 @@ export function CapturePreview({ participants={translationParticipants} /> )} + {isSFU && isHosting && session.id && ( + + )} {/* Main content */}
{/* Header */} diff --git a/apps/desktop/src/renderer/components/copilot/MeetingCopilot.tsx b/apps/desktop/src/renderer/components/copilot/MeetingCopilot.tsx new file mode 100644 index 00000000..43b9ced5 --- /dev/null +++ b/apps/desktop/src/renderer/components/copilot/MeetingCopilot.tsx @@ -0,0 +1,40 @@ +import { + CopilotPanel, + copilotTranslator, + useMeetingCopilot, + type MeetingCopilotEnvironment, +} from '@pairux/meeting-copilot'; +import { getElectronAPI } from '@/lib/ipc'; +import { API_BASE_URL } from '../../../shared/config'; + +// Ask the main process for the token once a minute. +let cached: { token: string | null; until: number } = { token: null, until: 0 }; +async function bearer(): Promise { + if (Date.now() >= cached.until) { + const { token } = await getElectronAPI().invoke('auth:getToken', undefined); + cached = { token: token ?? null, until: Date.now() + 60_000 }; + } + return cached.token ? { Authorization: `Bearer ${cached.token}` } : {}; +} + +/** pairux.com over HTTPS with the desktop Bearer token; links open in the system browser. */ +const DESKTOP: MeetingCopilotEnvironment = { + apiBase: API_BASE_URL, + headers: bearer, + openUrl: (url) => { + void getElectronAPI().invoke('auth:openExternal', url); + }, +}; + +// The desktop app is not localized: the panel's English strings. +const t = copilotTranslator(); + +/** The AI copilot in the desktop app (see @pairux/meeting-copilot). */ +export function MeetingCopilot({ sessionId, selfId }: { sessionId: string; selfId: string }) { + const copilot = useMeetingCopilot({ sessionId, selfId, env: DESKTOP }); + return ( +
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/viewer.tsx b/apps/desktop/src/renderer/routes/viewer.tsx index 3d426c4b..823f2cbc 100644 --- a/apps/desktop/src/renderer/routes/viewer.tsx +++ b/apps/desktop/src/renderer/routes/viewer.tsx @@ -22,6 +22,7 @@ import { getElectronAPI } from '@/lib/ipc'; import { useWebRTCViewerAPI } from '@/hooks/useWebRTCViewerAPI'; import { useWebRTCViewerSFUAPI } from '@/hooks/useWebRTCViewerSFUAPI'; import { MeetingTranslation } from '@/components/translation/MeetingTranslation'; +import { MeetingCopilot } from '@/components/copilot/MeetingCopilot'; import type { Session, SessionParticipant, @@ -301,6 +302,7 @@ function SFUViewer({ hookResult={hookResult} /> + ); } diff --git a/apps/desktop/tailwind.config.js b/apps/desktop/tailwind.config.js index 042e845a..383a11de 100644 --- a/apps/desktop/tailwind.config.js +++ b/apps/desktop/tailwind.config.js @@ -1,7 +1,11 @@ /** @type {import('tailwindcss').Config} */ export default { darkMode: ['class'], - content: ['./src/renderer/**/*.{js,ts,jsx,tsx,html}'], + content: [ + './src/renderer/**/*.{js,ts,jsx,tsx,html}', + // The shared AI copilot panel. + '../../packages/meeting-copilot/src/**/*.{ts,tsx}', + ], theme: { extend: { colors: { diff --git a/apps/web/messages/ar.json b/apps/web/messages/ar.json index 75b22024..5b3f1091 100644 --- a/apps/web/messages/ar.json +++ b/apps/web/messages/ar.json @@ -2316,5 +2316,142 @@ "login": "لدي حساب بالفعل", "failed": "تعذر قبول الدعوة." } + }, + "copilot": { + "title": "المساعد الذكي", + "listeningFor": "المساعد الذكي يستمع لصالح {names}", + "consent": "أخبر الجميع أن مساعدًا ذكيًا يستمع؛ فهو يحوّل ما يُقال إلى نص.", + "open": "فتح المساعد الذكي", + "close": "إغلاق", + "proOnly": "يحضر المساعد الذكي مكالمتك، ويدوّن الملاحظات، ويقترح الأسئلة، ويكتب رسائل المتابعة. وهو جزء من PairUX Pro.", + "upgrade": "الترقية إلى Pro", + "signIn": "سجّل الدخول لاستخدام المساعد الذكي.", + "role": "ما دورك في هذا الاجتماع؟", + "listensFor": "ينتبه إلى", + "mode": "النماذج", + "modes": { + "auto": "نماذج مجانية، وسحابة مدفوعة كاحتياط", + "free": "نماذج مجانية مستضافة ذاتيًا فقط", + "byok": "مفاتيح API الخاصة بي", + "cloud": "سحابة بأفضل جودة (ادفع حسب الاستخدام)" + }, + "pricingNote": "تعمل النماذج المجانية على خوادمنا دون أي تكلفة. أما النماذج السحابية فتُدفع حسب الاستخدام بتكلفة المزوّد + 10%، أو مجانًا باستخدام مفاتيحك الخاصة.", + "settings": "المفاتيح والرصيد", + "start": "تشغيل المساعد", + "stop": "إيقاف وكتابة رسائل المتابعة", + "stopping": "جارٍ كتابة رسائل المتابعة…", + "live": "يستمع", + "providers": "الكلام: {stt} · الذكاء الاصطناعي: {llm}", + "waiting": "في الانتظار", + "recap": "تلخيص آخر 30 ث", + "recapTitle": "آخر 30 ثانية", + "suggested": "سؤال مقترح", + "jump": "أظهر الموضع", + "dismiss": "تجاهل", + "tabs": { + "notes": "الملاحظات", + "questions": "الأسئلة", + "transcript": "النص", + "ask": "اسأل" + }, + "summary": "الملخص", + "keyPoints": "النقاط الرئيسية", + "decisions": "القرارات", + "actionItems": "المهام", + "figures": "الأرقام والتواريخ المهمة", + "owner": "المسؤول: {owner}", + "nothingYet": "لا شيء بعد. تظهر الملاحظات أثناء حديث الناس.", + "noQuestions": "لا توجد أسئلة مفتوحة بعد.", + "answered": "تمت الإجابة", + "noTranscript": "لم يُسمع أحد بعد. يظهر الكلام هنا بعد ثوانٍ من قوله.", + "askPlaceholder": "اسأل بشكل خاص: «كم كانت الميزانية؟»", + "askSend": "اسأل", + "askHint": "أنت وحدك ترى هذا. تستند الإجابات إلى ما قيل.", + "followUps": "رسائل المتابعة", + "message": "رسالة الملخص", + "copy": "نسخ", + "copied": "تم النسخ", + "review": "مراجعة المحادثة", + "focusArea": "مجال التركيز", + "talkListen": "تحدثت {you}% من الوقت", + "questionsResolved": "تمت الإجابة عن {answered} من {asked} أسئلة", + "clarity": { + "clear": "رسائل المتابعة واضحة", + "partial": "رسائل المتابعة واضحة جزئيًا", + "unclear": "رسائل المتابعة غير واضحة" + }, + "thin": "النص قصير جدًا لمراجعة هذه المحادثة بإنصاف.", + "fullReport": "فتح الملاحظات كاملة", + "restart": "البدء من جديد", + "roles": { + "general": { + "label": "عام", + "meetings": "أي اجتماع." + }, + "sales": { + "label": "المبيعات", + "meetings": "مكالمات الاستكشاف والعروض التوضيحية والمتابعات." + }, + "recruiter": { + "label": "مسؤول توظيف", + "meetings": "الفرز الأولي والمقابلات ومناقشات المرشحين." + }, + "product": { + "label": "مدير منتج", + "meetings": "مقابلات المستخدمين والتخطيط واجتماعات أصحاب المصلحة." + }, + "founder": { + "label": "مؤسس", + "meetings": "مكالمات العملاء وأحاديث المستثمرين واجتماعات الشركاء." + }, + "success": { + "label": "نجاح العملاء", + "meetings": "المتابعات الدورية والتجديدات والتصعيدات." + }, + "engineer": { + "label": "مهندس", + "meetings": "المتطلبات والاجتماعات اليومية ومراجعات التصميم واجتماعات 1:1." + } + } + }, + "copilotPages": { + "listTitle": "ملاحظات المساعد الذكي", + "listIntro": "الملاحظات ورسائل المتابعة والمراجعات من كل اجتماع حضره مساعدك الذكي.", + "settingsLink": "المفاتيح والرصيد", + "loadError": "تعذّر تحميل ملاحظات المساعد.", + "empty": "لا توجد ملاحظات للمساعد بعد. شغّل المساعد الذكي من اللوحة في أي اجتماع.", + "live": "مباشر", + "untitled": "اجتماع بلا عنوان", + "reportTitle": "ملاحظات المساعد الذكي", + "back": "كل ملاحظات المساعد", + "noFollowUps": "لم تُكتب أي رسائل متابعة.", + "rewriteFollowUps": "إعادة كتابة رسائل المتابعة", + "noReview": "لا توجد مراجعة بعد.", + "rewriteReview": "إعادة كتابة المراجعة", + "settingsTitle": "إعدادات المساعد الذكي", + "topupPending": "شكرًا! سيظهر رصيدك هنا فور تأكيد الدفع.", + "saved": "تم الحفظ.", + "checkoutUnavailable": "الدفع غير متاح حاليًا. حاول مرة أخرى بعد قليل.", + "defaultRole": "الدور الافتراضي", + "keysTitle": "مفاتيح API الخاصة بك", + "keysIntro": "استخدم مفاتيحك الخاصة ولن نحاسبك أبدًا على الذكاء الاصطناعي. تُشفَّر المفاتيح، ولا تُعرض مرة أخرى أبدًا، ولا يستخدمها إلا مساعدك.", + "keysDisabled": "المفاتيح الخاصة غير مفعّلة على هذا الخادم بعد.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "مُعيَّن (…{last4})", + "keyNotSet": "غير مُعيَّن", + "keyPlaceholder": "الصق مفتاحًا", + "saveKey": "حفظ", + "removeKey": "إزالة", + "walletTitle": "رصيد الدفع حسب الاستخدام: {balance}", + "walletIntro": "يُستخدم فقط عندما يعمل نموذج سحابي بمفاتيحنا: تكلفة المزوّد + 10%. النماذج المجانية ومفاتيحك الخاصة لا تكلّف شيئًا.", + "addCredit": "إضافة {amount}", + "usageWhen": "متى", + "usageWhat": "ماذا", + "usageCharge": "الرسوم", + "refunded": "مُسترد" } } diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 8dae7a36..d8e79741 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -2316,5 +2316,142 @@ "login": "Ich habe schon ein Konto", "failed": "Die Einladung konnte nicht angenommen werden." } + }, + "copilot": { + "title": "KI-Copilot", + "listeningFor": "Der KI-Copilot hört für {names} zu", + "consent": "Sag allen Bescheid, dass ein KI-Copilot zuhört; er transkribiert, was gesagt wird.", + "open": "KI-Copilot öffnen", + "close": "Schließen", + "proOnly": "Der KI-Copilot sitzt in deinem Call, schreibt mit, schlägt Fragen vor und verfasst deine Follow-ups. Er ist Teil von PairUX Pro.", + "upgrade": "Auf Pro upgraden", + "signIn": "Melde dich an, um den KI-Copilot zu nutzen.", + "role": "Welche Rolle hast du in diesem Meeting?", + "listensFor": "Achtet auf", + "mode": "Modelle", + "modes": { + "auto": "Kostenlose Modelle, bezahlte Cloud als Fallback", + "free": "Nur kostenlose selbst gehostete Modelle", + "byok": "Meine eigenen API-Schlüssel", + "cloud": "Beste Qualität aus der Cloud (nutzungsbasiert)" + }, + "pricingNote": "Kostenlose Modelle laufen ohne Kosten auf unseren Servern. Cloud-Modelle werden nutzungsbasiert zum Anbieterpreis + 10 % abgerechnet oder sind mit deinen eigenen Schlüsseln kostenlos.", + "settings": "Schlüssel und Guthaben", + "start": "Copilot starten", + "stop": "Stoppen und Follow-ups schreiben", + "stopping": "Follow-ups werden geschrieben…", + "live": "Hört zu", + "providers": "Sprache: {stt} · KI: {llm}", + "waiting": "wartet", + "recap": "Letzte 30 s zusammenfassen", + "recapTitle": "Letzte 30 Sekunden", + "suggested": "Vorgeschlagene Frage", + "jump": "Stelle zeigen", + "dismiss": "Verwerfen", + "tabs": { + "notes": "Notizen", + "questions": "Fragen", + "transcript": "Transkript", + "ask": "Nachfragen" + }, + "summary": "Zusammenfassung", + "keyPoints": "Kernpunkte", + "decisions": "Entscheidungen", + "actionItems": "Aufgaben", + "figures": "Wichtige Zahlen und Termine", + "owner": "Verantwortlich: {owner}", + "nothingYet": "Noch nichts. Notizen erscheinen, während gesprochen wird.", + "noQuestions": "Noch keine offenen Fragen.", + "answered": "Beantwortet", + "noTranscript": "Noch wurde niemand gehört. Gesprochenes erscheint hier wenige Sekunden später.", + "askPlaceholder": "Privat fragen: „Wie hoch war nochmal das Budget?“", + "askSend": "Fragen", + "askHint": "Nur du siehst das. Die Antworten basieren auf dem Gesagten.", + "followUps": "Follow-ups", + "message": "Zusammenfassungsnachricht", + "copy": "Kopieren", + "copied": "Kopiert", + "review": "Gesprächsanalyse", + "focusArea": "Schwerpunkt", + "talkListen": "Du hast {you} % der Zeit gesprochen", + "questionsResolved": "{answered} von {asked} Fragen beantwortet", + "clarity": { + "clear": "Follow-ups sind klar", + "partial": "Follow-ups sind teilweise klar", + "unclear": "Follow-ups sind unklar" + }, + "thin": "Das Transkript ist zu kurz, um dieses Gespräch fair zu bewerten.", + "fullReport": "Vollständige Notizen öffnen", + "restart": "Neu starten", + "roles": { + "general": { + "label": "Allgemein", + "meetings": "Jedes Meeting." + }, + "sales": { + "label": "Vertrieb", + "meetings": "Discovery-Calls, Demos und Follow-ups." + }, + "recruiter": { + "label": "Recruiter", + "meetings": "Screenings, Interviews und Kandidaten-Debriefs." + }, + "product": { + "label": "Produktmanager", + "meetings": "Nutzerinterviews, Planung und Stakeholder-Abstimmungen." + }, + "founder": { + "label": "Gründer", + "meetings": "Kundengespräche, Investorengespräche und Partnermeetings." + }, + "success": { + "label": "Customer Success", + "meetings": "Check-ins, Verlängerungen und Eskalationen." + }, + "engineer": { + "label": "Engineer", + "meetings": "Anforderungen, Standups, Design-Reviews und 1:1s." + } + } + }, + "copilotPages": { + "listTitle": "KI-Copilot-Notizen", + "listIntro": "Notizen, Follow-ups und Analysen aus jedem Meeting, an dem dein KI-Copilot teilgenommen hat.", + "settingsLink": "Schlüssel und Guthaben", + "loadError": "Deine Copilot-Notizen konnten nicht geladen werden.", + "empty": "Noch keine Copilot-Notizen. Starte den KI-Copilot in einem beliebigen Meeting über das Panel.", + "live": "live", + "untitled": "Unbenanntes Meeting", + "reportTitle": "KI-Copilot-Notizen", + "back": "Alle Copilot-Notizen", + "noFollowUps": "Es wurden keine Follow-ups geschrieben.", + "rewriteFollowUps": "Follow-ups neu schreiben", + "noReview": "Noch keine Analyse.", + "rewriteReview": "Analyse neu schreiben", + "settingsTitle": "KI-Copilot-Einstellungen", + "topupPending": "Danke! Dein Guthaben erscheint hier, sobald die Zahlung bestätigt ist.", + "saved": "Gespeichert.", + "checkoutUnavailable": "Der Checkout ist gerade nicht verfügbar. Versuch es gleich noch einmal.", + "defaultRole": "Standardrolle", + "keysTitle": "Deine eigenen API-Schlüssel", + "keysIntro": "Mit deinen eigenen Schlüsseln berechnen wir dir nie etwas für KI. Schlüssel werden verschlüsselt, nie wieder angezeigt und nur von deinem Copilot verwendet.", + "keysDisabled": "Eigene Schlüssel sind auf diesem Server noch nicht aktiviert.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "Gesetzt (…{last4})", + "keyNotSet": "Nicht gesetzt", + "keyPlaceholder": "Schlüssel einfügen", + "saveKey": "Speichern", + "removeKey": "Entfernen", + "walletTitle": "Nutzungsbasiertes Guthaben: {balance}", + "walletIntro": "Wird nur genutzt, wenn ein Cloud-Modell über unsere Schlüssel läuft: Anbieterpreis + 10 %. Kostenlose Modelle und deine eigenen Schlüssel kosten nichts.", + "addCredit": "{amount} aufladen", + "usageWhen": "Wann", + "usageWhat": "Was", + "usageCharge": "Betrag", + "refunded": "erstattet" } } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index d1241b25..b9b2259f 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2316,5 +2316,142 @@ "login": "I already have an account", "failed": "Could not accept the invite." } + }, + "copilot": { + "title": "AI copilot", + "listeningFor": "AI copilot is listening for {names}", + "consent": "Let everyone know an AI copilot is listening; it transcribes what is said.", + "open": "Open AI copilot", + "close": "Close", + "proOnly": "The AI copilot sits in your call, takes notes, suggests questions and writes your follow-ups. It is part of PairUX Pro.", + "upgrade": "Upgrade to Pro", + "signIn": "Sign in to use the AI copilot.", + "role": "Who are you in this meeting?", + "listensFor": "Listens for", + "mode": "Models", + "modes": { + "auto": "Free models, paid cloud as fallback", + "free": "Free self-hosted models only", + "byok": "My own API keys", + "cloud": "Best quality cloud (pay as you go)" + }, + "pricingNote": "Free models run on our servers at no cost. Cloud models are pay as you go at provider cost + 10%, or free with your own keys.", + "settings": "Keys and credit", + "start": "Start copilot", + "stop": "Stop and write follow-ups", + "stopping": "Writing follow-ups…", + "live": "Listening", + "providers": "Speech: {stt} · AI: {llm}", + "waiting": "waiting", + "recap": "Recap last 30 s", + "recapTitle": "Last 30 seconds", + "suggested": "Suggested question", + "jump": "Show where", + "dismiss": "Dismiss", + "tabs": { + "notes": "Notes", + "questions": "Questions", + "transcript": "Transcript", + "ask": "Ask" + }, + "summary": "Summary", + "keyPoints": "Key points", + "decisions": "Decisions", + "actionItems": "Action items", + "figures": "Key figures and dates", + "owner": "Owner: {owner}", + "nothingYet": "Nothing yet. Notes appear as people talk.", + "noQuestions": "No open questions yet.", + "answered": "Answered", + "noTranscript": "Nobody has been heard yet. Speech appears here a few seconds after it is said.", + "askPlaceholder": "Ask privately: “What was the budget again?”", + "askSend": "Ask", + "askHint": "Only you see this. Answers come from what was said.", + "followUps": "Follow-ups", + "message": "Recap message", + "copy": "Copy", + "copied": "Copied", + "review": "Conversation review", + "focusArea": "Focus area", + "talkListen": "You talked {you}% of the time", + "questionsResolved": "{answered} of {asked} questions answered", + "clarity": { + "clear": "Follow-ups are clear", + "partial": "Follow-ups are partly clear", + "unclear": "Follow-ups are unclear" + }, + "thin": "The transcript is too short to review this conversation fairly.", + "fullReport": "Open full notes", + "restart": "Start again", + "roles": { + "general": { + "label": "General", + "meetings": "Any meeting." + }, + "sales": { + "label": "Sales", + "meetings": "Discovery calls, demos and follow-ups." + }, + "recruiter": { + "label": "Recruiter", + "meetings": "Screens, interviews and candidate debriefs." + }, + "product": { + "label": "Product Manager", + "meetings": "User interviews, planning and stakeholder syncs." + }, + "founder": { + "label": "Founder", + "meetings": "Customer calls, investor chats and partner meetings." + }, + "success": { + "label": "Customer Success", + "meetings": "Check-ins, renewals and escalations." + }, + "engineer": { + "label": "Engineer", + "meetings": "Requirements, standups, design reviews and 1:1s." + } + } + }, + "copilotPages": { + "listTitle": "AI copilot notes", + "listIntro": "Notes, follow-ups and reviews from every meeting your AI copilot sat in.", + "settingsLink": "Keys and credit", + "loadError": "Could not load your copilot notes.", + "empty": "No copilot notes yet. Start the AI copilot from the panel in any meeting.", + "live": "live", + "untitled": "Untitled meeting", + "reportTitle": "AI copilot notes", + "back": "All copilot notes", + "noFollowUps": "No follow-ups were written.", + "rewriteFollowUps": "Rewrite follow-ups", + "noReview": "No review yet.", + "rewriteReview": "Rewrite review", + "settingsTitle": "AI copilot settings", + "topupPending": "Thanks! Your credit appears here once the payment confirms.", + "saved": "Saved.", + "checkoutUnavailable": "Checkout is unavailable right now. Try again shortly.", + "defaultRole": "Default role", + "keysTitle": "Your own API keys", + "keysIntro": "Use your own keys and we never charge you for AI. Keys are encrypted, never shown again, and used only by your copilot.", + "keysDisabled": "Own keys are not enabled on this server yet.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "Set (…{last4})", + "keyNotSet": "Not set", + "keyPlaceholder": "Paste a key", + "saveKey": "Save", + "removeKey": "Remove", + "walletTitle": "Pay-as-you-go credit: {balance}", + "walletIntro": "Only used when a cloud model runs on our keys: provider cost + 10%. Free models and your own keys cost nothing.", + "addCredit": "Add {amount}", + "usageWhen": "When", + "usageWhat": "What", + "usageCharge": "Charge", + "refunded": "refunded" } } diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index e8e42965..5b781504 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -2316,5 +2316,142 @@ "login": "Ya tengo una cuenta", "failed": "No se pudo aceptar la invitación." } + }, + "copilot": { + "title": "Copiloto de IA", + "listeningFor": "El copiloto de IA está escuchando para {names}", + "consent": "Avisa a todos de que un copiloto de IA está escuchando; transcribe lo que se dice.", + "open": "Abrir copiloto de IA", + "close": "Cerrar", + "proOnly": "El copiloto de IA se une a tu llamada, toma notas, sugiere preguntas y redacta tus seguimientos. Forma parte de PairUX Pro.", + "upgrade": "Pásate a Pro", + "signIn": "Inicia sesión para usar el copiloto de IA.", + "role": "¿Cuál es tu papel en esta reunión?", + "listensFor": "Presta atención a", + "mode": "Modelos", + "modes": { + "auto": "Modelos gratuitos, nube de pago como respaldo", + "free": "Solo modelos gratuitos autoalojados", + "byok": "Mis propias claves de API", + "cloud": "Nube de máxima calidad (pago por uso)" + }, + "pricingNote": "Los modelos gratuitos se ejecutan en nuestros servidores sin coste. Los modelos en la nube son de pago por uso al coste del proveedor + 10 %, o gratis con tus propias claves.", + "settings": "Claves y saldo", + "start": "Iniciar copiloto", + "stop": "Detener y redactar seguimientos", + "stopping": "Redactando seguimientos…", + "live": "Escuchando", + "providers": "Voz: {stt} · IA: {llm}", + "waiting": "esperando", + "recap": "Resumir últimos 30 s", + "recapTitle": "Últimos 30 segundos", + "suggested": "Pregunta sugerida", + "jump": "Mostrar dónde", + "dismiss": "Descartar", + "tabs": { + "notes": "Notas", + "questions": "Preguntas", + "transcript": "Transcripción", + "ask": "Preguntar" + }, + "summary": "Resumen", + "keyPoints": "Puntos clave", + "decisions": "Decisiones", + "actionItems": "Tareas pendientes", + "figures": "Cifras y fechas clave", + "owner": "Responsable: {owner}", + "nothingYet": "Aún no hay nada. Las notas aparecen mientras la gente habla.", + "noQuestions": "Aún no hay preguntas abiertas.", + "answered": "Respondida", + "noTranscript": "Aún no se ha oído a nadie. Lo que se dice aparece aquí unos segundos después.", + "askPlaceholder": "Pregunta en privado: «¿Cuál era el presupuesto?»", + "askSend": "Preguntar", + "askHint": "Solo tú ves esto. Las respuestas se basan en lo que se ha dicho.", + "followUps": "Seguimientos", + "message": "Mensaje de resumen", + "copy": "Copiar", + "copied": "Copiado", + "review": "Análisis de la conversación", + "focusArea": "Área de mejora", + "talkListen": "Hablaste el {you} % del tiempo", + "questionsResolved": "{answered} de {asked} preguntas respondidas", + "clarity": { + "clear": "Los seguimientos están claros", + "partial": "Los seguimientos están claros en parte", + "unclear": "Los seguimientos no están claros" + }, + "thin": "La transcripción es demasiado corta para analizar esta conversación de forma justa.", + "fullReport": "Abrir notas completas", + "restart": "Empezar de nuevo", + "roles": { + "general": { + "label": "General", + "meetings": "Cualquier reunión." + }, + "sales": { + "label": "Ventas", + "meetings": "Llamadas de descubrimiento, demos y seguimientos." + }, + "recruiter": { + "label": "Reclutador", + "meetings": "Filtros, entrevistas y valoraciones de candidatos." + }, + "product": { + "label": "Product Manager", + "meetings": "Entrevistas con usuarios, planificación y reuniones con stakeholders." + }, + "founder": { + "label": "Fundador", + "meetings": "Llamadas con clientes, charlas con inversores y reuniones con socios." + }, + "success": { + "label": "Customer Success", + "meetings": "Seguimientos, renovaciones y escalados." + }, + "engineer": { + "label": "Ingeniero", + "meetings": "Requisitos, standups, revisiones de diseño y 1:1." + } + } + }, + "copilotPages": { + "listTitle": "Notas del copiloto de IA", + "listIntro": "Notas, seguimientos y análisis de cada reunión en la que estuvo tu copiloto de IA.", + "settingsLink": "Claves y saldo", + "loadError": "No se pudieron cargar tus notas del copiloto.", + "empty": "Aún no hay notas del copiloto. Inicia el copiloto de IA desde el panel en cualquier reunión.", + "live": "en vivo", + "untitled": "Reunión sin título", + "reportTitle": "Notas del copiloto de IA", + "back": "Todas las notas del copiloto", + "noFollowUps": "No se redactaron seguimientos.", + "rewriteFollowUps": "Reescribir seguimientos", + "noReview": "Aún no hay análisis.", + "rewriteReview": "Reescribir análisis", + "settingsTitle": "Ajustes del copiloto de IA", + "topupPending": "¡Gracias! Tu saldo aparecerá aquí cuando se confirme el pago.", + "saved": "Guardado.", + "checkoutUnavailable": "El pago no está disponible ahora mismo. Inténtalo de nuevo en breve.", + "defaultRole": "Rol predeterminado", + "keysTitle": "Tus propias claves de API", + "keysIntro": "Usa tus propias claves y nunca te cobraremos por la IA. Las claves se cifran, no se vuelven a mostrar y solo las usa tu copiloto.", + "keysDisabled": "Las claves propias aún no están habilitadas en este servidor.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "Configurada (…{last4})", + "keyNotSet": "Sin configurar", + "keyPlaceholder": "Pega una clave", + "saveKey": "Guardar", + "removeKey": "Quitar", + "walletTitle": "Saldo de pago por uso: {balance}", + "walletIntro": "Solo se usa cuando un modelo en la nube funciona con nuestras claves: coste del proveedor + 10 %. Los modelos gratuitos y tus propias claves no cuestan nada.", + "addCredit": "Añadir {amount}", + "usageWhen": "Cuándo", + "usageWhat": "Qué", + "usageCharge": "Cargo", + "refunded": "reembolsado" } } diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 736121b7..f0677e76 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -2316,5 +2316,142 @@ "login": "J'ai déjà un compte", "failed": "Impossible d'accepter l'invitation." } + }, + "copilot": { + "title": "Copilote IA", + "listeningFor": "Le copilote IA écoute pour {names}", + "consent": "Prévenez tout le monde qu'un copilote IA écoute ; il transcrit ce qui est dit.", + "open": "Ouvrir le copilote IA", + "close": "Fermer", + "proOnly": "Le copilote IA participe à votre appel, prend des notes, suggère des questions et rédige vos suivis. Il fait partie de PairUX Pro.", + "upgrade": "Passer à Pro", + "signIn": "Connectez-vous pour utiliser le copilote IA.", + "role": "Quel est votre rôle dans cette réunion ?", + "listensFor": "À l'écoute de", + "mode": "Modèles", + "modes": { + "auto": "Modèles gratuits, cloud payant en secours", + "free": "Uniquement des modèles gratuits auto-hébergés", + "byok": "Mes propres clés API", + "cloud": "Cloud haute qualité (paiement à l'usage)" + }, + "pricingNote": "Les modèles gratuits tournent sur nos serveurs sans frais. Les modèles cloud sont facturés à l'usage au coût du fournisseur + 10 %, ou gratuits avec vos propres clés.", + "settings": "Clés et crédit", + "start": "Démarrer le copilote", + "stop": "Arrêter et rédiger les suivis", + "stopping": "Rédaction des suivis…", + "live": "À l'écoute", + "providers": "Voix : {stt} · IA : {llm}", + "waiting": "en attente", + "recap": "Récap des 30 dernières s", + "recapTitle": "30 dernières secondes", + "suggested": "Question suggérée", + "jump": "Montrer où", + "dismiss": "Ignorer", + "tabs": { + "notes": "Notes", + "questions": "Questions", + "transcript": "Transcription", + "ask": "Demander" + }, + "summary": "Résumé", + "keyPoints": "Points clés", + "decisions": "Décisions", + "actionItems": "Actions à mener", + "figures": "Chiffres et dates clés", + "owner": "Responsable : {owner}", + "nothingYet": "Rien pour l'instant. Les notes apparaissent au fil de la conversation.", + "noQuestions": "Aucune question ouverte pour l'instant.", + "answered": "Répondue", + "noTranscript": "Personne n'a encore été entendu. Les paroles apparaissent ici quelques secondes après avoir été prononcées.", + "askPlaceholder": "Demandez en privé : « C'était quoi le budget, déjà ? »", + "askSend": "Demander", + "askHint": "Vous seul voyez ceci. Les réponses s'appuient sur ce qui a été dit.", + "followUps": "Suivis", + "message": "Message récapitulatif", + "copy": "Copier", + "copied": "Copié", + "review": "Analyse de la conversation", + "focusArea": "Axe d'amélioration", + "talkListen": "Vous avez parlé {you} % du temps", + "questionsResolved": "{answered} questions sur {asked} ont reçu une réponse", + "clarity": { + "clear": "Les suivis sont clairs", + "partial": "Les suivis sont en partie clairs", + "unclear": "Les suivis ne sont pas clairs" + }, + "thin": "La transcription est trop courte pour analyser équitablement cette conversation.", + "fullReport": "Ouvrir les notes complètes", + "restart": "Recommencer", + "roles": { + "general": { + "label": "Général", + "meetings": "Toute réunion." + }, + "sales": { + "label": "Ventes", + "meetings": "Appels de découverte, démos et suivis." + }, + "recruiter": { + "label": "Recruteur", + "meetings": "Présélections, entretiens et débriefs candidats." + }, + "product": { + "label": "Product Manager", + "meetings": "Entretiens utilisateurs, planification et points avec les parties prenantes." + }, + "founder": { + "label": "Fondateur", + "meetings": "Appels clients, échanges avec des investisseurs et réunions partenaires." + }, + "success": { + "label": "Customer Success", + "meetings": "Points de suivi, renouvellements et escalades." + }, + "engineer": { + "label": "Ingénieur", + "meetings": "Exigences, stand-ups, revues de conception et tête-à-tête." + } + } + }, + "copilotPages": { + "listTitle": "Notes du copilote IA", + "listIntro": "Notes, suivis et analyses de chaque réunion à laquelle votre copilote IA a participé.", + "settingsLink": "Clés et crédit", + "loadError": "Impossible de charger vos notes du copilote.", + "empty": "Aucune note du copilote pour l'instant. Démarrez le copilote IA depuis le panneau dans n'importe quelle réunion.", + "live": "en direct", + "untitled": "Réunion sans titre", + "reportTitle": "Notes du copilote IA", + "back": "Toutes les notes du copilote", + "noFollowUps": "Aucun suivi n'a été rédigé.", + "rewriteFollowUps": "Réécrire les suivis", + "noReview": "Pas encore d'analyse.", + "rewriteReview": "Réécrire l'analyse", + "settingsTitle": "Paramètres du copilote IA", + "topupPending": "Merci ! Votre crédit apparaîtra ici dès que le paiement sera confirmé.", + "saved": "Enregistré.", + "checkoutUnavailable": "Le paiement est indisponible pour le moment. Réessayez dans un instant.", + "defaultRole": "Rôle par défaut", + "keysTitle": "Vos propres clés API", + "keysIntro": "Utilisez vos propres clés et nous ne vous facturerons jamais l'IA. Les clés sont chiffrées, ne sont plus jamais affichées et ne servent qu'à votre copilote.", + "keysDisabled": "Les clés personnelles ne sont pas encore activées sur ce serveur.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "Définie (…{last4})", + "keyNotSet": "Non définie", + "keyPlaceholder": "Collez une clé", + "saveKey": "Enregistrer", + "removeKey": "Supprimer", + "walletTitle": "Crédit à l'usage : {balance}", + "walletIntro": "Utilisé uniquement quand un modèle cloud tourne avec nos clés : coût du fournisseur + 10 %. Les modèles gratuits et vos propres clés ne coûtent rien.", + "addCredit": "Ajouter {amount}", + "usageWhen": "Quand", + "usageWhat": "Quoi", + "usageCharge": "Montant", + "refunded": "remboursé" } } diff --git a/apps/web/messages/hi.json b/apps/web/messages/hi.json index 4fb5c0c7..d33057e4 100644 --- a/apps/web/messages/hi.json +++ b/apps/web/messages/hi.json @@ -2316,5 +2316,142 @@ "login": "मेरा खाता पहले से है", "failed": "आमंत्रण स्वीकार नहीं किया जा सका।" } + }, + "copilot": { + "title": "AI कोपायलट", + "listeningFor": "AI कोपायलट {names} के लिए सुन रहा है", + "consent": "सभी को बता दें कि एक AI कोपायलट सुन रहा है; यह कही गई बातों को ट्रांसक्राइब करता है।", + "open": "AI कोपायलट खोलें", + "close": "बंद करें", + "proOnly": "AI कोपायलट आपकी कॉल में रहता है, नोट्स लेता है, सवाल सुझाता है और आपके फ़ॉलो-अप लिखता है। यह PairUX Pro का हिस्सा है।", + "upgrade": "Pro में अपग्रेड करें", + "signIn": "AI कोपायलट इस्तेमाल करने के लिए साइन इन करें।", + "role": "इस मीटिंग में आपकी भूमिका क्या है?", + "listensFor": "किन बातों पर ध्यान देता है", + "mode": "मॉडल", + "modes": { + "auto": "मुफ़्त मॉडल, बैकअप के लिए पेड क्लाउड", + "free": "केवल मुफ़्त सेल्फ़-होस्टेड मॉडल", + "byok": "मेरी अपनी API कुंजियाँ", + "cloud": "सबसे अच्छी गुणवत्ता वाला क्लाउड (जितना इस्तेमाल, उतना भुगतान)" + }, + "pricingNote": "मुफ़्त मॉडल हमारे सर्वर पर बिना किसी लागत के चलते हैं। क्लाउड मॉडल का भुगतान इस्तेमाल के हिसाब से प्रदाता की लागत + 10% पर होता है, या आपकी अपनी कुंजियों के साथ मुफ़्त हैं।", + "settings": "कुंजियाँ और क्रेडिट", + "start": "कोपायलट शुरू करें", + "stop": "रोकें और फ़ॉलो-अप लिखें", + "stopping": "फ़ॉलो-अप लिखे जा रहे हैं…", + "live": "सुन रहा है", + "providers": "आवाज़: {stt} · AI: {llm}", + "waiting": "प्रतीक्षा में", + "recap": "पिछले 30 से. का सार", + "recapTitle": "पिछले 30 सेकंड", + "suggested": "सुझाया गया सवाल", + "jump": "जगह दिखाएँ", + "dismiss": "हटाएँ", + "tabs": { + "notes": "नोट्स", + "questions": "सवाल", + "transcript": "ट्रांसक्रिप्ट", + "ask": "पूछें" + }, + "summary": "सारांश", + "keyPoints": "मुख्य बातें", + "decisions": "फ़ैसले", + "actionItems": "कार्य बिंदु", + "figures": "मुख्य आँकड़े और तारीखें", + "owner": "ज़िम्मेदार: {owner}", + "nothingYet": "अभी कुछ नहीं। लोगों के बोलते ही नोट्स दिखने लगेंगे।", + "noQuestions": "अभी कोई खुला सवाल नहीं है।", + "answered": "जवाब मिला", + "noTranscript": "अभी तक किसी की आवाज़ नहीं सुनी गई। बोली गई बातें कुछ सेकंड बाद यहाँ दिखती हैं।", + "askPlaceholder": "निजी रूप से पूछें: “बजट कितना था?”", + "askSend": "पूछें", + "askHint": "यह सिर्फ़ आपको दिखता है। जवाब कही गई बातों पर आधारित होते हैं।", + "followUps": "फ़ॉलो-अप", + "message": "सारांश संदेश", + "copy": "कॉपी करें", + "copied": "कॉपी हो गया", + "review": "बातचीत की समीक्षा", + "focusArea": "सुधार का क्षेत्र", + "talkListen": "आप {you}% समय बोले", + "questionsResolved": "{asked} में से {answered} सवालों के जवाब मिले", + "clarity": { + "clear": "फ़ॉलो-अप स्पष्ट हैं", + "partial": "फ़ॉलो-अप आंशिक रूप से स्पष्ट हैं", + "unclear": "फ़ॉलो-अप स्पष्ट नहीं हैं" + }, + "thin": "इस बातचीत की निष्पक्ष समीक्षा के लिए ट्रांसक्रिप्ट बहुत छोटा है।", + "fullReport": "पूरे नोट्स खोलें", + "restart": "फिर से शुरू करें", + "roles": { + "general": { + "label": "सामान्य", + "meetings": "कोई भी मीटिंग।" + }, + "sales": { + "label": "सेल्स", + "meetings": "डिस्कवरी कॉल, डेमो और फ़ॉलो-अप।" + }, + "recruiter": { + "label": "रिक्रूटर", + "meetings": "स्क्रीनिंग, इंटरव्यू और उम्मीदवारों पर चर्चा।" + }, + "product": { + "label": "प्रोडक्ट मैनेजर", + "meetings": "यूज़र इंटरव्यू, प्लानिंग और स्टेकहोल्डर मीटिंग।" + }, + "founder": { + "label": "फ़ाउंडर", + "meetings": "ग्राहक कॉल, निवेशकों से बातचीत और पार्टनर मीटिंग।" + }, + "success": { + "label": "कस्टमर सक्सेस", + "meetings": "चेक-इन, रिन्यूअल और एस्केलेशन।" + }, + "engineer": { + "label": "इंजीनियर", + "meetings": "आवश्यकताएँ, स्टैंडअप, डिज़ाइन रिव्यू और 1:1।" + } + } + }, + "copilotPages": { + "listTitle": "AI कोपायलट नोट्स", + "listIntro": "हर उस मीटिंग के नोट्स, फ़ॉलो-अप और समीक्षाएँ जिसमें आपका AI कोपायलट शामिल था।", + "settingsLink": "कुंजियाँ और क्रेडिट", + "loadError": "आपके कोपायलट नोट्स लोड नहीं हो सके।", + "empty": "अभी कोई कोपायलट नोट्स नहीं हैं। किसी भी मीटिंग में पैनल से AI कोपायलट शुरू करें।", + "live": "लाइव", + "untitled": "बिना नाम की मीटिंग", + "reportTitle": "AI कोपायलट नोट्स", + "back": "सभी कोपायलट नोट्स", + "noFollowUps": "कोई फ़ॉलो-अप नहीं लिखा गया।", + "rewriteFollowUps": "फ़ॉलो-अप दोबारा लिखें", + "noReview": "अभी कोई समीक्षा नहीं।", + "rewriteReview": "समीक्षा दोबारा लिखें", + "settingsTitle": "AI कोपायलट सेटिंग्स", + "topupPending": "धन्यवाद! भुगतान की पुष्टि होते ही आपका क्रेडिट यहाँ दिखेगा।", + "saved": "सहेजा गया।", + "checkoutUnavailable": "चेकआउट अभी उपलब्ध नहीं है। थोड़ी देर में फिर कोशिश करें।", + "defaultRole": "डिफ़ॉल्ट भूमिका", + "keysTitle": "आपकी अपनी API कुंजियाँ", + "keysIntro": "अपनी कुंजियाँ इस्तेमाल करें, तो हम AI के लिए आपसे कभी शुल्क नहीं लेंगे। कुंजियाँ एन्क्रिप्ट की जाती हैं, दोबारा कभी नहीं दिखाई जातीं और केवल आपका कोपायलट उनका इस्तेमाल करता है।", + "keysDisabled": "इस सर्वर पर अपनी कुंजियाँ अभी चालू नहीं हैं।", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "सेट है (…{last4})", + "keyNotSet": "सेट नहीं है", + "keyPlaceholder": "कुंजी पेस्ट करें", + "saveKey": "सहेजें", + "removeKey": "हटाएँ", + "walletTitle": "इस्तेमाल के हिसाब से भुगतान क्रेडिट: {balance}", + "walletIntro": "केवल तब इस्तेमाल होता है जब कोई क्लाउड मॉडल हमारी कुंजियों पर चलता है: प्रदाता की लागत + 10%। मुफ़्त मॉडल और आपकी अपनी कुंजियाँ मुफ़्त हैं।", + "addCredit": "{amount} जोड़ें", + "usageWhen": "कब", + "usageWhat": "क्या", + "usageCharge": "शुल्क", + "refunded": "रिफ़ंड किया गया" } } diff --git a/apps/web/messages/id.json b/apps/web/messages/id.json index 7287bb37..4bf2a5a6 100644 --- a/apps/web/messages/id.json +++ b/apps/web/messages/id.json @@ -2316,5 +2316,142 @@ "login": "Saya sudah punya akun", "failed": "Undangan tidak dapat diterima." } + }, + "copilot": { + "title": "Kopilot AI", + "listeningFor": "Kopilot AI sedang mendengarkan untuk {names}", + "consent": "Beri tahu semua orang bahwa kopilot AI sedang mendengarkan; kopilot menyalin apa yang diucapkan.", + "open": "Buka kopilot AI", + "close": "Tutup", + "proOnly": "Kopilot AI ikut dalam panggilan Anda, mencatat, menyarankan pertanyaan, dan menulis tindak lanjut Anda. Fitur ini bagian dari PairUX Pro.", + "upgrade": "Upgrade ke Pro", + "signIn": "Masuk untuk menggunakan kopilot AI.", + "role": "Apa peran Anda dalam rapat ini?", + "listensFor": "Memperhatikan", + "mode": "Model", + "modes": { + "auto": "Model gratis, cloud berbayar sebagai cadangan", + "free": "Hanya model gratis yang di-host sendiri", + "byok": "Kunci API saya sendiri", + "cloud": "Cloud kualitas terbaik (bayar sesuai pemakaian)" + }, + "pricingNote": "Model gratis berjalan di server kami tanpa biaya. Model cloud dibayar sesuai pemakaian dengan biaya penyedia + 10%, atau gratis dengan kunci Anda sendiri.", + "settings": "Kunci dan saldo", + "start": "Mulai kopilot", + "stop": "Hentikan dan tulis tindak lanjut", + "stopping": "Menulis tindak lanjut…", + "live": "Mendengarkan", + "providers": "Suara: {stt} · AI: {llm}", + "waiting": "menunggu", + "recap": "Ringkas 30 dtk terakhir", + "recapTitle": "30 detik terakhir", + "suggested": "Pertanyaan yang disarankan", + "jump": "Tunjukkan letaknya", + "dismiss": "Abaikan", + "tabs": { + "notes": "Catatan", + "questions": "Pertanyaan", + "transcript": "Transkrip", + "ask": "Tanya" + }, + "summary": "Ringkasan", + "keyPoints": "Poin utama", + "decisions": "Keputusan", + "actionItems": "Tindakan", + "figures": "Angka dan tanggal penting", + "owner": "Penanggung jawab: {owner}", + "nothingYet": "Belum ada apa-apa. Catatan muncul saat orang berbicara.", + "noQuestions": "Belum ada pertanyaan terbuka.", + "answered": "Terjawab", + "noTranscript": "Belum ada yang terdengar. Ucapan muncul di sini beberapa detik setelah diucapkan.", + "askPlaceholder": "Tanya secara pribadi: “Berapa anggarannya tadi?”", + "askSend": "Tanya", + "askHint": "Hanya Anda yang melihat ini. Jawaban berasal dari apa yang telah diucapkan.", + "followUps": "Tindak lanjut", + "message": "Pesan ringkasan", + "copy": "Salin", + "copied": "Disalin", + "review": "Tinjauan percakapan", + "focusArea": "Area fokus", + "talkListen": "Anda berbicara {you}% dari waktu", + "questionsResolved": "{answered} dari {asked} pertanyaan terjawab", + "clarity": { + "clear": "Tindak lanjut sudah jelas", + "partial": "Tindak lanjut sebagian jelas", + "unclear": "Tindak lanjut belum jelas" + }, + "thin": "Transkrip terlalu pendek untuk meninjau percakapan ini secara adil.", + "fullReport": "Buka catatan lengkap", + "restart": "Mulai lagi", + "roles": { + "general": { + "label": "Umum", + "meetings": "Rapat apa pun." + }, + "sales": { + "label": "Penjualan", + "meetings": "Panggilan discovery, demo, dan tindak lanjut." + }, + "recruiter": { + "label": "Perekrut", + "meetings": "Penyaringan, wawancara, dan diskusi kandidat." + }, + "product": { + "label": "Product Manager", + "meetings": "Wawancara pengguna, perencanaan, dan sinkronisasi dengan pemangku kepentingan." + }, + "founder": { + "label": "Pendiri", + "meetings": "Panggilan pelanggan, obrolan dengan investor, dan rapat mitra." + }, + "success": { + "label": "Customer Success", + "meetings": "Check-in, perpanjangan, dan eskalasi." + }, + "engineer": { + "label": "Engineer", + "meetings": "Kebutuhan, standup, tinjauan desain, dan 1:1." + } + } + }, + "copilotPages": { + "listTitle": "Catatan kopilot AI", + "listIntro": "Catatan, tindak lanjut, dan tinjauan dari setiap rapat yang diikuti kopilot AI Anda.", + "settingsLink": "Kunci dan saldo", + "loadError": "Tidak dapat memuat catatan kopilot Anda.", + "empty": "Belum ada catatan kopilot. Mulai kopilot AI dari panel di rapat mana pun.", + "live": "langsung", + "untitled": "Rapat tanpa judul", + "reportTitle": "Catatan kopilot AI", + "back": "Semua catatan kopilot", + "noFollowUps": "Tidak ada tindak lanjut yang ditulis.", + "rewriteFollowUps": "Tulis ulang tindak lanjut", + "noReview": "Belum ada tinjauan.", + "rewriteReview": "Tulis ulang tinjauan", + "settingsTitle": "Pengaturan kopilot AI", + "topupPending": "Terima kasih! Saldo Anda muncul di sini setelah pembayaran dikonfirmasi.", + "saved": "Tersimpan.", + "checkoutUnavailable": "Checkout sedang tidak tersedia. Coba lagi sebentar lagi.", + "defaultRole": "Peran default", + "keysTitle": "Kunci API Anda sendiri", + "keysIntro": "Gunakan kunci Anda sendiri dan kami tidak akan pernah menagih Anda untuk AI. Kunci dienkripsi, tidak pernah ditampilkan lagi, dan hanya digunakan oleh kopilot Anda.", + "keysDisabled": "Kunci sendiri belum diaktifkan di server ini.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "Diatur (…{last4})", + "keyNotSet": "Belum diatur", + "keyPlaceholder": "Tempel kunci", + "saveKey": "Simpan", + "removeKey": "Hapus", + "walletTitle": "Saldo bayar sesuai pemakaian: {balance}", + "walletIntro": "Hanya dipakai saat model cloud berjalan dengan kunci kami: biaya penyedia + 10%. Model gratis dan kunci Anda sendiri tidak dikenai biaya.", + "addCredit": "Tambah {amount}", + "usageWhen": "Kapan", + "usageWhat": "Apa", + "usageCharge": "Biaya", + "refunded": "dikembalikan" } } diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 42ea0e87..11778d3a 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -2316,5 +2316,142 @@ "login": "Ho già un account", "failed": "Impossibile accettare l'invito." } + }, + "copilot": { + "title": "Copilota IA", + "listeningFor": "Il copilota IA sta ascoltando per {names}", + "consent": "Avvisa tutti che un copilota IA sta ascoltando; trascrive ciò che viene detto.", + "open": "Apri il copilota IA", + "close": "Chiudi", + "proOnly": "Il copilota IA partecipa alla tua chiamata, prende appunti, suggerisce domande e scrive i tuoi follow-up. Fa parte di PairUX Pro.", + "upgrade": "Passa a Pro", + "signIn": "Accedi per usare il copilota IA.", + "role": "Qual è il tuo ruolo in questa riunione?", + "listensFor": "Presta attenzione a", + "mode": "Modelli", + "modes": { + "auto": "Modelli gratuiti, cloud a pagamento come riserva", + "free": "Solo modelli gratuiti self-hosted", + "byok": "Le mie chiavi API", + "cloud": "Cloud di qualità migliore (a consumo)" + }, + "pricingNote": "I modelli gratuiti girano sui nostri server senza costi. I modelli cloud sono a consumo al costo del fornitore + 10%, oppure gratuiti con le tue chiavi.", + "settings": "Chiavi e credito", + "start": "Avvia il copilota", + "stop": "Ferma e scrivi i follow-up", + "stopping": "Scrittura dei follow-up…", + "live": "In ascolto", + "providers": "Voce: {stt} · IA: {llm}", + "waiting": "in attesa", + "recap": "Riepiloga ultimi 30 s", + "recapTitle": "Ultimi 30 secondi", + "suggested": "Domanda suggerita", + "jump": "Mostra dove", + "dismiss": "Ignora", + "tabs": { + "notes": "Note", + "questions": "Domande", + "transcript": "Trascrizione", + "ask": "Chiedi" + }, + "summary": "Riepilogo", + "keyPoints": "Punti chiave", + "decisions": "Decisioni", + "actionItems": "Azioni da fare", + "figures": "Cifre e date chiave", + "owner": "Responsabile: {owner}", + "nothingYet": "Ancora niente. Le note compaiono mentre le persone parlano.", + "noQuestions": "Ancora nessuna domanda aperta.", + "answered": "Risposta data", + "noTranscript": "Non si è ancora sentito nessuno. Il parlato compare qui pochi secondi dopo.", + "askPlaceholder": "Chiedi in privato: «Qual era il budget?»", + "askSend": "Chiedi", + "askHint": "Lo vedi solo tu. Le risposte si basano su ciò che è stato detto.", + "followUps": "Follow-up", + "message": "Messaggio di riepilogo", + "copy": "Copia", + "copied": "Copiato", + "review": "Analisi della conversazione", + "focusArea": "Area su cui lavorare", + "talkListen": "Hai parlato per il {you}% del tempo", + "questionsResolved": "{answered} domande su {asked} con risposta", + "clarity": { + "clear": "I follow-up sono chiari", + "partial": "I follow-up sono chiari solo in parte", + "unclear": "I follow-up non sono chiari" + }, + "thin": "La trascrizione è troppo breve per valutare questa conversazione in modo equo.", + "fullReport": "Apri le note complete", + "restart": "Ricomincia", + "roles": { + "general": { + "label": "Generale", + "meetings": "Qualsiasi riunione." + }, + "sales": { + "label": "Vendite", + "meetings": "Call conoscitive, demo e follow-up." + }, + "recruiter": { + "label": "Recruiter", + "meetings": "Screening, colloqui e debrief sui candidati." + }, + "product": { + "label": "Product Manager", + "meetings": "Interviste agli utenti, pianificazione e allineamenti con gli stakeholder." + }, + "founder": { + "label": "Founder", + "meetings": "Call con clienti, chiacchierate con investitori e incontri con partner." + }, + "success": { + "label": "Customer Success", + "meetings": "Check-in, rinnovi ed escalation." + }, + "engineer": { + "label": "Ingegnere", + "meetings": "Requisiti, standup, design review e 1:1." + } + } + }, + "copilotPages": { + "listTitle": "Note del copilota IA", + "listIntro": "Note, follow-up e analisi di ogni riunione a cui ha partecipato il tuo copilota IA.", + "settingsLink": "Chiavi e credito", + "loadError": "Impossibile caricare le note del copilota.", + "empty": "Ancora nessuna nota del copilota. Avvia il copilota IA dal pannello in qualsiasi riunione.", + "live": "dal vivo", + "untitled": "Riunione senza titolo", + "reportTitle": "Note del copilota IA", + "back": "Tutte le note del copilota", + "noFollowUps": "Non è stato scritto alcun follow-up.", + "rewriteFollowUps": "Riscrivi i follow-up", + "noReview": "Ancora nessuna analisi.", + "rewriteReview": "Riscrivi l'analisi", + "settingsTitle": "Impostazioni del copilota IA", + "topupPending": "Grazie! Il tuo credito comparirà qui appena il pagamento sarà confermato.", + "saved": "Salvato.", + "checkoutUnavailable": "Il checkout non è disponibile al momento. Riprova tra poco.", + "defaultRole": "Ruolo predefinito", + "keysTitle": "Le tue chiavi API", + "keysIntro": "Usa le tue chiavi e non ti addebiteremo mai nulla per l'IA. Le chiavi sono cifrate, non vengono più mostrate e sono usate solo dal tuo copilota.", + "keysDisabled": "Le chiavi personali non sono ancora attive su questo server.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "Impostata (…{last4})", + "keyNotSet": "Non impostata", + "keyPlaceholder": "Incolla una chiave", + "saveKey": "Salva", + "removeKey": "Rimuovi", + "walletTitle": "Credito a consumo: {balance}", + "walletIntro": "Usato solo quando un modello cloud gira con le nostre chiavi: costo del fornitore + 10%. I modelli gratuiti e le tue chiavi non costano nulla.", + "addCredit": "Aggiungi {amount}", + "usageWhen": "Quando", + "usageWhat": "Cosa", + "usageCharge": "Addebito", + "refunded": "rimborsato" } } diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index aa47816c..facd1594 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -2316,5 +2316,142 @@ "login": "アカウントをお持ちの方", "failed": "招待を受けられませんでした。" } + }, + "copilot": { + "title": "AI コパイロット", + "listeningFor": "AI コパイロットが {names} のために聞き取り中です", + "consent": "AI コパイロットが聞き取り中であることを全員に伝えてください。発言内容を文字起こしします。", + "open": "AI コパイロットを開く", + "close": "閉じる", + "proOnly": "AI コパイロットは通話に同席してメモを取り、質問を提案し、フォローアップを作成します。PairUX Pro に含まれています。", + "upgrade": "Pro にアップグレード", + "signIn": "AI コパイロットを使うにはサインインしてください。", + "role": "この会議でのあなたの役割は?", + "listensFor": "注目する点", + "mode": "モデル", + "modes": { + "auto": "無料モデル(必要に応じて有料クラウド)", + "free": "無料のセルフホストモデルのみ", + "byok": "自分の API キー", + "cloud": "最高品質のクラウド(従量課金)" + }, + "pricingNote": "無料モデルは当社のサーバーで無料で動作します。クラウドモデルはプロバイダー料金 + 10% の従量課金、または自分のキーを使えば無料です。", + "settings": "キーとクレジット", + "start": "コパイロットを開始", + "stop": "停止してフォローアップを作成", + "stopping": "フォローアップを作成中…", + "live": "聞き取り中", + "providers": "音声: {stt} · AI: {llm}", + "waiting": "待機中", + "recap": "直近 30 秒を振り返る", + "recapTitle": "直近 30 秒", + "suggested": "おすすめの質問", + "jump": "該当箇所を表示", + "dismiss": "閉じる", + "tabs": { + "notes": "メモ", + "questions": "質問", + "transcript": "文字起こし", + "ask": "たずねる" + }, + "summary": "要約", + "keyPoints": "要点", + "decisions": "決定事項", + "actionItems": "アクションアイテム", + "figures": "重要な数字と日付", + "owner": "担当: {owner}", + "nothingYet": "まだ何もありません。会話が進むとメモが表示されます。", + "noQuestions": "未解決の質問はまだありません。", + "answered": "回答済み", + "noTranscript": "まだ誰の発言も聞き取れていません。発言は数秒後にここに表示されます。", + "askPlaceholder": "非公開でたずねる:「予算はいくらだっけ?」", + "askSend": "たずねる", + "askHint": "これはあなたにだけ表示されます。回答は会話の内容に基づきます。", + "followUps": "フォローアップ", + "message": "まとめメッセージ", + "copy": "コピー", + "copied": "コピーしました", + "review": "会話レビュー", + "focusArea": "改善ポイント", + "talkListen": "あなたの発言時間は {you}% でした", + "questionsResolved": "{asked} 件中 {answered} 件の質問に回答済み", + "clarity": { + "clear": "フォローアップは明確です", + "partial": "フォローアップは一部明確です", + "unclear": "フォローアップが不明確です" + }, + "thin": "文字起こしが短すぎるため、この会話を公平にレビューできません。", + "fullReport": "メモ全文を開く", + "restart": "最初からやり直す", + "roles": { + "general": { + "label": "一般", + "meetings": "あらゆる会議。" + }, + "sales": { + "label": "営業", + "meetings": "ヒアリング、デモ、フォローアップ。" + }, + "recruiter": { + "label": "採用担当", + "meetings": "書類選考後の面談、面接、候補者の振り返り。" + }, + "product": { + "label": "プロダクトマネージャー", + "meetings": "ユーザーインタビュー、計画、関係者との打ち合わせ。" + }, + "founder": { + "label": "創業者", + "meetings": "顧客との通話、投資家との会話、パートナーとの会議。" + }, + "success": { + "label": "カスタマーサクセス", + "meetings": "定例チェックイン、更新、エスカレーション。" + }, + "engineer": { + "label": "エンジニア", + "meetings": "要件定義、スタンドアップ、設計レビュー、1on1。" + } + } + }, + "copilotPages": { + "listTitle": "AI コパイロットのメモ", + "listIntro": "AI コパイロットが同席したすべての会議のメモ、フォローアップ、レビュー。", + "settingsLink": "キーとクレジット", + "loadError": "コパイロットのメモを読み込めませんでした。", + "empty": "コパイロットのメモはまだありません。会議中のパネルから AI コパイロットを開始してください。", + "live": "ライブ", + "untitled": "無題の会議", + "reportTitle": "AI コパイロットのメモ", + "back": "すべてのコパイロットメモ", + "noFollowUps": "フォローアップは作成されませんでした。", + "rewriteFollowUps": "フォローアップを書き直す", + "noReview": "レビューはまだありません。", + "rewriteReview": "レビューを書き直す", + "settingsTitle": "AI コパイロットの設定", + "topupPending": "ありがとうございます。支払いが確認されると、ここにクレジットが表示されます。", + "saved": "保存しました。", + "checkoutUnavailable": "現在チェックアウトをご利用いただけません。しばらくしてからもう一度お試しください。", + "defaultRole": "デフォルトの役割", + "keysTitle": "自分の API キー", + "keysIntro": "自分のキーを使えば、AI の料金は一切かかりません。キーは暗号化され、二度と表示されず、あなたのコパイロットだけが使用します。", + "keysDisabled": "このサーバーでは自分のキーはまだ有効になっていません。", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "設定済み(…{last4})", + "keyNotSet": "未設定", + "keyPlaceholder": "キーを貼り付け", + "saveKey": "保存", + "removeKey": "削除", + "walletTitle": "従量課金クレジット: {balance}", + "walletIntro": "クラウドモデルが当社のキーで動作するときだけ使われます(プロバイダー料金 + 10%)。無料モデルと自分のキーには料金がかかりません。", + "addCredit": "{amount} を追加", + "usageWhen": "日時", + "usageWhat": "内容", + "usageCharge": "料金", + "refunded": "返金済み" } } diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json index 9ba98e9a..a2e49233 100644 --- a/apps/web/messages/ko.json +++ b/apps/web/messages/ko.json @@ -2316,5 +2316,142 @@ "login": "이미 계정이 있어요", "failed": "초대를 수락하지 못했습니다." } + }, + "copilot": { + "title": "AI 코파일럿", + "listeningFor": "AI 코파일럿이 {names} 님을 위해 듣고 있습니다", + "consent": "AI 코파일럿이 듣고 있다는 사실을 모두에게 알려 주세요. 말한 내용을 텍스트로 기록합니다.", + "open": "AI 코파일럿 열기", + "close": "닫기", + "proOnly": "AI 코파일럿은 통화에 함께하며 메모를 작성하고, 질문을 제안하고, 후속 메시지를 써 줍니다. PairUX Pro에 포함되어 있습니다.", + "upgrade": "Pro로 업그레이드", + "signIn": "AI 코파일럿을 사용하려면 로그인하세요.", + "role": "이 회의에서 내 역할은 무엇인가요?", + "listensFor": "주목하는 내용", + "mode": "모델", + "modes": { + "auto": "무료 모델, 필요 시 유료 클라우드", + "free": "무료 자체 호스팅 모델만", + "byok": "내 API 키", + "cloud": "최고 품질 클라우드(사용한 만큼 지불)" + }, + "pricingNote": "무료 모델은 저희 서버에서 비용 없이 실행됩니다. 클라우드 모델은 제공업체 비용 + 10%로 사용한 만큼 지불하거나, 내 키를 쓰면 무료입니다.", + "settings": "키와 크레딧", + "start": "코파일럿 시작", + "stop": "중지하고 후속 메시지 작성", + "stopping": "후속 메시지 작성 중…", + "live": "듣는 중", + "providers": "음성: {stt} · AI: {llm}", + "waiting": "대기 중", + "recap": "최근 30초 요약", + "recapTitle": "최근 30초", + "suggested": "추천 질문", + "jump": "위치 보기", + "dismiss": "닫기", + "tabs": { + "notes": "메모", + "questions": "질문", + "transcript": "대화록", + "ask": "물어보기" + }, + "summary": "요약", + "keyPoints": "핵심 내용", + "decisions": "결정 사항", + "actionItems": "할 일", + "figures": "주요 수치와 날짜", + "owner": "담당: {owner}", + "nothingYet": "아직 내용이 없습니다. 대화가 진행되면 메모가 표시됩니다.", + "noQuestions": "아직 열린 질문이 없습니다.", + "answered": "답변됨", + "noTranscript": "아직 아무도 말하지 않았습니다. 발언은 몇 초 후 여기에 표시됩니다.", + "askPlaceholder": "비공개로 물어보기: “예산이 얼마였죠?”", + "askSend": "물어보기", + "askHint": "나만 볼 수 있습니다. 답변은 대화 내용을 바탕으로 합니다.", + "followUps": "후속 메시지", + "message": "요약 메시지", + "copy": "복사", + "copied": "복사됨", + "review": "대화 리뷰", + "focusArea": "개선할 점", + "talkListen": "내가 말한 시간: {you}%", + "questionsResolved": "질문 {asked}개 중 {answered}개 답변됨", + "clarity": { + "clear": "후속 메시지가 명확합니다", + "partial": "후속 메시지가 일부만 명확합니다", + "unclear": "후속 메시지가 명확하지 않습니다" + }, + "thin": "대화록이 너무 짧아 이 대화를 공정하게 리뷰할 수 없습니다.", + "fullReport": "전체 메모 열기", + "restart": "다시 시작", + "roles": { + "general": { + "label": "일반", + "meetings": "모든 회의." + }, + "sales": { + "label": "영업", + "meetings": "니즈 파악 통화, 데모, 후속 미팅." + }, + "recruiter": { + "label": "채용 담당자", + "meetings": "사전 스크리닝, 면접, 후보자 디브리핑." + }, + "product": { + "label": "프로덕트 매니저", + "meetings": "사용자 인터뷰, 기획, 이해관계자 미팅." + }, + "founder": { + "label": "창업자", + "meetings": "고객 통화, 투자자 미팅, 파트너 회의." + }, + "success": { + "label": "고객 성공", + "meetings": "정기 점검, 갱신, 에스컬레이션." + }, + "engineer": { + "label": "엔지니어", + "meetings": "요구사항, 스탠드업, 설계 리뷰, 1:1." + } + } + }, + "copilotPages": { + "listTitle": "AI 코파일럿 메모", + "listIntro": "AI 코파일럿이 함께한 모든 회의의 메모, 후속 메시지, 리뷰입니다.", + "settingsLink": "키와 크레딧", + "loadError": "코파일럿 메모를 불러오지 못했습니다.", + "empty": "아직 코파일럿 메모가 없습니다. 아무 회의에서나 패널로 AI 코파일럿을 시작하세요.", + "live": "진행 중", + "untitled": "제목 없는 회의", + "reportTitle": "AI 코파일럿 메모", + "back": "모든 코파일럿 메모", + "noFollowUps": "작성된 후속 메시지가 없습니다.", + "rewriteFollowUps": "후속 메시지 다시 쓰기", + "noReview": "아직 리뷰가 없습니다.", + "rewriteReview": "리뷰 다시 쓰기", + "settingsTitle": "AI 코파일럿 설정", + "topupPending": "감사합니다! 결제가 확인되면 여기에 크레딧이 표시됩니다.", + "saved": "저장되었습니다.", + "checkoutUnavailable": "지금은 결제를 이용할 수 없습니다. 잠시 후 다시 시도하세요.", + "defaultRole": "기본 역할", + "keysTitle": "내 API 키", + "keysIntro": "내 키를 사용하면 AI 비용이 청구되지 않습니다. 키는 암호화되며 다시 표시되지 않고 내 코파일럿만 사용합니다.", + "keysDisabled": "이 서버에서는 아직 내 키 사용이 활성화되지 않았습니다.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "설정됨(…{last4})", + "keyNotSet": "설정 안 됨", + "keyPlaceholder": "키 붙여넣기", + "saveKey": "저장", + "removeKey": "삭제", + "walletTitle": "사용량 기반 크레딧: {balance}", + "walletIntro": "클라우드 모델이 저희 키로 실행될 때만 사용됩니다: 제공업체 비용 + 10%. 무료 모델과 내 키는 비용이 들지 않습니다.", + "addCredit": "{amount} 충전", + "usageWhen": "일시", + "usageWhat": "항목", + "usageCharge": "요금", + "refunded": "환불됨" } } diff --git a/apps/web/messages/nl.json b/apps/web/messages/nl.json index 878d4ef9..72a2c78c 100644 --- a/apps/web/messages/nl.json +++ b/apps/web/messages/nl.json @@ -2316,5 +2316,142 @@ "login": "Ik heb al een account", "failed": "De uitnodiging kon niet worden geaccepteerd." } + }, + "copilot": { + "title": "AI-copilot", + "listeningFor": "AI-copilot luistert mee voor {names}", + "consent": "Laat iedereen weten dat een AI-copilot meeluistert; die schrijft uit wat er gezegd wordt.", + "open": "AI-copilot openen", + "close": "Sluiten", + "proOnly": "De AI-copilot zit in je call, maakt aantekeningen, stelt vragen voor en schrijft je follow-ups. Hij hoort bij PairUX Pro.", + "upgrade": "Upgraden naar Pro", + "signIn": "Log in om de AI-copilot te gebruiken.", + "role": "Wat is jouw rol in deze meeting?", + "listensFor": "Let op", + "mode": "Modellen", + "modes": { + "auto": "Gratis modellen, betaalde cloud als terugval", + "free": "Alleen gratis zelfgehoste modellen", + "byok": "Mijn eigen API-sleutels", + "cloud": "Cloud met de beste kwaliteit (betalen naar gebruik)" + }, + "pricingNote": "Gratis modellen draaien kosteloos op onze servers. Cloudmodellen betaal je naar gebruik tegen de kosten van de provider + 10%, of gratis met je eigen sleutels.", + "settings": "Sleutels en tegoed", + "start": "Copilot starten", + "stop": "Stoppen en follow-ups schrijven", + "stopping": "Follow-ups schrijven…", + "live": "Luistert", + "providers": "Spraak: {stt} · AI: {llm}", + "waiting": "wachten", + "recap": "Laatste 30 s samenvatten", + "recapTitle": "Laatste 30 seconden", + "suggested": "Voorgestelde vraag", + "jump": "Toon waar", + "dismiss": "Negeren", + "tabs": { + "notes": "Notities", + "questions": "Vragen", + "transcript": "Transcript", + "ask": "Vraag" + }, + "summary": "Samenvatting", + "keyPoints": "Kernpunten", + "decisions": "Besluiten", + "actionItems": "Actiepunten", + "figures": "Belangrijke cijfers en data", + "owner": "Eigenaar: {owner}", + "nothingYet": "Nog niets. Notities verschijnen terwijl mensen praten.", + "noQuestions": "Nog geen open vragen.", + "answered": "Beantwoord", + "noTranscript": "Er is nog niemand gehoord. Wat er gezegd wordt, verschijnt hier een paar seconden later.", + "askPlaceholder": "Vraag privé: ‘Wat was het budget ook alweer?’", + "askSend": "Vragen", + "askHint": "Alleen jij ziet dit. Antwoorden komen uit wat er gezegd is.", + "followUps": "Follow-ups", + "message": "Samenvattingsbericht", + "copy": "Kopiëren", + "copied": "Gekopieerd", + "review": "Gespreksanalyse", + "focusArea": "Aandachtspunt", + "talkListen": "Je sprak {you}% van de tijd", + "questionsResolved": "{answered} van {asked} vragen beantwoord", + "clarity": { + "clear": "Follow-ups zijn duidelijk", + "partial": "Follow-ups zijn deels duidelijk", + "unclear": "Follow-ups zijn onduidelijk" + }, + "thin": "Het transcript is te kort om dit gesprek eerlijk te beoordelen.", + "fullReport": "Volledige notities openen", + "restart": "Opnieuw beginnen", + "roles": { + "general": { + "label": "Algemeen", + "meetings": "Elke meeting." + }, + "sales": { + "label": "Sales", + "meetings": "Discovery calls, demo's en follow-ups." + }, + "recruiter": { + "label": "Recruiter", + "meetings": "Screenings, sollicitatiegesprekken en kandidaat-debriefs." + }, + "product": { + "label": "Productmanager", + "meetings": "Gebruikersinterviews, planning en stakeholderoverleg." + }, + "founder": { + "label": "Oprichter", + "meetings": "Klantgesprekken, gesprekken met investeerders en partnermeetings." + }, + "success": { + "label": "Customer Success", + "meetings": "Check-ins, verlengingen en escalaties." + }, + "engineer": { + "label": "Engineer", + "meetings": "Requirements, standups, designreviews en 1-op-1's." + } + } + }, + "copilotPages": { + "listTitle": "AI-copilot-notities", + "listIntro": "Notities, follow-ups en analyses van elke meeting waar je AI-copilot bij was.", + "settingsLink": "Sleutels en tegoed", + "loadError": "Je copilot-notities konden niet worden geladen.", + "empty": "Nog geen copilot-notities. Start de AI-copilot vanuit het paneel in een meeting.", + "live": "live", + "untitled": "Naamloze meeting", + "reportTitle": "AI-copilot-notities", + "back": "Alle copilot-notities", + "noFollowUps": "Er zijn geen follow-ups geschreven.", + "rewriteFollowUps": "Follow-ups herschrijven", + "noReview": "Nog geen analyse.", + "rewriteReview": "Analyse herschrijven", + "settingsTitle": "Instellingen AI-copilot", + "topupPending": "Bedankt! Je tegoed verschijnt hier zodra de betaling is bevestigd.", + "saved": "Opgeslagen.", + "checkoutUnavailable": "Afrekenen is nu niet beschikbaar. Probeer het zo opnieuw.", + "defaultRole": "Standaardrol", + "keysTitle": "Je eigen API-sleutels", + "keysIntro": "Gebruik je eigen sleutels en we rekenen nooit iets voor AI. Sleutels worden versleuteld, nooit meer getoond en alleen door je copilot gebruikt.", + "keysDisabled": "Eigen sleutels zijn op deze server nog niet ingeschakeld.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "Ingesteld (…{last4})", + "keyNotSet": "Niet ingesteld", + "keyPlaceholder": "Plak een sleutel", + "saveKey": "Opslaan", + "removeKey": "Verwijderen", + "walletTitle": "Tegoed voor betalen naar gebruik: {balance}", + "walletIntro": "Alleen gebruikt als een cloudmodel op onze sleutels draait: kosten van de provider + 10%. Gratis modellen en je eigen sleutels kosten niets.", + "addCredit": "{amount} toevoegen", + "usageWhen": "Wanneer", + "usageWhat": "Wat", + "usageCharge": "Kosten", + "refunded": "terugbetaald" } } diff --git a/apps/web/messages/pl.json b/apps/web/messages/pl.json index 1e44dfd7..1d606478 100644 --- a/apps/web/messages/pl.json +++ b/apps/web/messages/pl.json @@ -2316,5 +2316,142 @@ "login": "Mam już konto", "failed": "Nie udało się przyjąć zaproszenia." } + }, + "copilot": { + "title": "Copilot AI", + "listeningFor": "Copilot AI słucha dla: {names}", + "consent": "Poinformuj wszystkich, że słucha copilot AI; transkrybuje to, co zostało powiedziane.", + "open": "Otwórz copilota AI", + "close": "Zamknij", + "proOnly": "Copilot AI uczestniczy w Twojej rozmowie, robi notatki, podpowiada pytania i pisze wiadomości follow-up. Jest częścią PairUX Pro.", + "upgrade": "Przejdź na Pro", + "signIn": "Zaloguj się, aby korzystać z copilota AI.", + "role": "Kim jesteś na tym spotkaniu?", + "listensFor": "Słucha pod kątem", + "mode": "Modele", + "modes": { + "auto": "Darmowe modele, płatna chmura jako rezerwa", + "free": "Tylko darmowe modele na własnych serwerach", + "byok": "Moje własne klucze API", + "cloud": "Chmura o najlepszej jakości (płatność za użycie)" + }, + "pricingNote": "Darmowe modele działają bezpłatnie na naszych serwerach. Modele w chmurze są płatne za użycie po kosztach dostawcy + 10% albo darmowe z Twoimi własnymi kluczami.", + "settings": "Klucze i środki", + "start": "Uruchom copilota", + "stop": "Zatrzymaj i napisz follow-upy", + "stopping": "Piszę follow-upy…", + "live": "Słucha", + "providers": "Mowa: {stt} · AI: {llm}", + "waiting": "oczekiwanie", + "recap": "Podsumuj ostatnie 30 s", + "recapTitle": "Ostatnie 30 sekund", + "suggested": "Proponowane pytanie", + "jump": "Pokaż gdzie", + "dismiss": "Odrzuć", + "tabs": { + "notes": "Notatki", + "questions": "Pytania", + "transcript": "Transkrypcja", + "ask": "Zapytaj" + }, + "summary": "Podsumowanie", + "keyPoints": "Najważniejsze punkty", + "decisions": "Decyzje", + "actionItems": "Zadania", + "figures": "Kluczowe liczby i daty", + "owner": "Odpowiedzialny: {owner}", + "nothingYet": "Jeszcze nic. Notatki pojawiają się w trakcie rozmowy.", + "noQuestions": "Brak otwartych pytań.", + "answered": "Odpowiedziano", + "noTranscript": "Jeszcze nikogo nie usłyszano. Wypowiedzi pojawiają się tutaj kilka sekund po ich wypowiedzeniu.", + "askPlaceholder": "Zapytaj prywatnie: „Jaki był budżet?”", + "askSend": "Zapytaj", + "askHint": "Widzisz to tylko Ty. Odpowiedzi opierają się na tym, co zostało powiedziane.", + "followUps": "Follow-upy", + "message": "Wiadomość z podsumowaniem", + "copy": "Kopiuj", + "copied": "Skopiowano", + "review": "Analiza rozmowy", + "focusArea": "Obszar do poprawy", + "talkListen": "Twój udział w rozmowie: {you}%", + "questionsResolved": "Odpowiedzi na {answered} z {asked} pytań", + "clarity": { + "clear": "Follow-upy są jasne", + "partial": "Follow-upy są częściowo jasne", + "unclear": "Follow-upy są niejasne" + }, + "thin": "Transkrypcja jest za krótka, aby rzetelnie ocenić tę rozmowę.", + "fullReport": "Otwórz pełne notatki", + "restart": "Zacznij od nowa", + "roles": { + "general": { + "label": "Ogólna", + "meetings": "Dowolne spotkanie." + }, + "sales": { + "label": "Sprzedaż", + "meetings": "Rozmowy rozpoznawcze, prezentacje i follow-upy." + }, + "recruiter": { + "label": "Rekruter", + "meetings": "Rozmowy wstępne, rozmowy kwalifikacyjne i omówienia kandydatów." + }, + "product": { + "label": "Product Manager", + "meetings": "Wywiady z użytkownikami, planowanie i spotkania z interesariuszami." + }, + "founder": { + "label": "Założyciel", + "meetings": "Rozmowy z klientami, inwestorami i partnerami." + }, + "success": { + "label": "Customer Success", + "meetings": "Spotkania kontrolne, odnowienia i eskalacje." + }, + "engineer": { + "label": "Inżynier", + "meetings": "Wymagania, standupy, przeglądy projektów i spotkania 1:1." + } + } + }, + "copilotPages": { + "listTitle": "Notatki copilota AI", + "listIntro": "Notatki, follow-upy i analizy ze wszystkich spotkań, w których uczestniczył Twój copilot AI.", + "settingsLink": "Klucze i środki", + "loadError": "Nie udało się wczytać notatek copilota.", + "empty": "Brak notatek copilota. Uruchom copilota AI z panelu podczas dowolnego spotkania.", + "live": "na żywo", + "untitled": "Spotkanie bez tytułu", + "reportTitle": "Notatki copilota AI", + "back": "Wszystkie notatki copilota", + "noFollowUps": "Nie napisano żadnych follow-upów.", + "rewriteFollowUps": "Napisz follow-upy od nowa", + "noReview": "Brak analizy.", + "rewriteReview": "Napisz analizę od nowa", + "settingsTitle": "Ustawienia copilota AI", + "topupPending": "Dziękujemy! Środki pojawią się tutaj po potwierdzeniu płatności.", + "saved": "Zapisano.", + "checkoutUnavailable": "Płatność jest teraz niedostępna. Spróbuj ponownie za chwilę.", + "defaultRole": "Domyślna rola", + "keysTitle": "Twoje własne klucze API", + "keysIntro": "Używaj własnych kluczy, a nigdy nie obciążymy Cię za AI. Klucze są szyfrowane, nigdy więcej nie są wyświetlane i używa ich tylko Twój copilot.", + "keysDisabled": "Własne klucze nie są jeszcze włączone na tym serwerze.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "Ustawiony (…{last4})", + "keyNotSet": "Nieustawiony", + "keyPlaceholder": "Wklej klucz", + "saveKey": "Zapisz", + "removeKey": "Usuń", + "walletTitle": "Środki na płatność za użycie: {balance}", + "walletIntro": "Używane tylko wtedy, gdy model w chmurze działa na naszych kluczach: koszt dostawcy + 10%. Darmowe modele i Twoje własne klucze nic nie kosztują.", + "addCredit": "Dodaj {amount}", + "usageWhen": "Kiedy", + "usageWhat": "Co", + "usageCharge": "Opłata", + "refunded": "zwrócono" } } diff --git a/apps/web/messages/pt.json b/apps/web/messages/pt.json index 04c9f37f..8af2b764 100644 --- a/apps/web/messages/pt.json +++ b/apps/web/messages/pt.json @@ -2316,5 +2316,142 @@ "login": "Já tenho uma conta", "failed": "Não foi possível aceitar o convite." } + }, + "copilot": { + "title": "Copiloto de IA", + "listeningFor": "O copiloto de IA está ouvindo para {names}", + "consent": "Avise a todos que um copiloto de IA está ouvindo; ele transcreve o que é dito.", + "open": "Abrir copiloto de IA", + "close": "Fechar", + "proOnly": "O copiloto de IA participa da sua chamada, faz anotações, sugere perguntas e escreve seus follow-ups. Ele faz parte do PairUX Pro.", + "upgrade": "Fazer upgrade para o Pro", + "signIn": "Entre para usar o copiloto de IA.", + "role": "Qual é o seu papel nesta reunião?", + "listensFor": "Presta atenção a", + "mode": "Modelos", + "modes": { + "auto": "Modelos gratuitos, nuvem paga como reserva", + "free": "Somente modelos gratuitos auto-hospedados", + "byok": "Minhas próprias chaves de API", + "cloud": "Nuvem de melhor qualidade (pague pelo uso)" + }, + "pricingNote": "Os modelos gratuitos rodam nos nossos servidores sem custo. Os modelos na nuvem são cobrados pelo uso, ao custo do provedor + 10%, ou gratuitos com suas próprias chaves.", + "settings": "Chaves e crédito", + "start": "Iniciar copiloto", + "stop": "Parar e escrever follow-ups", + "stopping": "Escrevendo follow-ups…", + "live": "Ouvindo", + "providers": "Fala: {stt} · IA: {llm}", + "waiting": "aguardando", + "recap": "Resumir últimos 30 s", + "recapTitle": "Últimos 30 segundos", + "suggested": "Pergunta sugerida", + "jump": "Mostrar onde", + "dismiss": "Dispensar", + "tabs": { + "notes": "Notas", + "questions": "Perguntas", + "transcript": "Transcrição", + "ask": "Perguntar" + }, + "summary": "Resumo", + "keyPoints": "Pontos-chave", + "decisions": "Decisões", + "actionItems": "Itens de ação", + "figures": "Números e datas importantes", + "owner": "Responsável: {owner}", + "nothingYet": "Nada ainda. As notas aparecem conforme as pessoas falam.", + "noQuestions": "Nenhuma pergunta em aberto ainda.", + "answered": "Respondida", + "noTranscript": "Ninguém foi ouvido ainda. A fala aparece aqui alguns segundos depois de ser dita.", + "askPlaceholder": "Pergunte em particular: “Qual era o orçamento mesmo?”", + "askSend": "Perguntar", + "askHint": "Só você vê isto. As respostas vêm do que foi dito.", + "followUps": "Follow-ups", + "message": "Mensagem de resumo", + "copy": "Copiar", + "copied": "Copiado", + "review": "Análise da conversa", + "focusArea": "Ponto de foco", + "talkListen": "Você falou {you}% do tempo", + "questionsResolved": "{answered} de {asked} perguntas respondidas", + "clarity": { + "clear": "Os follow-ups estão claros", + "partial": "Os follow-ups estão parcialmente claros", + "unclear": "Os follow-ups não estão claros" + }, + "thin": "A transcrição é curta demais para analisar esta conversa de forma justa.", + "fullReport": "Abrir notas completas", + "restart": "Recomeçar", + "roles": { + "general": { + "label": "Geral", + "meetings": "Qualquer reunião." + }, + "sales": { + "label": "Vendas", + "meetings": "Chamadas de descoberta, demos e follow-ups." + }, + "recruiter": { + "label": "Recrutador", + "meetings": "Triagens, entrevistas e debriefs de candidatos." + }, + "product": { + "label": "Gerente de Produto", + "meetings": "Entrevistas com usuários, planejamento e alinhamentos com stakeholders." + }, + "founder": { + "label": "Fundador", + "meetings": "Chamadas com clientes, conversas com investidores e reuniões com parceiros." + }, + "success": { + "label": "Customer Success", + "meetings": "Check-ins, renovações e escalonamentos." + }, + "engineer": { + "label": "Engenheiro", + "meetings": "Requisitos, standups, revisões de design e 1:1s." + } + } + }, + "copilotPages": { + "listTitle": "Notas do copiloto de IA", + "listIntro": "Notas, follow-ups e análises de todas as reuniões em que seu copiloto de IA participou.", + "settingsLink": "Chaves e crédito", + "loadError": "Não foi possível carregar suas notas do copiloto.", + "empty": "Ainda não há notas do copiloto. Inicie o copiloto de IA pelo painel em qualquer reunião.", + "live": "ao vivo", + "untitled": "Reunião sem título", + "reportTitle": "Notas do copiloto de IA", + "back": "Todas as notas do copiloto", + "noFollowUps": "Nenhum follow-up foi escrito.", + "rewriteFollowUps": "Reescrever follow-ups", + "noReview": "Ainda não há análise.", + "rewriteReview": "Reescrever análise", + "settingsTitle": "Configurações do copiloto de IA", + "topupPending": "Obrigado! Seu crédito aparece aqui assim que o pagamento for confirmado.", + "saved": "Salvo.", + "checkoutUnavailable": "O checkout está indisponível no momento. Tente novamente em instantes.", + "defaultRole": "Papel padrão", + "keysTitle": "Suas próprias chaves de API", + "keysIntro": "Use suas próprias chaves e nunca cobraremos pela IA. As chaves são criptografadas, nunca mais exibidas e usadas apenas pelo seu copiloto.", + "keysDisabled": "Chaves próprias ainda não estão ativadas neste servidor.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "Definida (…{last4})", + "keyNotSet": "Não definida", + "keyPlaceholder": "Cole uma chave", + "saveKey": "Salvar", + "removeKey": "Remover", + "walletTitle": "Crédito pré-pago por uso: {balance}", + "walletIntro": "Usado apenas quando um modelo na nuvem roda com as nossas chaves: custo do provedor + 10%. Modelos gratuitos e suas próprias chaves não custam nada.", + "addCredit": "Adicionar {amount}", + "usageWhen": "Quando", + "usageWhat": "O quê", + "usageCharge": "Cobrança", + "refunded": "reembolsado" } } diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json index 326d20c3..efdf9ee2 100644 --- a/apps/web/messages/ru.json +++ b/apps/web/messages/ru.json @@ -2316,5 +2316,142 @@ "login": "У меня уже есть аккаунт", "failed": "Не удалось принять приглашение." } + }, + "copilot": { + "title": "ИИ-копилот", + "listeningFor": "ИИ-копилот слушает для: {names}", + "consent": "Предупредите всех, что слушает ИИ-копилот: он расшифровывает всё сказанное.", + "open": "Открыть ИИ-копилота", + "close": "Закрыть", + "proOnly": "ИИ-копилот участвует в вашем звонке, ведёт заметки, подсказывает вопросы и пишет письма по итогам. Он входит в PairUX Pro.", + "upgrade": "Перейти на Pro", + "signIn": "Войдите, чтобы пользоваться ИИ-копилотом.", + "role": "Какая у вас роль на этой встрече?", + "listensFor": "Обращает внимание на", + "mode": "Модели", + "modes": { + "auto": "Бесплатные модели, платное облако как запасной вариант", + "free": "Только бесплатные модели на наших серверах", + "byok": "Мои собственные API-ключи", + "cloud": "Облако лучшего качества (оплата по факту)" + }, + "pricingNote": "Бесплатные модели работают на наших серверах без оплаты. Облачные модели оплачиваются по факту использования по цене провайдера + 10% или бесплатны с вашими собственными ключами.", + "settings": "Ключи и баланс", + "start": "Запустить копилота", + "stop": "Остановить и написать итоги", + "stopping": "Пишу итоги…", + "live": "Слушает", + "providers": "Речь: {stt} · ИИ: {llm}", + "waiting": "ожидание", + "recap": "Пересказ последних 30 с", + "recapTitle": "Последние 30 секунд", + "suggested": "Предлагаемый вопрос", + "jump": "Показать где", + "dismiss": "Скрыть", + "tabs": { + "notes": "Заметки", + "questions": "Вопросы", + "transcript": "Расшифровка", + "ask": "Спросить" + }, + "summary": "Итоги", + "keyPoints": "Ключевые моменты", + "decisions": "Решения", + "actionItems": "Задачи", + "figures": "Ключевые цифры и даты", + "owner": "Ответственный: {owner}", + "nothingYet": "Пока ничего. Заметки появляются по ходу разговора.", + "noQuestions": "Открытых вопросов пока нет.", + "answered": "Отвечено", + "noTranscript": "Пока никого не слышно. Речь появляется здесь через несколько секунд после того, как прозвучала.", + "askPlaceholder": "Спросите приватно: «Какой там был бюджет?»", + "askSend": "Спросить", + "askHint": "Это видите только вы. Ответы основаны на том, что было сказано.", + "followUps": "Итоги", + "message": "Итоговое сообщение", + "copy": "Копировать", + "copied": "Скопировано", + "review": "Разбор разговора", + "focusArea": "Над чем поработать", + "talkListen": "Вы говорили {you}% времени", + "questionsResolved": "Отвечено {answered} из {asked} вопросов", + "clarity": { + "clear": "Итоги понятны", + "partial": "Итоги понятны частично", + "unclear": "Итоги непонятны" + }, + "thin": "Расшифровка слишком короткая, чтобы объективно оценить этот разговор.", + "fullReport": "Открыть полные заметки", + "restart": "Начать заново", + "roles": { + "general": { + "label": "Общая", + "meetings": "Любая встреча." + }, + "sales": { + "label": "Продажи", + "meetings": "Ознакомительные звонки, демо и повторные контакты." + }, + "recruiter": { + "label": "Рекрутер", + "meetings": "Скрининги, собеседования и обсуждения кандидатов." + }, + "product": { + "label": "Продакт-менеджер", + "meetings": "Интервью с пользователями, планирование и встречи со стейкхолдерами." + }, + "founder": { + "label": "Основатель", + "meetings": "Звонки с клиентами, беседы с инвесторами и встречи с партнёрами." + }, + "success": { + "label": "Customer Success", + "meetings": "Регулярные созвоны, продления и эскалации." + }, + "engineer": { + "label": "Инженер", + "meetings": "Требования, стендапы, дизайн-ревью и встречи 1:1." + } + } + }, + "copilotPages": { + "listTitle": "Заметки ИИ-копилота", + "listIntro": "Заметки, итоги и разборы со всех встреч, где был ваш ИИ-копилот.", + "settingsLink": "Ключи и баланс", + "loadError": "Не удалось загрузить заметки копилота.", + "empty": "Заметок копилота пока нет. Запустите ИИ-копилота с панели на любой встрече.", + "live": "в эфире", + "untitled": "Встреча без названия", + "reportTitle": "Заметки ИИ-копилота", + "back": "Все заметки копилота", + "noFollowUps": "Итоги не написаны.", + "rewriteFollowUps": "Переписать итоги", + "noReview": "Разбора пока нет.", + "rewriteReview": "Переписать разбор", + "settingsTitle": "Настройки ИИ-копилота", + "topupPending": "Спасибо! Баланс появится здесь, как только платёж подтвердится.", + "saved": "Сохранено.", + "checkoutUnavailable": "Оплата сейчас недоступна. Попробуйте чуть позже.", + "defaultRole": "Роль по умолчанию", + "keysTitle": "Ваши собственные API-ключи", + "keysIntro": "С собственными ключами мы никогда не берём с вас плату за ИИ. Ключи шифруются, больше никогда не показываются и используются только вашим копилотом.", + "keysDisabled": "Собственные ключи на этом сервере пока не включены.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "Задан (…{last4})", + "keyNotSet": "Не задан", + "keyPlaceholder": "Вставьте ключ", + "saveKey": "Сохранить", + "removeKey": "Удалить", + "walletTitle": "Баланс для оплаты по факту: {balance}", + "walletIntro": "Используется только когда облачная модель работает на наших ключах: цена провайдера + 10%. Бесплатные модели и ваши собственные ключи ничего не стоят.", + "addCredit": "Пополнить на {amount}", + "usageWhen": "Когда", + "usageWhat": "Что", + "usageCharge": "Списание", + "refunded": "возвращено" } } diff --git a/apps/web/messages/sv.json b/apps/web/messages/sv.json index 28cff9ec..e45b8615 100644 --- a/apps/web/messages/sv.json +++ b/apps/web/messages/sv.json @@ -2316,5 +2316,142 @@ "login": "Jag har redan ett konto", "failed": "Det gick inte att acceptera inbjudan." } + }, + "copilot": { + "title": "AI-copilot", + "listeningFor": "AI-copiloten lyssnar för {names}", + "consent": "Låt alla veta att en AI-copilot lyssnar; den transkriberar det som sägs.", + "open": "Öppna AI-copilot", + "close": "Stäng", + "proOnly": "AI-copiloten är med i ditt samtal, antecknar, föreslår frågor och skriver dina uppföljningar. Den ingår i PairUX Pro.", + "upgrade": "Uppgradera till Pro", + "signIn": "Logga in för att använda AI-copiloten.", + "role": "Vem är du i det här mötet?", + "listensFor": "Lyssnar efter", + "mode": "Modeller", + "modes": { + "auto": "Gratis modeller, betalt moln som reserv", + "free": "Endast gratis egenhostade modeller", + "byok": "Mina egna API-nycklar", + "cloud": "Moln med bäst kvalitet (betala per användning)" + }, + "pricingNote": "Gratis modeller körs kostnadsfritt på våra servrar. Molnmodeller betalas per användning till leverantörens kostnad + 10 %, eller gratis med dina egna nycklar.", + "settings": "Nycklar och saldo", + "start": "Starta copilot", + "stop": "Stoppa och skriv uppföljningar", + "stopping": "Skriver uppföljningar…", + "live": "Lyssnar", + "providers": "Tal: {stt} · AI: {llm}", + "waiting": "väntar", + "recap": "Sammanfatta senaste 30 s", + "recapTitle": "Senaste 30 sekunderna", + "suggested": "Föreslagen fråga", + "jump": "Visa var", + "dismiss": "Avfärda", + "tabs": { + "notes": "Anteckningar", + "questions": "Frågor", + "transcript": "Transkription", + "ask": "Fråga" + }, + "summary": "Sammanfattning", + "keyPoints": "Huvudpunkter", + "decisions": "Beslut", + "actionItems": "Åtgärder", + "figures": "Viktiga siffror och datum", + "owner": "Ansvarig: {owner}", + "nothingYet": "Inget än. Anteckningar visas medan folk pratar.", + "noQuestions": "Inga öppna frågor än.", + "answered": "Besvarad", + "noTranscript": "Ingen har hörts än. Tal visas här några sekunder efter att det sagts.", + "askPlaceholder": "Fråga privat: ”Vad var budgeten nu igen?”", + "askSend": "Fråga", + "askHint": "Bara du ser det här. Svaren bygger på det som sagts.", + "followUps": "Uppföljningar", + "message": "Sammanfattningsmeddelande", + "copy": "Kopiera", + "copied": "Kopierat", + "review": "Samtalsanalys", + "focusArea": "Fokusområde", + "talkListen": "Du pratade {you} % av tiden", + "questionsResolved": "{answered} av {asked} frågor besvarade", + "clarity": { + "clear": "Uppföljningarna är tydliga", + "partial": "Uppföljningarna är delvis tydliga", + "unclear": "Uppföljningarna är otydliga" + }, + "thin": "Transkriptionen är för kort för att bedöma samtalet rättvist.", + "fullReport": "Öppna alla anteckningar", + "restart": "Börja om", + "roles": { + "general": { + "label": "Allmänt", + "meetings": "Alla typer av möten." + }, + "sales": { + "label": "Sälj", + "meetings": "Behovsanalyser, demos och uppföljningar." + }, + "recruiter": { + "label": "Rekryterare", + "meetings": "Screeningar, intervjuer och kandidatgenomgångar." + }, + "product": { + "label": "Produktchef", + "meetings": "Användarintervjuer, planering och avstämningar med intressenter." + }, + "founder": { + "label": "Grundare", + "meetings": "Kundsamtal, investerarsamtal och partnermöten." + }, + "success": { + "label": "Customer Success", + "meetings": "Avstämningar, förnyelser och eskaleringar." + }, + "engineer": { + "label": "Ingenjör", + "meetings": "Krav, standups, designgranskningar och 1:1-samtal." + } + } + }, + "copilotPages": { + "listTitle": "AI-copilotens anteckningar", + "listIntro": "Anteckningar, uppföljningar och analyser från varje möte din AI-copilot var med på.", + "settingsLink": "Nycklar och saldo", + "loadError": "Det gick inte att läsa in dina copilot-anteckningar.", + "empty": "Inga copilot-anteckningar än. Starta AI-copiloten från panelen i valfritt möte.", + "live": "live", + "untitled": "Namnlöst möte", + "reportTitle": "AI-copilotens anteckningar", + "back": "Alla copilot-anteckningar", + "noFollowUps": "Inga uppföljningar skrevs.", + "rewriteFollowUps": "Skriv om uppföljningar", + "noReview": "Ingen analys än.", + "rewriteReview": "Skriv om analysen", + "settingsTitle": "Inställningar för AI-copilot", + "topupPending": "Tack! Ditt saldo visas här när betalningen har bekräftats.", + "saved": "Sparat.", + "checkoutUnavailable": "Kassan är inte tillgänglig just nu. Försök igen om en stund.", + "defaultRole": "Standardroll", + "keysTitle": "Dina egna API-nycklar", + "keysIntro": "Använd dina egna nycklar så debiterar vi dig aldrig för AI. Nycklarna krypteras, visas aldrig igen och används bara av din copilot.", + "keysDisabled": "Egna nycklar är inte aktiverade på den här servern än.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "Angiven (…{last4})", + "keyNotSet": "Inte angiven", + "keyPlaceholder": "Klistra in en nyckel", + "saveKey": "Spara", + "removeKey": "Ta bort", + "walletTitle": "Saldo för betalning per användning: {balance}", + "walletIntro": "Används bara när en molnmodell körs med våra nycklar: leverantörens kostnad + 10 %. Gratis modeller och dina egna nycklar kostar ingenting.", + "addCredit": "Lägg till {amount}", + "usageWhen": "När", + "usageWhat": "Vad", + "usageCharge": "Belopp", + "refunded": "återbetald" } } diff --git a/apps/web/messages/tr.json b/apps/web/messages/tr.json index a0bf0195..40c4f672 100644 --- a/apps/web/messages/tr.json +++ b/apps/web/messages/tr.json @@ -2316,5 +2316,142 @@ "login": "Zaten hesabım var", "failed": "Davet kabul edilemedi." } + }, + "copilot": { + "title": "Yapay zekâ yardımcı pilotu", + "listeningFor": "Yapay zekâ yardımcı pilotu {names} için dinliyor", + "consent": "Herkese bir yapay zekâ yardımcı pilotunun dinlediğini bildir; söylenenleri yazıya döker.", + "open": "Yapay zekâ yardımcı pilotunu aç", + "close": "Kapat", + "proOnly": "Yapay zekâ yardımcı pilotu görüşmene katılır, not alır, soru önerir ve takip mesajlarını yazar. PairUX Pro'nun bir parçasıdır.", + "upgrade": "Pro'ya geç", + "signIn": "Yapay zekâ yardımcı pilotunu kullanmak için giriş yap.", + "role": "Bu toplantıdaki rolün ne?", + "listensFor": "Dikkat ettiği konular", + "mode": "Modeller", + "modes": { + "auto": "Ücretsiz modeller, yedek olarak ücretli bulut", + "free": "Yalnızca ücretsiz, kendi sunucumuzdaki modeller", + "byok": "Kendi API anahtarlarım", + "cloud": "En kaliteli bulut (kullandıkça öde)" + }, + "pricingNote": "Ücretsiz modeller sunucularımızda ücretsiz çalışır. Bulut modelleri sağlayıcı maliyeti + %10 ile kullandıkça ödenir ya da kendi anahtarlarınla ücretsizdir.", + "settings": "Anahtarlar ve bakiye", + "start": "Yardımcı pilotu başlat", + "stop": "Durdur ve takip mesajlarını yaz", + "stopping": "Takip mesajları yazılıyor…", + "live": "Dinliyor", + "providers": "Konuşma: {stt} · Yapay zekâ: {llm}", + "waiting": "bekleniyor", + "recap": "Son 30 sn'yi özetle", + "recapTitle": "Son 30 saniye", + "suggested": "Önerilen soru", + "jump": "Yerini göster", + "dismiss": "Kapat", + "tabs": { + "notes": "Notlar", + "questions": "Sorular", + "transcript": "Döküm", + "ask": "Sor" + }, + "summary": "Özet", + "keyPoints": "Önemli noktalar", + "decisions": "Kararlar", + "actionItems": "Yapılacaklar", + "figures": "Önemli rakamlar ve tarihler", + "owner": "Sorumlu: {owner}", + "nothingYet": "Henüz bir şey yok. Notlar insanlar konuştukça görünür.", + "noQuestions": "Henüz açık soru yok.", + "answered": "Yanıtlandı", + "noTranscript": "Henüz kimse duyulmadı. Konuşmalar söylendikten birkaç saniye sonra burada görünür.", + "askPlaceholder": "Gizlice sor: “Bütçe neydi?”", + "askSend": "Sor", + "askHint": "Bunu yalnızca sen görürsün. Yanıtlar söylenenlere dayanır.", + "followUps": "Takip mesajları", + "message": "Özet mesajı", + "copy": "Kopyala", + "copied": "Kopyalandı", + "review": "Görüşme değerlendirmesi", + "focusArea": "Odak alanı", + "talkListen": "Konuşma payın: %{you}", + "questionsResolved": "{asked} sorudan {answered} tanesi yanıtlandı", + "clarity": { + "clear": "Takip mesajları net", + "partial": "Takip mesajları kısmen net", + "unclear": "Takip mesajları net değil" + }, + "thin": "Döküm, bu görüşmeyi adil biçimde değerlendirmek için çok kısa.", + "fullReport": "Tüm notları aç", + "restart": "Baştan başla", + "roles": { + "general": { + "label": "Genel", + "meetings": "Her türlü toplantı." + }, + "sales": { + "label": "Satış", + "meetings": "Keşif görüşmeleri, demolar ve takipler." + }, + "recruiter": { + "label": "İşe alım uzmanı", + "meetings": "Ön elemeler, mülakatlar ve aday değerlendirmeleri." + }, + "product": { + "label": "Ürün Yöneticisi", + "meetings": "Kullanıcı görüşmeleri, planlama ve paydaş toplantıları." + }, + "founder": { + "label": "Kurucu", + "meetings": "Müşteri görüşmeleri, yatırımcı sohbetleri ve ortak toplantıları." + }, + "success": { + "label": "Müşteri Başarısı", + "meetings": "Düzenli görüşmeler, yenilemeler ve eskalasyonlar." + }, + "engineer": { + "label": "Mühendis", + "meetings": "Gereksinimler, standup'lar, tasarım incelemeleri ve birebir görüşmeler." + } + } + }, + "copilotPages": { + "listTitle": "Yapay zekâ yardımcı pilotu notları", + "listIntro": "Yapay zekâ yardımcı pilotunun katıldığı her toplantıdan notlar, takip mesajları ve değerlendirmeler.", + "settingsLink": "Anahtarlar ve bakiye", + "loadError": "Yardımcı pilot notların yüklenemedi.", + "empty": "Henüz yardımcı pilot notu yok. Herhangi bir toplantıda paneldeki yapay zekâ yardımcı pilotunu başlat.", + "live": "canlı", + "untitled": "Adsız toplantı", + "reportTitle": "Yapay zekâ yardımcı pilotu notları", + "back": "Tüm yardımcı pilot notları", + "noFollowUps": "Hiç takip mesajı yazılmadı.", + "rewriteFollowUps": "Takip mesajlarını yeniden yaz", + "noReview": "Henüz değerlendirme yok.", + "rewriteReview": "Değerlendirmeyi yeniden yaz", + "settingsTitle": "Yapay zekâ yardımcı pilotu ayarları", + "topupPending": "Teşekkürler! Bakiyen, ödeme onaylanınca burada görünür.", + "saved": "Kaydedildi.", + "checkoutUnavailable": "Ödeme şu anda kullanılamıyor. Birazdan tekrar dene.", + "defaultRole": "Varsayılan rol", + "keysTitle": "Kendi API anahtarların", + "keysIntro": "Kendi anahtarlarını kullan, yapay zekâ için senden asla ücret almayalım. Anahtarlar şifrelenir, bir daha asla gösterilmez ve yalnızca yardımcı pilotun tarafından kullanılır.", + "keysDisabled": "Kendi anahtarların bu sunucuda henüz etkin değil.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "Ayarlı (…{last4})", + "keyNotSet": "Ayarlı değil", + "keyPlaceholder": "Bir anahtar yapıştır", + "saveKey": "Kaydet", + "removeKey": "Kaldır", + "walletTitle": "Kullandıkça öde bakiyesi: {balance}", + "walletIntro": "Yalnızca bir bulut modeli bizim anahtarlarımızla çalıştığında kullanılır: sağlayıcı maliyeti + %10. Ücretsiz modeller ve kendi anahtarların hiçbir ücret gerektirmez.", + "addCredit": "{amount} ekle", + "usageWhen": "Ne zaman", + "usageWhat": "Ne", + "usageCharge": "Ücret", + "refunded": "iade edildi" } } diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json index 8c6ce4eb..3b1f9452 100644 --- a/apps/web/messages/uk.json +++ b/apps/web/messages/uk.json @@ -2316,5 +2316,142 @@ "login": "У мене вже є акаунт", "failed": "Не вдалося прийняти запрошення." } + }, + "copilot": { + "title": "ШІ-копілот", + "listeningFor": "ШІ-копілот слухає для: {names}", + "consent": "Попередьте всіх, що слухає ШІ-копілот: він розшифровує все сказане.", + "open": "Відкрити ШІ-копілота", + "close": "Закрити", + "proOnly": "ШІ-копілот бере участь у вашому дзвінку, веде нотатки, підказує запитання й пише листи за підсумками. Він входить до PairUX Pro.", + "upgrade": "Перейти на Pro", + "signIn": "Увійдіть, щоб користуватися ШІ-копілотом.", + "role": "Яка ваша роль на цій зустрічі?", + "listensFor": "Звертає увагу на", + "mode": "Моделі", + "modes": { + "auto": "Безкоштовні моделі, платна хмара як запасний варіант", + "free": "Лише безкоштовні моделі на наших серверах", + "byok": "Мої власні API-ключі", + "cloud": "Хмара найкращої якості (оплата за фактом)" + }, + "pricingNote": "Безкоштовні моделі працюють на наших серверах без оплати. Хмарні моделі оплачуються за фактом використання за ціною провайдера + 10% або безкоштовні з вашими власними ключами.", + "settings": "Ключі та баланс", + "start": "Запустити копілота", + "stop": "Зупинити й написати підсумки", + "stopping": "Пишу підсумки…", + "live": "Слухає", + "providers": "Мовлення: {stt} · ШІ: {llm}", + "waiting": "очікування", + "recap": "Переказ останніх 30 с", + "recapTitle": "Останні 30 секунд", + "suggested": "Запропоноване запитання", + "jump": "Показати де", + "dismiss": "Сховати", + "tabs": { + "notes": "Нотатки", + "questions": "Запитання", + "transcript": "Розшифровка", + "ask": "Запитати" + }, + "summary": "Підсумок", + "keyPoints": "Ключові моменти", + "decisions": "Рішення", + "actionItems": "Завдання", + "figures": "Ключові цифри й дати", + "owner": "Відповідальний: {owner}", + "nothingYet": "Поки нічого. Нотатки з'являються під час розмови.", + "noQuestions": "Відкритих запитань поки немає.", + "answered": "Відповідь є", + "noTranscript": "Поки нікого не чути. Мовлення з'являється тут за кілька секунд після того, як прозвучало.", + "askPlaceholder": "Запитайте приватно: «Який там був бюджет?»", + "askSend": "Запитати", + "askHint": "Це бачите лише ви. Відповіді ґрунтуються на тому, що було сказано.", + "followUps": "Підсумки", + "message": "Підсумкове повідомлення", + "copy": "Копіювати", + "copied": "Скопійовано", + "review": "Розбір розмови", + "focusArea": "Над чим попрацювати", + "talkListen": "Ви говорили {you}% часу", + "questionsResolved": "Відповіді на {answered} з {asked} запитань", + "clarity": { + "clear": "Підсумки зрозумілі", + "partial": "Підсумки зрозумілі частково", + "unclear": "Підсумки незрозумілі" + }, + "thin": "Розшифровка надто коротка, щоб об'єктивно оцінити цю розмову.", + "fullReport": "Відкрити повні нотатки", + "restart": "Почати знову", + "roles": { + "general": { + "label": "Загальна", + "meetings": "Будь-яка зустріч." + }, + "sales": { + "label": "Продажі", + "meetings": "Ознайомчі дзвінки, демо й повторні контакти." + }, + "recruiter": { + "label": "Рекрутер", + "meetings": "Скринінги, співбесіди й обговорення кандидатів." + }, + "product": { + "label": "Продакт-менеджер", + "meetings": "Інтерв'ю з користувачами, планування й зустрічі зі стейкхолдерами." + }, + "founder": { + "label": "Засновник", + "meetings": "Дзвінки з клієнтами, розмови з інвесторами й зустрічі з партнерами." + }, + "success": { + "label": "Customer Success", + "meetings": "Регулярні зустрічі, продовження й ескалації." + }, + "engineer": { + "label": "Інженер", + "meetings": "Вимоги, стендапи, дизайн-рев'ю та зустрічі 1:1." + } + } + }, + "copilotPages": { + "listTitle": "Нотатки ШІ-копілота", + "listIntro": "Нотатки, підсумки й розбори з усіх зустрічей, де був ваш ШІ-копілот.", + "settingsLink": "Ключі та баланс", + "loadError": "Не вдалося завантажити нотатки копілота.", + "empty": "Нотаток копілота поки немає. Запустіть ШІ-копілота з панелі на будь-якій зустрічі.", + "live": "наживо", + "untitled": "Зустріч без назви", + "reportTitle": "Нотатки ШІ-копілота", + "back": "Усі нотатки копілота", + "noFollowUps": "Підсумки не написано.", + "rewriteFollowUps": "Переписати підсумки", + "noReview": "Розбору поки немає.", + "rewriteReview": "Переписати розбір", + "settingsTitle": "Налаштування ШІ-копілота", + "topupPending": "Дякуємо! Баланс з'явиться тут, щойно платіж підтвердиться.", + "saved": "Збережено.", + "checkoutUnavailable": "Оплата зараз недоступна. Спробуйте трохи згодом.", + "defaultRole": "Роль за замовчуванням", + "keysTitle": "Ваші власні API-ключі", + "keysIntro": "З власними ключами ми ніколи не стягуємо з вас плату за ШІ. Ключі шифруються, більше ніколи не показуються й використовуються лише вашим копілотом.", + "keysDisabled": "Власні ключі на цьому сервері ще не ввімкнено.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "Задано (…{last4})", + "keyNotSet": "Не задано", + "keyPlaceholder": "Вставте ключ", + "saveKey": "Зберегти", + "removeKey": "Видалити", + "walletTitle": "Баланс для оплати за фактом: {balance}", + "walletIntro": "Використовується лише тоді, коли хмарна модель працює на наших ключах: ціна провайдера + 10%. Безкоштовні моделі та ваші власні ключі нічого не коштують.", + "addCredit": "Поповнити на {amount}", + "usageWhen": "Коли", + "usageWhat": "Що", + "usageCharge": "Списання", + "refunded": "повернено" } } diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index a0fc3028..efb28c4e 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -2316,5 +2316,142 @@ "login": "Tôi đã có tài khoản", "failed": "Không thể chấp nhận lời mời." } + }, + "copilot": { + "title": "Trợ lý AI", + "listeningFor": "Trợ lý AI đang lắng nghe cho {names}", + "consent": "Hãy cho mọi người biết có trợ lý AI đang lắng nghe; trợ lý sẽ chép lại những gì được nói.", + "open": "Mở trợ lý AI", + "close": "Đóng", + "proOnly": "Trợ lý AI tham gia cuộc gọi của bạn, ghi chú, gợi ý câu hỏi và soạn nội dung theo dõi. Tính năng này có trong PairUX Pro.", + "upgrade": "Nâng cấp lên Pro", + "signIn": "Đăng nhập để dùng trợ lý AI.", + "role": "Bạn là ai trong cuộc họp này?", + "listensFor": "Chú ý đến", + "mode": "Mô hình", + "modes": { + "auto": "Mô hình miễn phí, đám mây trả phí làm dự phòng", + "free": "Chỉ mô hình miễn phí tự lưu trữ", + "byok": "Khóa API của riêng tôi", + "cloud": "Đám mây chất lượng tốt nhất (trả theo mức dùng)" + }, + "pricingNote": "Mô hình miễn phí chạy trên máy chủ của chúng tôi, không tốn phí. Mô hình đám mây trả theo mức dùng với giá nhà cung cấp + 10%, hoặc miễn phí với khóa của riêng bạn.", + "settings": "Khóa và số dư", + "start": "Bắt đầu trợ lý", + "stop": "Dừng và soạn nội dung theo dõi", + "stopping": "Đang soạn nội dung theo dõi…", + "live": "Đang nghe", + "providers": "Giọng nói: {stt} · AI: {llm}", + "waiting": "đang chờ", + "recap": "Tóm tắt 30 giây qua", + "recapTitle": "30 giây vừa qua", + "suggested": "Câu hỏi gợi ý", + "jump": "Hiện vị trí", + "dismiss": "Bỏ qua", + "tabs": { + "notes": "Ghi chú", + "questions": "Câu hỏi", + "transcript": "Bản ghi", + "ask": "Hỏi" + }, + "summary": "Tóm tắt", + "keyPoints": "Ý chính", + "decisions": "Quyết định", + "actionItems": "Việc cần làm", + "figures": "Số liệu và mốc thời gian chính", + "owner": "Phụ trách: {owner}", + "nothingYet": "Chưa có gì. Ghi chú sẽ xuất hiện khi mọi người nói chuyện.", + "noQuestions": "Chưa có câu hỏi nào còn mở.", + "answered": "Đã trả lời", + "noTranscript": "Chưa nghe thấy ai. Lời nói sẽ xuất hiện ở đây vài giây sau khi được nói.", + "askPlaceholder": "Hỏi riêng: “Ngân sách là bao nhiêu nhỉ?”", + "askSend": "Hỏi", + "askHint": "Chỉ bạn thấy nội dung này. Câu trả lời dựa trên những gì đã được nói.", + "followUps": "Nội dung theo dõi", + "message": "Tin nhắn tóm tắt", + "copy": "Sao chép", + "copied": "Đã sao chép", + "review": "Đánh giá cuộc trò chuyện", + "focusArea": "Điểm cần cải thiện", + "talkListen": "Bạn đã nói {you}% thời gian", + "questionsResolved": "Đã trả lời {answered} trên {asked} câu hỏi", + "clarity": { + "clear": "Nội dung theo dõi rõ ràng", + "partial": "Nội dung theo dõi rõ ràng một phần", + "unclear": "Nội dung theo dõi chưa rõ ràng" + }, + "thin": "Bản ghi quá ngắn để đánh giá cuộc trò chuyện này một cách công bằng.", + "fullReport": "Mở toàn bộ ghi chú", + "restart": "Bắt đầu lại", + "roles": { + "general": { + "label": "Chung", + "meetings": "Mọi cuộc họp." + }, + "sales": { + "label": "Bán hàng", + "meetings": "Cuộc gọi khám phá nhu cầu, demo và theo dõi." + }, + "recruiter": { + "label": "Tuyển dụng", + "meetings": "Sàng lọc, phỏng vấn và trao đổi về ứng viên." + }, + "product": { + "label": "Quản lý sản phẩm", + "meetings": "Phỏng vấn người dùng, lập kế hoạch và họp với các bên liên quan." + }, + "founder": { + "label": "Nhà sáng lập", + "meetings": "Gọi với khách hàng, trò chuyện với nhà đầu tư và họp với đối tác." + }, + "success": { + "label": "Chăm sóc khách hàng", + "meetings": "Kiểm tra định kỳ, gia hạn và xử lý sự cố." + }, + "engineer": { + "label": "Kỹ sư", + "meetings": "Yêu cầu, họp đứng, đánh giá thiết kế và họp 1:1." + } + } + }, + "copilotPages": { + "listTitle": "Ghi chú của trợ lý AI", + "listIntro": "Ghi chú, nội dung theo dõi và đánh giá từ mọi cuộc họp có trợ lý AI của bạn tham gia.", + "settingsLink": "Khóa và số dư", + "loadError": "Không tải được ghi chú của trợ lý.", + "empty": "Chưa có ghi chú nào của trợ lý. Hãy bắt đầu trợ lý AI từ bảng điều khiển trong bất kỳ cuộc họp nào.", + "live": "trực tiếp", + "untitled": "Cuộc họp chưa đặt tên", + "reportTitle": "Ghi chú của trợ lý AI", + "back": "Tất cả ghi chú của trợ lý", + "noFollowUps": "Chưa soạn nội dung theo dõi nào.", + "rewriteFollowUps": "Soạn lại nội dung theo dõi", + "noReview": "Chưa có đánh giá.", + "rewriteReview": "Viết lại đánh giá", + "settingsTitle": "Cài đặt trợ lý AI", + "topupPending": "Cảm ơn bạn! Số dư sẽ hiện ở đây khi thanh toán được xác nhận.", + "saved": "Đã lưu.", + "checkoutUnavailable": "Hiện không thể thanh toán. Vui lòng thử lại sau ít phút.", + "defaultRole": "Vai trò mặc định", + "keysTitle": "Khóa API của riêng bạn", + "keysIntro": "Dùng khóa của riêng bạn thì chúng tôi không bao giờ tính phí AI. Khóa được mã hóa, không bao giờ hiển thị lại và chỉ trợ lý của bạn sử dụng.", + "keysDisabled": "Máy chủ này chưa bật tính năng dùng khóa riêng.", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "Đã đặt (…{last4})", + "keyNotSet": "Chưa đặt", + "keyPlaceholder": "Dán khóa", + "saveKey": "Lưu", + "removeKey": "Xóa", + "walletTitle": "Số dư trả theo mức dùng: {balance}", + "walletIntro": "Chỉ dùng khi mô hình đám mây chạy bằng khóa của chúng tôi: giá nhà cung cấp + 10%. Mô hình miễn phí và khóa của riêng bạn không tốn gì.", + "addCredit": "Nạp {amount}", + "usageWhen": "Thời gian", + "usageWhat": "Nội dung", + "usageCharge": "Phí", + "refunded": "đã hoàn tiền" } } diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index 755b0bc3..84f14615 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -2316,5 +2316,142 @@ "login": "我已有账号", "failed": "无法接受邀请。" } + }, + "copilot": { + "title": "AI 副驾驶", + "listeningFor": "AI 副驾驶正在为 {names} 聆听", + "consent": "请告知所有人 AI 副驾驶正在聆听;它会转录大家所说的话。", + "open": "打开 AI 副驾驶", + "close": "关闭", + "proOnly": "AI 副驾驶会加入你的通话,记录笔记、建议问题并撰写跟进内容。它包含在 PairUX Pro 中。", + "upgrade": "升级到 Pro", + "signIn": "登录后即可使用 AI 副驾驶。", + "role": "你在这个会议中是什么角色?", + "listensFor": "关注重点", + "mode": "模型", + "modes": { + "auto": "免费模型,付费云端作为后备", + "free": "仅使用免费的自托管模型", + "byok": "我自己的 API 密钥", + "cloud": "最佳质量云端(按用量付费)" + }, + "pricingNote": "免费模型在我们的服务器上运行,不收费。云端模型按用量付费,价格为服务商成本 + 10%;使用你自己的密钥则免费。", + "settings": "密钥和余额", + "start": "启动副驾驶", + "stop": "停止并撰写跟进", + "stopping": "正在撰写跟进…", + "live": "正在聆听", + "providers": "语音:{stt} · AI:{llm}", + "waiting": "等待中", + "recap": "回顾最近 30 秒", + "recapTitle": "最近 30 秒", + "suggested": "建议的问题", + "jump": "显示位置", + "dismiss": "忽略", + "tabs": { + "notes": "笔记", + "questions": "问题", + "transcript": "文字记录", + "ask": "提问" + }, + "summary": "摘要", + "keyPoints": "要点", + "decisions": "决定", + "actionItems": "待办事项", + "figures": "关键数字和日期", + "owner": "负责人:{owner}", + "nothingYet": "暂无内容。大家开始说话后,笔记会显示在这里。", + "noQuestions": "暂无未解决的问题。", + "answered": "已回答", + "noTranscript": "还没有听到任何人说话。发言会在几秒后显示在这里。", + "askPlaceholder": "私下提问:“预算是多少来着?”", + "askSend": "提问", + "askHint": "只有你能看到。回答基于会议中说过的内容。", + "followUps": "跟进", + "message": "回顾消息", + "copy": "复制", + "copied": "已复制", + "review": "对话复盘", + "focusArea": "改进重点", + "talkListen": "你的发言时间占 {you}%", + "questionsResolved": "{asked} 个问题中已回答 {answered} 个", + "clarity": { + "clear": "跟进内容清晰", + "partial": "跟进内容部分清晰", + "unclear": "跟进内容不清晰" + }, + "thin": "文字记录太短,无法公正地复盘这次对话。", + "fullReport": "打开完整笔记", + "restart": "重新开始", + "roles": { + "general": { + "label": "通用", + "meetings": "任何会议。" + }, + "sales": { + "label": "销售", + "meetings": "需求挖掘电话、演示和跟进。" + }, + "recruiter": { + "label": "招聘", + "meetings": "初筛、面试和候选人复盘。" + }, + "product": { + "label": "产品经理", + "meetings": "用户访谈、规划和干系人同步。" + }, + "founder": { + "label": "创始人", + "meetings": "客户电话、投资人沟通和合作伙伴会议。" + }, + "success": { + "label": "客户成功", + "meetings": "定期回访、续约和问题升级。" + }, + "engineer": { + "label": "工程师", + "meetings": "需求讨论、站会、设计评审和一对一。" + } + } + }, + "copilotPages": { + "listTitle": "AI 副驾驶笔记", + "listIntro": "AI 副驾驶参加过的每场会议的笔记、跟进和复盘。", + "settingsLink": "密钥和余额", + "loadError": "无法加载你的副驾驶笔记。", + "empty": "还没有副驾驶笔记。在任何会议中从面板启动 AI 副驾驶。", + "live": "进行中", + "untitled": "未命名会议", + "reportTitle": "AI 副驾驶笔记", + "back": "所有副驾驶笔记", + "noFollowUps": "没有撰写跟进内容。", + "rewriteFollowUps": "重写跟进", + "noReview": "暂无复盘。", + "rewriteReview": "重写复盘", + "settingsTitle": "AI 副驾驶设置", + "topupPending": "谢谢!付款确认后,余额会显示在这里。", + "saved": "已保存。", + "checkoutUnavailable": "结账暂时不可用,请稍后再试。", + "defaultRole": "默认角色", + "keysTitle": "你自己的 API 密钥", + "keysIntro": "使用你自己的密钥,我们绝不会向你收取 AI 费用。密钥经过加密,不会再次显示,且仅供你的副驾驶使用。", + "keysDisabled": "此服务器尚未启用自有密钥。", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs" + }, + "keySet": "已设置(…{last4})", + "keyNotSet": "未设置", + "keyPlaceholder": "粘贴密钥", + "saveKey": "保存", + "removeKey": "移除", + "walletTitle": "按用量付费余额:{balance}", + "walletIntro": "仅在云端模型使用我们的密钥运行时扣费:服务商成本 + 10%。免费模型和你自己的密钥不产生任何费用。", + "addCredit": "充值 {amount}", + "usageWhen": "时间", + "usageWhat": "项目", + "usageCharge": "费用", + "refunded": "已退款" } } diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 2069c669..67e79143 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -26,7 +26,11 @@ const nextConfig: NextConfig = { output: 'standalone', // Transpile shared packages - transpilePackages: ['@pairux/shared-types', '@pairux/meeting-translation'], + transpilePackages: [ + '@pairux/shared-types', + '@pairux/meeting-translation', + '@pairux/meeting-copilot', + ], // Image optimization images: { diff --git a/apps/web/package.json b/apps/web/package.json index 93c740bc..6868f127 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^1.3.0", + "@pairux/meeting-copilot": "workspace:*", "@pairux/meeting-translation": "workspace:*", "@profullstack/affiliates": "^0.2.0", "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", diff --git a/apps/web/src/app/api/billing/copilot-topup/route.ts b/apps/web/src/app/api/billing/copilot-topup/route.ts new file mode 100644 index 00000000..9c563d94 --- /dev/null +++ b/apps/web/src/app/api/billing/copilot-topup/route.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; +import { CoinPayError, METHOD_LABELS, normalizeMethod } from '@/lib/coinpay-client'; +import { CheckoutError, startPlanCheckout } from '@/lib/billing/plan-checkout'; +import { copilotTopup } from '@/lib/copilot/pricing'; +import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; +import { successResponse, errorResponse, handleApiError } from '@/lib/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const Body = z.object({ + topup: z.enum(['copilot_credit_5', 'copilot_credit_20', 'copilot_credit_50']), + /** A coin (USDC_POL, BTC, ...) or PAYPAL. Omitted: CoinPay's hosted checkout, if offered. */ + method: z.string().optional(), +}); + +// POST /api/billing/copilot-topup — prepaid AI copilot credit for pay-as-you-go +// cloud models. The CoinPay webhook adds it to copilot_wallets once settled. +export async function POST(request: Request) { + try { + const body = Body.parse(await request.json()); + const topup = copilotTopup(body.topup); + if (!topup) return errorResponse('Unknown top-up', 400); + let method = null; + if (body.method) { + method = normalizeMethod(body.method); + if (!method) return errorResponse('Unsupported payment method', 400); + } + const supabase = await createClient(); + const { user, error: authError } = await getAuthenticatedUser(supabase); + if (authError || !user) return errorResponse('Sign in to add copilot credit', 401); + + const result = await startPlanCheckout({ + plan: { id: topup.id, label: 'PairUX AI copilot credit', priceUsd: topup.priceUsd }, + userId: user.id, + email: user.email ?? null, + method, + description: `PairUX AI copilot credit: ${String(topup.priceUsd)} USD (pay as you go, pairux.com)`, + returnPath: '/settings/copilot', + type: 'copilot_credit', + }); + if (result.kind === 'choose') + return successResponse({ + choose_method: true, + topup: topup.id, + amount_usd: topup.priceUsd, + methods: result.methods.map((id) => ({ id, label: METHOD_LABELS[id] })), + }); + return successResponse({ + payment_id: result.paymentId, + checkout_url: result.checkoutUrl, + rail: result.rail, + topup: topup.id, + amount_usd: topup.priceUsd, + }); + } catch (error) { + if (error instanceof CheckoutError) return errorResponse(error.message, error.status); + if (error instanceof CoinPayError) { + console.error('[billing/copilot-topup] CoinPay refused:', error.status, error.message); + return errorResponse(`CoinPay: ${error.message}`, 502); + } + return handleApiError(error); + } +} diff --git a/apps/web/src/app/api/copilot/[id]/[action]/route.ts b/apps/web/src/app/api/copilot/[id]/[action]/route.ts new file mode 100644 index 00000000..bcba0daa --- /dev/null +++ b/apps/web/src/app/api/copilot/[id]/[action]/route.ts @@ -0,0 +1,47 @@ +import { errorResponse, successResponse } from '@/lib/api'; +import { writeFollowUps, writeReview } from '@/lib/copilot/agent'; +import { copilotError, runtimeOrThrow } from '@/lib/copilot/http'; +import { requestUser } from '@/lib/request-user'; +import { FixedWindowRateLimiter } from '@/lib/rate-limit'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const writesByUser = new FixedWindowRateLimiter(20, 60_000); + +interface RouteParams { + params: Promise<{ id: string; action: string }>; +} + +// POST /api/copilot/:id/follow-ups | /api/copilot/:id/review — write (or +// rewrite) the follow-ups or the conversation review from what was heard. +export async function POST(request: Request, { params }: RouteParams) { + try { + const { id, action } = await params; + if (!UUID.test(id) || (action !== 'follow-ups' && action !== 'review')) + return errorResponse('Not found', 404); + const user = await requestUser(request); + if (!user) return errorResponse('Sign in first.', 401); + if (!writesByUser.check(user.id).success) + return errorResponse('Too many requests. Wait a moment.', 429); + const runtime = runtimeOrThrow(); + const stored = await runtime.store.get(id, user.id); + if (!stored) return errorResponse('Not found', 404); + if (!(await runtime.accounts.eligible(user.id))) + return errorResponse('The AI copilot is part of PairUX Pro.', 403); + const live = runtime.get(stored.state.sessionId, user.id); + const state = live?.state ?? stored.state; + const ctx = await runtime.context(state.sessionId, user.id, state.mode); + if (action === 'follow-ups') { + state.followUps = await writeFollowUps(runtime.llm, ctx, state, stored.participantId); + if (!state.followUps) return errorResponse('Nothing was heard to follow up on.', 400); + } else { + state.review = await writeReview(runtime.llm, ctx, state, stored.participantId); + } + await runtime.store.save(state); + return successResponse({ followUps: state.followUps, review: state.review }); + } catch (error) { + return copilotError(error); + } +} diff --git a/apps/web/src/app/api/copilot/[id]/route.ts b/apps/web/src/app/api/copilot/[id]/route.ts new file mode 100644 index 00000000..eeeec1af --- /dev/null +++ b/apps/web/src/app/api/copilot/[id]/route.ts @@ -0,0 +1,30 @@ +import { errorResponse, successResponse } from '@/lib/api'; +import { copilotError, runtimeOrThrow } from '@/lib/copilot/http'; +import { requestUser } from '@/lib/request-user'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +// GET /api/copilot/:id — one copilot's notes, transcript, follow-ups and review. +export async function GET(request: Request, { params }: RouteParams) { + try { + const { id } = await params; + if (!UUID.test(id)) return errorResponse('Not found', 404); + const user = await requestUser(request); + if (!user) return errorResponse('Sign in to see your copilot notes.', 401); + const runtime = runtimeOrThrow(); + const stored = await runtime.store.get(id, user.id); + if (!stored) return errorResponse('Not found', 404); + // A live one is read from memory: the database copy lags up to 30 s. + const live = runtime.get(stored.state.sessionId, user.id); + return successResponse({ copilot: live?.state ?? stored.state, title: stored.title }); + } catch (error) { + return copilotError(error); + } +} diff --git a/apps/web/src/app/api/copilot/route.ts b/apps/web/src/app/api/copilot/route.ts new file mode 100644 index 00000000..33837e9f --- /dev/null +++ b/apps/web/src/app/api/copilot/route.ts @@ -0,0 +1,18 @@ +import { errorResponse, successResponse } from '@/lib/api'; +import { copilotError, runtimeOrThrow } from '@/lib/copilot/http'; +import { requestUser } from '@/lib/request-user'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +// GET /api/copilot — the caller's copilots, newest first (history). +export async function GET(request: Request) { + try { + const user = await requestUser(request); + if (!user) return errorResponse('Sign in to see your copilot notes.', 401); + const runtime = runtimeOrThrow(); + return successResponse({ copilots: await runtime.store.list(user.id) }); + } catch (error) { + return copilotError(error); + } +} diff --git a/apps/web/src/app/api/copilot/settings/route.ts b/apps/web/src/app/api/copilot/settings/route.ts new file mode 100644 index 00000000..d21e56e7 --- /dev/null +++ b/apps/web/src/app/api/copilot/settings/route.ts @@ -0,0 +1,76 @@ +import { isCopilotMode, isCopilotRole } from '@pairux/meeting-copilot/types'; +import { errorResponse, successResponse } from '@/lib/api'; +import { + KEY_PROVIDERS, + byokAvailable, + validKeyShape, + type KeyProvider, +} from '@/lib/copilot/account'; +import { body, copilotError, runtimeOrThrow } from '@/lib/copilot/http'; +import { formatUsd } from '@/lib/copilot/pricing'; +import { requestUser } from '@/lib/request-user'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +// GET /api/copilot/settings — mode, role, which own keys are set (last 4 only), +// eligibility and the pay-as-you-go balance. +export async function GET(request: Request) { + try { + const user = await requestUser(request); + if (!user) return errorResponse('Sign in first.', 401); + const runtime = runtimeOrThrow(); + const [settings, eligible, balance] = await Promise.all([ + runtime.accounts.publicSettings(user.id), + runtime.accounts.eligible(user.id), + runtime.accounts.balanceMicros(user.id), + ]); + return successResponse({ + ...settings, + eligible, + byokAvailable: byokAvailable(), + balanceMicros: balance, + balance: formatUsd(balance), + }); + } catch (error) { + return copilotError(error); + } +} + +// PUT /api/copilot/settings — { mode?, role?, keys?: { anthropic?: string|null, ... } }. +// A key string replaces the stored key; null or "" deletes it. +export async function PUT(request: Request) { + try { + const user = await requestUser(request); + if (!user) return errorResponse('Sign in first.', 401); + const runtime = runtimeOrThrow(); + const input = await body(request); + if (input.mode !== undefined && !isCopilotMode(input.mode)) + return errorResponse('mode must be auto, free, byok or cloud.', 400); + if (input.role !== undefined && !isCopilotRole(input.role)) + return errorResponse('Unknown role.', 400); + const keys: Partial> = {}; + const raw = + input.keys && typeof input.keys === 'object' ? (input.keys as Record) : {}; + for (const provider of KEY_PROVIDERS) { + const value = raw[provider]; + if (value === undefined) continue; + if (value === null || value === '') { + keys[provider] = null; + continue; + } + if (typeof value !== 'string' || !validKeyShape(provider, value.trim())) + return errorResponse(`That does not look like a ${provider} API key.`, 400); + if (!byokAvailable()) return errorResponse('Own keys are not enabled on this server.', 503); + keys[provider] = value.trim(); + } + const saved = await runtime.accounts.saveSettings(user.id, { + ...(isCopilotMode(input.mode) ? { mode: input.mode } : {}), + ...(isCopilotRole(input.role) ? { role: input.role } : {}), + keys, + }); + return successResponse(saved); + } catch (error) { + return copilotError(error); + } +} diff --git a/apps/web/src/app/api/copilot/wallet/route.ts b/apps/web/src/app/api/copilot/wallet/route.ts new file mode 100644 index 00000000..ec0fbccb --- /dev/null +++ b/apps/web/src/app/api/copilot/wallet/route.ts @@ -0,0 +1,39 @@ +import { errorResponse, successResponse } from '@/lib/api'; +import { copilotError, runtimeOrThrow } from '@/lib/copilot/http'; +import { COPILOT_MARKUP, COPILOT_TOPUPS, formatUsd } from '@/lib/copilot/pricing'; +import { requestUser } from '@/lib/request-user'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +// GET /api/copilot/wallet — pay-as-you-go balance, top-ups on offer and the +// latest charges (provider cost + 10%). +export async function GET(request: Request) { + try { + const user = await requestUser(request); + if (!user) return errorResponse('Sign in first.', 401); + const runtime = runtimeOrThrow(); + const [balance, usage] = await Promise.all([ + runtime.accounts.balanceMicros(user.id), + runtime.accounts.usage(user.id), + ]); + return successResponse({ + balanceMicros: balance, + balance: formatUsd(balance), + markup: COPILOT_MARKUP, + topups: COPILOT_TOPUPS, + usage: usage.map((row) => ({ + sessionId: row.session_id, + provider: row.provider, + kind: row.kind, + units: row.units, + charge: formatUsd(row.charge_micros), + chargeMicros: row.charge_micros, + status: row.status, + at: row.created_at, + })), + }); + } catch (error) { + return copilotError(error); + } +} diff --git a/apps/web/src/app/api/sessions/[sessionId]/copilot/ask/route.ts b/apps/web/src/app/api/sessions/[sessionId]/copilot/ask/route.ts new file mode 100644 index 00000000..09ea6d80 --- /dev/null +++ b/apps/web/src/app/api/sessions/[sessionId]/copilot/ask/route.ts @@ -0,0 +1,29 @@ +import { errorResponse, successResponse } from '@/lib/api'; +import { body, callerAgent, copilotError, text } from '@/lib/copilot/http'; +import { FixedWindowRateLimiter } from '@/lib/rate-limit'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const asksByUser = new FixedWindowRateLimiter(30, 60_000); + +interface RouteParams { + params: Promise<{ sessionId: string }>; +} + +// POST /api/sessions/:id/copilot/ask — message the copilot privately. +// Body: { participantId, question }. Answers { answer }. +export async function POST(request: Request, { params }: RouteParams) { + try { + const { sessionId } = await params; + const input = await body(request); + const question = text(input.question, 2000); + if (!question) return errorResponse('Ask the copilot something.', 400); + const { agent, caller } = await callerAgent(request, sessionId, text(input.participantId, 64)); + if (!asksByUser.check(caller.userId).success) + return errorResponse('Too many questions. Wait a moment.', 429); + return successResponse({ answer: await agent.ask(question) }); + } catch (error) { + return copilotError(error); + } +} diff --git a/apps/web/src/app/api/sessions/[sessionId]/copilot/questions/route.ts b/apps/web/src/app/api/sessions/[sessionId]/copilot/questions/route.ts new file mode 100644 index 00000000..d33ae345 --- /dev/null +++ b/apps/web/src/app/api/sessions/[sessionId]/copilot/questions/route.ts @@ -0,0 +1,24 @@ +import { errorResponse, successResponse } from '@/lib/api'; +import { body, callerAgent, copilotError, text } from '@/lib/copilot/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface RouteParams { + params: Promise<{ sessionId: string }>; +} + +// POST /api/sessions/:id/copilot/questions — mark an open question answered +// (or open again). Body: { participantId, id, answered }. +export async function POST(request: Request, { params }: RouteParams) { + try { + const { sessionId } = await params; + const input = await body(request); + const { agent } = await callerAgent(request, sessionId, text(input.participantId, 64)); + if (!agent.setQuestion(text(input.id, 64), input.answered !== false)) + return errorResponse('No such question.', 404); + return successResponse({ openQuestions: agent.state.openQuestions }); + } catch (error) { + return copilotError(error); + } +} diff --git a/apps/web/src/app/api/sessions/[sessionId]/copilot/recap/route.ts b/apps/web/src/app/api/sessions/[sessionId]/copilot/recap/route.ts new file mode 100644 index 00000000..d1bbbee1 --- /dev/null +++ b/apps/web/src/app/api/sessions/[sessionId]/copilot/recap/route.ts @@ -0,0 +1,28 @@ +import { errorResponse, successResponse } from '@/lib/api'; +import { body, callerAgent, copilotError, text } from '@/lib/copilot/http'; +import { FixedWindowRateLimiter } from '@/lib/rate-limit'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const recapsByUser = new FixedWindowRateLimiter(30, 60_000); + +interface RouteParams { + params: Promise<{ sessionId: string }>; +} + +// POST /api/sessions/:id/copilot/recap — "zoned out?": what was said in the +// last `seconds` (default 30, at most 300). Body: { participantId, seconds }. +export async function POST(request: Request, { params }: RouteParams) { + try { + const { sessionId } = await params; + const input = await body(request); + const seconds = Math.min(300, Math.max(10, Number(input.seconds) || 30)); + const { agent, caller } = await callerAgent(request, sessionId, text(input.participantId, 64)); + if (!recapsByUser.check(caller.userId).success) + return errorResponse('Too many recaps. Wait a moment.', 429); + return successResponse({ recap: await agent.recap(seconds), seconds }); + } catch (error) { + return copilotError(error); + } +} diff --git a/apps/web/src/app/api/sessions/[sessionId]/copilot/route.ts b/apps/web/src/app/api/sessions/[sessionId]/copilot/route.ts new file mode 100644 index 00000000..8ff24bbb --- /dev/null +++ b/apps/web/src/app/api/sessions/[sessionId]/copilot/route.ts @@ -0,0 +1,136 @@ +import { isCopilotMode, isCopilotRole, type CopilotStatus } from '@pairux/meeting-copilot/types'; +import { errorResponse, successResponse } from '@/lib/api'; +import { authorizeCopilotCaller } from '@/lib/copilot/access'; +import { COPILOT_REQUIRES_PRO } from '@/lib/copilot/account'; +import { body, copilotError, runtimeOrThrow } from '@/lib/copilot/http'; +import { copilotRuntime } from '@/lib/copilot/runtime'; +import { upgradePath } from '@/lib/plans'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface RouteParams { + params: Promise<{ sessionId: string }>; +} + +// GET /api/sessions/:id/copilot?participantId= — who has a copilot listening +// (everyone sees it), whether the caller may start one, and the caller's own. +export async function GET(request: Request, { params }: RouteParams) { + try { + const { sessionId } = await params; + const participantId = new URL(request.url).searchParams.get('participantId') ?? ''; + const runtime = copilotRuntime(); + // Guests without an account still see who is listening. + let caller; + try { + caller = await authorizeCopilotCaller(request, sessionId, participantId); + } catch { + caller = null; + } + if (!runtime || !caller) { + const status: CopilotStatus = { + available: Boolean(runtime), + eligible: false, + upgradeUrl: null, + active: runtime?.active(sessionId) ?? [], + mine: null, + mode: 'auto', + role: 'general', + }; + return successResponse(status); + } + const [eligible, settings] = await Promise.all([ + runtime.accounts.eligible(caller.userId), + runtime.accounts.settingsFor(caller.userId), + ]); + // A copilot left live by a redeploy resumes as soon as its owner looks. + const mine = + runtime.get(sessionId, caller.userId)?.state ?? + (await recoverIfLive(runtime, sessionId, caller.userId)); + const status: CopilotStatus = { + available: true, + eligible, + upgradeUrl: eligible ? null : upgradePath('pro'), + active: runtime.active(sessionId), + mine, + mode: settings.mode, + role: settings.role, + }; + return successResponse(status); + } catch (error) { + return copilotError(error); + } +} + +async function recoverIfLive( + runtime: NonNullable>, + sessionId: string, + userId: string +) { + const live = await runtime.store.findLive(sessionId, userId); + if (!live) return null; + const recovered = await runtime.recover(live); + if (!recovered) return null; + return 'state' in recovered ? recovered.state : recovered; +} + +// POST /api/sessions/:id/copilot — start (or resume) the caller's copilot. +// Body: { participantId, role, mode }. +export async function POST(request: Request, { params }: RouteParams) { + try { + const { sessionId } = await params; + const input = await body(request); + const runtime = runtimeOrThrow(); + const caller = await authorizeCopilotCaller( + request, + sessionId, + typeof input.participantId === 'string' ? input.participantId : '' + ); + if (caller.sessionStatus === 'ended') return errorResponse('This meeting has ended.', 400); + if (!(await runtime.accounts.eligible(caller.userId))) + return errorResponse(COPILOT_REQUIRES_PRO, 403); + const settings = await runtime.accounts.settingsFor(caller.userId); + const role = isCopilotRole(input.role) ? input.role : settings.role; + const mode = isCopilotMode(input.mode) ? input.mode : settings.mode; + // Remember the choice for next time (and for the CLI). + if (role !== settings.role || mode !== settings.mode) + await runtime.accounts.saveSettings(caller.userId, { role, mode }); + const agent = await runtime.start({ + sessionId, + userId: caller.userId, + participantId: caller.participantId, + role, + mode, + title: caller.title, + }); + if (agent.state.role !== role || agent.state.mode !== mode) { + agent.state.role = role; + agent.state.mode = mode; + await agent.save(); + } + return successResponse(agent.state); + } catch (error) { + return copilotError(error); + } +} + +// DELETE /api/sessions/:id/copilot — stop it; answers the final state with +// follow-ups and the review. +export async function DELETE(request: Request, { params }: RouteParams) { + try { + const { sessionId } = await params; + const runtime = runtimeOrThrow(); + const caller = await authorizeCopilotCaller( + request, + sessionId, + new URL(request.url).searchParams.get('participantId') ?? '' + ); + const state = await runtime.stop(sessionId, caller.userId); + if (state) return successResponse(state); + const live = await runtime.store.findLive(sessionId, caller.userId); + if (live) return successResponse(await runtime.finishStored(live)); + return errorResponse('No copilot is running for you in this meeting.', 404); + } catch (error) { + return copilotError(error); + } +} diff --git a/apps/web/src/app/api/sessions/[sessionId]/copilot/stream/route.ts b/apps/web/src/app/api/sessions/[sessionId]/copilot/stream/route.ts new file mode 100644 index 00000000..5761f106 --- /dev/null +++ b/apps/web/src/app/api/sessions/[sessionId]/copilot/stream/route.ts @@ -0,0 +1,72 @@ +import type { CopilotEvent } from '@pairux/meeting-copilot/types'; +import { CORS_HEADERS } from '@/lib/cors'; +import { body, callerAgent, copilotError } from '@/lib/copilot/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface RouteParams { + params: Promise<{ sessionId: string }>; +} + +// POST /api/sessions/:id/copilot/stream — the owner's live copilot events +// (state first, then lines, notes, suggestions, notices, ended). Body: { participantId }. +export async function POST(request: Request, { params }: RouteParams) { + try { + const { sessionId } = await params; + const input = await body(request); + const { agent } = await callerAgent( + request, + sessionId, + typeof input.participantId === 'string' ? input.participantId : '' + ); + const encoder = new TextEncoder(); + let heartbeat: ReturnType | undefined; + let send: ((event: CopilotEvent) => void) | null = null; + const stop = () => { + if (heartbeat) clearInterval(heartbeat); + if (send) agent.subscribers.delete(send); + send = null; + }; + const stream = new ReadableStream({ + start(controller) { + const close = () => { + stop(); + try { + controller.close(); + } catch { + /* already closed */ + } + }; + send = (event) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + if (event.type === 'ended') queueMicrotask(close); + }; + agent.subscribers.add(send); + send({ type: 'state', state: agent.state }); + heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(': copilot\n\n')); + agent.lastSeen = Date.now(); + } catch { + stop(); + } + }, 10_000); + request.signal.addEventListener('abort', close, { once: true }); + }, + cancel: stop, + }); + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-store, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + // The desktop app streams this from a file:// page. + ...CORS_HEADERS, + }, + }); + } catch (error) { + return copilotError(error); + } +} diff --git a/apps/web/src/app/api/sessions/[sessionId]/translation/feed/route.ts b/apps/web/src/app/api/sessions/[sessionId]/translation/feed/route.ts index c17c7c1f..2740fbbe 100644 --- a/apps/web/src/app/api/sessions/[sessionId]/translation/feed/route.ts +++ b/apps/web/src/app/api/sessions/[sessionId]/translation/feed/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { SpeechError } from '@profullstack/live-translate'; import { authorizeMeetingCaller, MeetingAccessError } from '@/lib/translation/access'; import { translationRuntime } from '@/lib/translation/server'; +import { copilotRuntime } from '@/lib/copilot/runtime'; import { FixedWindowRateLimiter } from '@/lib/rate-limit'; export const runtime = 'nodejs'; @@ -41,15 +42,26 @@ export async function POST(request: Request, { params }: RouteParams) { const self = await callerFor(sessionId, client, url.searchParams.get('self') ?? ''); if (!chunksByClient.check(`${sessionId}:${client}`).success) return NextResponse.json({ error: 'Too many audio chunks.' }, { status: 429 }); + // The same uploads feed live translation and any AI copilot in the meeting. const room = translationRuntime()?.rooms.get(sessionId); - if (!room || participant === self) return NextResponse.json({ feeder: false, wanted: false }); + const copilot = copilotRuntime(); + const copilotListening = (copilot?.listening(sessionId) ?? 0) > 0; + if ((!room && !copilotListening) || participant === self) + return NextResponse.json({ feeder: false, wanted: false }); const length = Number(request.headers.get('content-length') ?? 0); if (length > MAX_BYTES) return NextResponse.json({ error: 'Audio chunk too large.' }, { status: 413 }); const bytes = new Uint8Array(await request.arrayBuffer()); if (bytes.length > MAX_BYTES) return NextResponse.json({ error: 'Audio chunk too large.' }, { status: 413 }); - return NextResponse.json(room.feed(participant, client, bytes)); + const answers = [ + room?.feed(participant, client, bytes), + copilotListening ? copilot?.feed(sessionId, participant, client, bytes) : null, + ].filter((answer): answer is { feeder: boolean; wanted: boolean } => Boolean(answer)); + return NextResponse.json({ + feeder: answers.some((answer) => answer.feeder), + wanted: answers.some((answer) => answer.wanted), + }); } catch (error) { if (error instanceof MeetingAccessError || error instanceof SpeechError) return NextResponse.json({ error: error.message }, { status: error.status }); diff --git a/apps/web/src/app/api/sessions/[sessionId]/translation/route.ts b/apps/web/src/app/api/sessions/[sessionId]/translation/route.ts index f7a7f3db..9ac12a7b 100644 --- a/apps/web/src/app/api/sessions/[sessionId]/translation/route.ts +++ b/apps/web/src/app/api/sessions/[sessionId]/translation/route.ts @@ -2,6 +2,7 @@ import { authorizeMeetingCaller, MeetingAccessError } from '@/lib/translation/ac import { TRANSLATION_LANGUAGES } from '@/lib/translation/languages'; import { TRANSLATION_OFFER } from '@/lib/translation/meter'; import { translationRuntime } from '@/lib/translation/server'; +import { copilotRuntime } from '@/lib/copilot/runtime'; import { errorResponse, handleApiError, successResponse } from '@/lib/api'; export const runtime = 'nodejs'; @@ -21,11 +22,13 @@ export async function GET(request: Request, { params }: RouteParams) { const runtime = translationRuntime(); const access = runtime && caller.isHost ? await runtime.meter.access(caller.hostUserId) : null; return successResponse({ - available: Boolean(runtime), + // The copilot rides on the same uploads, so clients feed whenever it listens. + available: Boolean(runtime) || (copilotRuntime()?.listening(sessionId) ?? 0) > 0, languages: TRANSLATION_LANGUAGES.map(([code, name]) => ({ code, name })), isHost: caller.isHost, // Clients tap and upload remote tracks only while someone is listening. - listening: runtime?.rooms.get(sessionId)?.size ?? 0, + listening: + (runtime?.rooms.get(sessionId)?.size ?? 0) + (copilotRuntime()?.listening(sessionId) ?? 0), // Pro feature: everyone in the meeting can see whether the host's plan covers it. funded: runtime ? (await runtime.meter.funded(caller.hostUserId, sessionId)) || diff --git a/apps/web/src/app/api/v1/mcp/route.ts b/apps/web/src/app/api/v1/mcp/route.ts new file mode 100644 index 00000000..e5116395 --- /dev/null +++ b/apps/web/src/app/api/v1/mcp/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from 'next/server'; +import { CopilotLlmError } from '@/lib/copilot/llm'; +import { MCP_PROTOCOL_VERSION, MCP_TOOLS, McpToolError, callMcpTool } from '@/lib/copilot/mcp'; +import { copilotRuntime } from '@/lib/copilot/runtime'; +import { requestUser } from '@/lib/request-user'; +import { FixedWindowRateLimiter } from '@/lib/rate-limit'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const callsByUser = new FixedWindowRateLimiter(120, 60_000); + +interface JsonRpcRequest { + jsonrpc?: string; + id?: string | number | null; + method?: string; + params?: Record; +} + +const reply = (id: JsonRpcRequest['id'], result: unknown) => ({ + jsonrpc: '2.0', + id: id ?? null, + result, +}); +const failure = (id: JsonRpcRequest['id'], code: number, message: string) => ({ + jsonrpc: '2.0', + id: id ?? null, + error: { code, message }, +}); + +async function handle(message: JsonRpcRequest, userId: string): Promise { + const { id, method, params = {} } = message; + // Notifications (no id) get no reply. + if (id === undefined) return null; + switch (method) { + case 'initialize': + return reply(id, { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'pairux', version: '1.0.0' }, + instructions: + 'PairUX AI copilot: list your meeting copilots, read their notes and follow-ups, and ask a live copilot about the meeting in progress.', + }); + case 'ping': + return reply(id, {}); + case 'tools/list': + return reply(id, { tools: MCP_TOOLS }); + case 'tools/call': { + const runtime = copilotRuntime(); + if (!runtime) return failure(id, -32603, 'The AI copilot is turned off on this server.'); + const name = typeof params.name === 'string' ? params.name : ''; + const args = + params.arguments && typeof params.arguments === 'object' + ? (params.arguments as Record) + : {}; + try { + const result = await callMcpTool(runtime, userId, name, args); + return reply(id, { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + structuredContent: result, + }); + } catch (error) { + if (error instanceof McpToolError || error instanceof CopilotLlmError) + return reply(id, { content: [{ type: 'text', text: error.message }], isError: true }); + console.error('[mcp] tool failed:', error); + return failure(id, -32603, 'The tool failed.'); + } + } + default: + return failure(id, -32601, `Method not found: ${String(method)}`); + } +} + +// POST /api/v1/mcp — MCP over Streamable HTTP (JSON responses). Bearer auth: +// a `pairux login` CLI token or a PairUX session token. +export async function POST(request: Request) { + const user = await requestUser(request); + if (!user) + return NextResponse.json( + failure(null, -32001, 'Unauthorized: send Authorization: Bearer .'), + { + status: 401, + headers: { 'WWW-Authenticate': 'Bearer realm="pairux"' }, + } + ); + if (!callsByUser.check(user.id).success) + return NextResponse.json(failure(null, -32000, 'Too many requests.'), { status: 429 }); + const parsed: unknown = await request.json().catch(() => null); + if (!parsed || typeof parsed !== 'object') + return NextResponse.json(failure(null, -32700, 'Parse error'), { status: 400 }); + if (Array.isArray(parsed)) { + const replies = ( + await Promise.all(parsed.map((one) => handle(one as JsonRpcRequest, user.id))) + ).filter(Boolean); + return replies.length ? NextResponse.json(replies) : new Response(null, { status: 202 }); + } + const answer = await handle(parsed as JsonRpcRequest, user.id); + return answer ? NextResponse.json(answer) : new Response(null, { status: 202 }); +} + +// No server-initiated stream: GET is not offered (Streamable HTTP allows 405). +export function GET() { + return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'POST' } }); +} diff --git a/apps/web/src/app/api/webhooks/coinpay/route.ts b/apps/web/src/app/api/webhooks/coinpay/route.ts index 1a63f8fb..f6b9b831 100644 --- a/apps/web/src/app/api/webhooks/coinpay/route.ts +++ b/apps/web/src/app/api/webhooks/coinpay/route.ts @@ -4,6 +4,7 @@ import { verifyCoinPayWebhook } from '@profullstack/stack/coinpay'; import { asText, PAYMENT_PROPERTY, readCoinpayWebhook } from '@/lib/coinpay-client'; import { getPlanDef, TERM_DAYS } from '@/lib/plans'; import { translationPass } from '@/lib/translation/meter'; +import { copilotTopup } from '@/lib/copilot/pricing'; import { serviceClient } from '@/lib/supabase/service'; export const runtime = 'nodejs'; @@ -66,7 +67,7 @@ export async function POST(request: NextRequest) { // plan payment that names an account and a plan we sell. if (!payment && event.settled) { const meta = event.metadata; - const plan = getPlanDef(asText(meta.plan)); + const plan = getPlanDef(asText(meta.plan)) ?? copilotTopup(asText(meta.plan)); const userId = asText(meta.account_id) || asText(meta.user_id); if (plan && userId && (asText(meta.property) || PAYMENT_PROPERTY) === PAYMENT_PROPERTY) { const key = event.paymentId ?? event.sessionId ?? ''; @@ -102,6 +103,19 @@ export async function POST(request: NextRequest) { if (!event.settled) return NextResponse.json({ received: true }); + if (String(payment.plan).startsWith('copilot_credit_')) { + // Pay-as-you-go copilot credit: marked credited and added in one statement. + const { error: creditError } = await supabase.rpc('copilot_credit_payment', { + p_payment_id: rowKey, + p_status: event.status, + }); + if (creditError) { + console.error('[coinpay webhook] copilot_credit_payment failed:', creditError); + return NextResponse.json({ error: 'Credit failed' }, { status: 500 }); + } + return NextResponse.json({ received: true }); + } + if (String(payment.plan).startsWith('translation_')) { // Legacy prepaid translation credit: marked credited and added in one statement. const pass = translationPass(String(payment.plan)); diff --git a/apps/web/src/app/copilot/[id]/copilot-report.tsx b/apps/web/src/app/copilot/[id]/copilot-report.tsx new file mode 100644 index 00000000..41c3db75 --- /dev/null +++ b/apps/web/src/app/copilot/[id]/copilot-report.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import Link from 'next/link'; +import { useLocale, useTranslations } from 'next-intl'; +import { Bot, Copy, Loader2, RefreshCw } from 'lucide-react'; +import type { CopilotState } from '@pairux/meeting-copilot/types'; + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function Bullets({ items }: { items: string[] }) { + return ( +
    + {items.map((item, index) => ( +
  • {item}
  • + ))} +
+ ); +} + +export function CopilotReport({ id, header }: { id: string; header: ReactNode }) { + const t = useTranslations('copilotPages'); + const c = useTranslations('copilot'); + const locale = useLocale(); + const [state, setState] = useState(null); + const [title, setTitle] = useState(null); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(''); + + const load = useCallback(async () => { + const res = await fetch(`/api/copilot/${id}`); + if (res.status === 401) { + window.location.href = `/login?redirect=/copilot/${id}`; + return; + } + const body = (await res.json()) as { + data?: { copilot: CopilotState; title: string | null }; + error?: string; + }; + if (!res.ok || !body.data) setError(body.error ?? t('loadError')); + else { + setState(body.data.copilot); + setTitle(body.data.title); + } + }, [id, t]); + useEffect(() => { + void load(); + }, [load]); + + const rewrite = async (action: 'follow-ups' | 'review') => { + setBusy(action); + setError(''); + const res = await fetch(`/api/copilot/${id}/${action}`, { method: 'POST' }); + const body = (await res.json()) as { error?: string }; + if (!res.ok) setError(body.error ?? t('loadError')); + await load(); + setBusy(''); + }; + + const notes = state?.notes; + const review = state?.review; + return ( +
+ {header} +
+ + ← {t('back')} + +

+

+ {state && ( +

+ {c(`roles.${state.role}.label`)} · {new Date(state.startedAt).toLocaleString(locale)} + {state.status === 'live' && ` · ${t('live')}`} + {state.providers.llm && ` · ${state.providers.llm}`} +

+ )} + {error &&

{error}

} + {!state && !error && } + + {notes && ( + <> + {notes.summary && ( +
+

{notes.summary}

+
+ )} + {notes.keyPoints.length > 0 && ( +
+ +
+ )} + {notes.decisions.length > 0 && ( +
+ +
+ )} + {notes.actionItems.length > 0 && ( +
+ + item.owner ? `${item.text} (${c('owner', { owner: item.owner })})` : item.text + )} + /> +
+ )} + + )} + {state && state.openQuestions.length > 0 && ( +
+
    + {state.openQuestions.map((question) => ( +
  • + {question.status === 'answered' ? '✓ ' : '○ '} + {question.text} + {question.answer && → {question.answer}} +
  • + ))} +
+
+ )} + {state && state.figures.length > 0 && ( +
+
+ {state.figures.map((figure) => ( + + {figure.value} + {figure.context && · {figure.context}} + + ))} +
+
+ )} + + {state?.status === 'ended' && ( + <> +
+ {state.followUps ? ( + <> + + [item.text, item.owner && c('owner', { owner: item.owner }), item.due] + .filter(Boolean) + .join(' · ') + )} + /> + {state.followUps.message && ( +
+

+ {state.followUps.message} +

+ +
+ )} + + ) : ( +

{t('noFollowUps')}

+ )} + +
+
+ {review ? ( +
+ {review.thin ? ( +

{c('thin')}

+ ) : ( + <> + {review.focusArea && ( +

+ {c('focusArea')}: {review.focusArea} +

+ )} + +

+ {c(`clarity.${review.followUpClarity}`)} +

+ + )} +

+ {c('talkListen', { you: Math.round(review.talkListen.you * 100) })} ·{' '} + {c('questionsResolved', { + answered: review.questions.answered, + asked: review.questions.asked, + })} +

+ {review.note && !review.thin && ( +

{review.note}

+ )} +
+ ) : ( +

{t('noReview')}

+ )} + +
+ + )} + + {state && state.transcript.length > 0 && ( +
+
+ {state.transcript.map((line) => ( +

+ {line.name}: {line.text} +

+ ))} +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/copilot/[id]/page.tsx b/apps/web/src/app/copilot/[id]/page.tsx new file mode 100644 index 00000000..0c7ce90a --- /dev/null +++ b/apps/web/src/app/copilot/[id]/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; +import { Header } from '@/components/header'; +import { CopilotReport } from './copilot-report'; + +export async function generateMetadata(): Promise { + const t = await getTranslations('copilotPages'); + return { title: t('reportTitle'), robots: { index: false, follow: false } }; +} + +export const dynamic = 'force-dynamic'; + +export default async function CopilotReportPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return } />; +} diff --git a/apps/web/src/app/copilot/copilot-list.tsx b/apps/web/src/app/copilot/copilot-list.tsx new file mode 100644 index 00000000..71495968 --- /dev/null +++ b/apps/web/src/app/copilot/copilot-list.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useEffect, useState, type ReactNode } from 'react'; +import Link from 'next/link'; +import { useLocale, useTranslations } from 'next-intl'; +import { Bot, Loader2, Settings } from 'lucide-react'; + +interface CopilotListItem { + id: string; + sessionId: string; + role: string; + status: string; + title: string | null; + summary: string; + startedAt: string; +} + +export function CopilotList({ header }: { header: ReactNode }) { + const t = useTranslations('copilotPages'); + const roles = useTranslations('copilot.roles'); + const locale = useLocale(); + const [items, setItems] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + void fetch('/api/copilot').then(async (res) => { + if (res.status === 401) { + window.location.href = '/login?redirect=/copilot'; + return; + } + const body = (await res.json()) as { data?: { copilots: CopilotListItem[] }; error?: string }; + if (!res.ok) setError(body.error ?? t('loadError')); + else setItems(body.data?.copilots ?? []); + }); + }, [t]); + + return ( +
+ {header} +
+
+

+

+ +
+

{t('listIntro')}

+ {error &&

{error}

} + {!items && !error && } + {items?.length === 0 && ( +
+ {t('empty')} +
+ )} +
    + {items?.map((item) => ( +
  • + +

    + {roles.has(`${item.role}.label`) ? roles(`${item.role}.label`) : item.role} ·{' '} + {new Date(item.startedAt).toLocaleString(locale)} + {item.status === 'live' && ` · ${t('live')}`} +

    +

    + {item.title ?? t('untitled')} +

    + {item.summary && ( +

    {item.summary}

    + )} + +
  • + ))} +
+
+
+ ); +} diff --git a/apps/web/src/app/copilot/page.tsx b/apps/web/src/app/copilot/page.tsx new file mode 100644 index 00000000..93119c1a --- /dev/null +++ b/apps/web/src/app/copilot/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; +import { Header } from '@/components/header'; +import { CopilotList } from './copilot-list'; + +export async function generateMetadata(): Promise { + const t = await getTranslations('copilotPages'); + return { title: t('listTitle'), robots: { index: false, follow: false } }; +} + +export const dynamic = 'force-dynamic'; + +export default function CopilotPage() { + return } />; +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index ef70b4a3..1e1d4604 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,5 +1,7 @@ @import 'tailwindcss'; @plugin '@tailwindcss/typography'; +/* The shared AI copilot panel lives in a workspace package. */ +@source '../../../../packages/meeting-copilot/src'; /* Custom theme configuration */ @theme { diff --git a/apps/web/src/app/host/[id]/page.tsx b/apps/web/src/app/host/[id]/page.tsx index 356ba1ab..41e032c1 100644 --- a/apps/web/src/app/host/[id]/page.tsx +++ b/apps/web/src/app/host/[id]/page.tsx @@ -41,6 +41,7 @@ import type { SeatKind, SessionParticipant } from '@pairux/shared-types'; import { Logo } from '@/components/Logo'; import { PublishRoomModal } from '@/components/session/PublishRoomModal'; import { MeetingTranslation } from '@/components/translation/MeetingTranslation'; +import { MeetingCopilot } from '@/components/copilot/MeetingCopilot'; interface SessionData { id: string; @@ -648,6 +649,9 @@ function HostContent({ participants={translationParticipants} /> )} + {currentSession.mode === 'sfu' && ( + + )} {/* Plan cap reached: the upgrade prompt, for the room's owner only (the payer; a handed-off host or a guest never sees prices) */} {currentUserId === currentSession.host_user_id && ( diff --git a/apps/web/src/app/session/[id]/page.tsx b/apps/web/src/app/session/[id]/page.tsx index b3a6d2b4..6901c1d7 100644 --- a/apps/web/src/app/session/[id]/page.tsx +++ b/apps/web/src/app/session/[id]/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, use, useRef, useCallback, useMemo } from 'react'; import { RecordingNotice } from '@/components/session/RecordingNotice'; import { MeetingTranslation } from '@/components/translation/MeetingTranslation'; +import { MeetingCopilot } from '@/components/copilot/MeetingCopilot'; import type { CallAnalysisSettings } from '@pairux/shared-types'; import Link from 'next/link'; import { @@ -198,6 +199,7 @@ function SFUSessionViewer({ sessionId, session }: SessionViewerWrapperProps) { {...hookResult} /> + ); } diff --git a/apps/web/src/app/settings/copilot/copilot-settings.tsx b/apps/web/src/app/settings/copilot/copilot-settings.tsx new file mode 100644 index 00000000..eda55439 --- /dev/null +++ b/apps/web/src/app/settings/copilot/copilot-settings.tsx @@ -0,0 +1,284 @@ +'use client'; + +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import Link from 'next/link'; +import { useLocale, useTranslations } from 'next-intl'; +import { Bot, KeyRound, Loader2, Wallet } from 'lucide-react'; +import { + COPILOT_MODES, + COPILOT_ROLES, + type CopilotMode, + type CopilotRole, +} from '@pairux/meeting-copilot/types'; + +const PROVIDERS = ['anthropic', 'openai', 'elevenlabs'] as const; +type Provider = (typeof PROVIDERS)[number]; + +interface Settings { + mode: CopilotMode; + role: CopilotRole; + keys: Record; + eligible: boolean; + byokAvailable: boolean; + balance: string; +} +interface WalletInfo { + balance: string; + topups: { id: string; priceUsd: number }[]; + usage: { provider: string; kind: string; charge: string; status: string; at: string }[]; +} + +async function read(res: Response): Promise { + const body = (await res.json()) as { data?: T; error?: string }; + if (!res.ok || !body.data) + throw new Error(body.error ?? `Request failed (${String(res.status)})`); + return body.data; +} + +export function CopilotSettings({ header }: { header: ReactNode }) { + const t = useTranslations('copilotPages'); + const c = useTranslations('copilot'); + const locale = useLocale(); + const [settings, setSettings] = useState(null); + const [wallet, setWallet] = useState(null); + const [drafts, setDrafts] = useState>({ + anthropic: '', + openai: '', + elevenlabs: '', + }); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + try { + const res = await fetch('/api/copilot/settings'); + if (res.status === 401) { + window.location.href = '/login?redirect=/settings/copilot'; + return; + } + setSettings(await read(res)); + setWallet(await read(await fetch('/api/copilot/wallet'))); + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)); + } + }, []); + useEffect(() => { + void load(); + if (new URLSearchParams(window.location.search).get('payment') === 'success') + setMessage(t('topupPending')); + }, [load, t]); + + const save = async (patch: Record) => { + setBusy(true); + setError(''); + setMessage(''); + try { + await read( + await fetch('/api/copilot/settings', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(patch), + }) + ); + setMessage(t('saved')); + setDrafts({ anthropic: '', openai: '', elevenlabs: '' }); + await load(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)); + } finally { + setBusy(false); + } + }; + + const topup = async (id: string) => { + setBusy(true); + setError(''); + try { + const data = await read<{ checkout_url?: string; choose_method?: boolean }>( + await fetch('/api/billing/copilot-topup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ topup: id }), + }) + ); + if (data.checkout_url) window.location.href = data.checkout_url; + else setError(t('checkoutUnavailable')); + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)); + } finally { + setBusy(false); + } + }; + + const card = 'mt-6 rounded-xl border border-gray-200 bg-white p-5'; + return ( +
+ {header} +
+ + ← {t('listTitle')} + +

+

+

{c('pricingNote')}

+ {message &&

{message}

} + {error &&

{error}

} + {!settings && !error && } + + {settings && !settings.eligible && ( +
+

{c('proOnly')}

+ + {c('upgrade')} + +
+ )} + + {settings && ( +
+ + +
+ )} + + {settings && ( +
+

+

+

{t('keysIntro')}

+ {!settings.byokAvailable && ( +

{t('keysDisabled')}

+ )} +
+ {PROVIDERS.map((provider) => ( +
+ + {t(`providers.${provider}`)} + + + {settings.keys[provider].set + ? t('keySet', { last4: settings.keys[provider].last4 ?? '' }) + : t('keyNotSet')} + + { + setDrafts((all) => ({ ...all, [provider]: event.target.value })); + }} + placeholder={t('keyPlaceholder')} + className="min-w-0 flex-1 rounded-lg border border-gray-300 px-3 py-1.5 text-sm" + /> + + {settings.keys[provider].set && ( + + )} +
+ ))} +
+
+ )} + + {wallet && ( +
+

+

+

{t('walletIntro')}

+
+ {wallet.topups.map((one) => ( + + ))} +
+ {wallet.usage.length > 0 && ( + + + + + + + + + + {wallet.usage.map((row, index) => ( + + + + + + ))} + +
{t('usageWhen')}{t('usageWhat')}{t('usageCharge')}
+ {new Date(row.at).toLocaleString(locale)} + + {row.provider} · {row.kind} + {row.status === 'refunded' && ` (${t('refunded')})`} + {row.charge}
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/settings/copilot/page.tsx b/apps/web/src/app/settings/copilot/page.tsx new file mode 100644 index 00000000..79a0e93d --- /dev/null +++ b/apps/web/src/app/settings/copilot/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; +import { Header } from '@/components/header'; +import { CopilotSettings } from './copilot-settings'; + +export async function generateMetadata(): Promise { + const t = await getTranslations('copilotPages'); + return { title: t('settingsTitle'), robots: { index: false, follow: false } }; +} + +export const dynamic = 'force-dynamic'; + +export default function CopilotSettingsPage() { + return } />; +} diff --git a/apps/web/src/app/view/[sessionId]/page.tsx b/apps/web/src/app/view/[sessionId]/page.tsx index c313ff92..bcb2ba56 100644 --- a/apps/web/src/app/view/[sessionId]/page.tsx +++ b/apps/web/src/app/view/[sessionId]/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, use, useRef } from 'react'; import { RecordingNotice } from '@/components/session/RecordingNotice'; import { MeetingTranslation } from '@/components/translation/MeetingTranslation'; +import { MeetingCopilot } from '@/components/copilot/MeetingCopilot'; import type { CallAnalysisSettings } from '@pairux/shared-types'; import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; @@ -207,6 +208,7 @@ function SFUGuestViewer({ sessionId, session, participant, participantId }: Gues <> + ); } diff --git a/apps/web/src/components/copilot/MeetingCopilot.tsx b/apps/web/src/components/copilot/MeetingCopilot.tsx new file mode 100644 index 00000000..b5f9f682 --- /dev/null +++ b/apps/web/src/components/copilot/MeetingCopilot.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { useCallback } from 'react'; +import { useTranslations } from 'next-intl'; +import { CopilotPanel, useMeetingCopilot, type CopilotT } from '@pairux/meeting-copilot'; + +/** The AI copilot for a meeting page (same origin, cookie auth). */ +export function MeetingCopilot({ sessionId, selfId }: { sessionId: string; selfId: string }) { + const copilot = useMeetingCopilot({ sessionId, selfId, env: {} }); + const intl = useTranslations('copilot'); + const t = useCallback((key, values) => intl(key, values), [intl]); + return ( +
+ +
+ ); +} diff --git a/apps/web/src/lib/billing/plan-checkout.ts b/apps/web/src/lib/billing/plan-checkout.ts index 64fd61b7..0d50cb54 100644 --- a/apps/web/src/lib/billing/plan-checkout.ts +++ b/apps/web/src/lib/billing/plan-checkout.ts @@ -61,25 +61,36 @@ export async function checkoutMethods(cfg: CoinPayConfig | null = coinpayConfig( * resolve user + plan by payment id even if CoinPay drops the metadata. */ export async function startPlanCheckout(input: { - plan: PlanDef; + /** A plan, or any other product sold through plan_payments (copilot credit). */ + plan: Pick & { id: string }; userId: string; email: string | null; method: CheckoutMethod | null; + /** Defaults to a plan term: "