Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 18 additions & 17 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -359,9 +359,9 @@
"custom": "Custom Fields",
"deals": "Deals",
"tags": "Tags",
"noTags": "No tags",
"noDeals": "No deals",
"addNotePlaceholder": "Add a note..."
"noTags": "No tags",
"noDeals": "No deals",
"addNotePlaceholder": "Add a note..."
},
"actions": {
"edit": "Edit",
Expand Down Expand Up @@ -932,6 +932,7 @@
"no": "No"
},
"addStep": "Add step",
"delete": "Delete",
"config": {
"messageText": "Message text",
"placeholderMessageText": "Hi! Thanks for reaching out…",
Expand Down Expand Up @@ -1331,13 +1332,13 @@
"deleteLocallyAria": "Delete template locally",
"deleteMetaLocallyTitle": "Delete from Meta and locally",
"deleteLocallyTitle": "Delete locally",

"dialogEditTitle": "Edit Message Template",
"dialogNewTitle": "New Message Template",
"dialogEditDesc": "Save your changes to re-submit to Meta. Status will flip back to PENDING during review.",
"dialogNewDesc": "Build a template and submit it to Meta for approval. Once approved, you can use it in broadcasts and the inbox.",
"authWarning": "AUTHENTICATION templates have a fixed body + OTP button shape that needs a different builder. Create them in Meta WhatsApp Manager for now and use <bold>Sync from Meta</bold> to bring them in.",

"templateName": "Template Name",
"namePlaceholder": "e.g. order_confirmation",
"nameFixed": "Name is fixed once a template exists on Meta — create a new template to change it.",
Expand All @@ -1346,7 +1347,7 @@
"language": "Language",
"langFixed": "Language is fixed once a template exists on Meta.",
"langHint": "Must match the exact code on Meta — <code>en_US</code> and <code>en</code> are distinct.",

"header": "Header",
"headerNone": "None",
"headerText": "Text",
Expand All @@ -1356,25 +1357,25 @@
"headerTextPlaceholder": "Header text (max 60 chars, optional {{1}})",
"headerSampleAria": "Sample value for header variable",
"headerSamplePlaceholder": "Sample value for {{1}} (required for Meta review)",

"uploadImage": "Upload image",
"uploadHint": "JPEG or PNG, ≤5 MB",
"mediaUrlPlaceholder": "https://… (or paste a public {format} link)",
"imageHint": "Upload a JPEG/PNG (≤5 MB, ≥800×418 px recommended) or paste a public HTTPS link — we upload it to Meta for review automatically.",
"mediaHint": "Must be a publicly accessible HTTPS link. Meta fetches it once during review, so it needs to stay live for ~24 hrs.",
"videoHint": " Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.",
"documentHint": " Recommended: PDF, ≤100 MB.",

"bodyText": "Body Text",
"bodyPlaceholder": "Hello {{1}}, your order {{2}} is confirmed.",
"bodyHint": "Use {{1}}, {{2}} for variables (must be contiguous starting at {{1}}).",
"sampleValues": "Sample values (Meta uses these to review your template)",
"sampleAria": "Sample value for body variable {var}",
"samplePlaceholder": "Sample for {var}",

"footer": "Footer (optional)",
"footerPlaceholder": "Optional footer text (max 60 chars)",

"buttons": "Buttons (optional)",
"addButton": "Add Button",
"buttonsLimit": "Up to {max} buttons. QUICK_REPLY buttons must come before URL / phone / copy-code buttons.",
Expand All @@ -1387,19 +1388,19 @@
"urlSamplePlaceholder": "Example value for {{1}} (required when URL has a variable)",
"phonePlaceholder": "+15551234567",
"codePlaceholder": "Example code (e.g. SUMMER20)",

"cancel": "Cancel",
"saving": "Saving…",
"submitting": "Submitting…",
"saveResubmit": "Save & Resubmit",
"submitApproval": "Submit for Approval",

"deleteDialogTitle": "Delete template?",
"deleteMetaDesc": "\"{name}\" will be deleted from Meta and from wacrm. Active broadcasts using this template will start failing on their next send. This can't be undone.",
"deleteLocalDesc": "\"{name}\" will be deleted from wacrm. It was never submitted to Meta, so no remote cleanup is needed.",
"deleting": "Deleting…",
"delete": "Delete",

"toastLoadFailed": "Failed to load templates",
"toastSubmitFailed": "Failed to submit",
"toastSubmitEditSuccess": "Edit submitted — Meta typically reviews within 24 hours.",
Expand Down Expand Up @@ -1539,7 +1540,7 @@
"profileSaved": "Profile saved",
"emailChangeFailed": "Email change failed: {message}",
"profileSavedEmailCheck": "Profile saved — check your email to confirm the address change",

"passwordTitle": "Password",
"passwordDesc": "Use at least {min} characters. You will stay signed in on this device after changing it.",
"currentPassword": "Current password",
Expand All @@ -1553,7 +1554,7 @@
"currentPasswordIncorrect": "Current password is incorrect",
"passwordUpdateFailed": "Password update failed: {message}",
"passwordUpdated": "Password updated",

"sessionsTitle": "Active sessions",
"sessionsDesc": "Sign out of every device where you're logged in — including this one. Useful if you lost a laptop or shared your password.",
"signOutAll": "Sign out of all devices",
Expand Down Expand Up @@ -1600,7 +1601,7 @@
"nameLabel": "Name",
"namePlaceholder": "e.g. Zapier automation",
"scopesLabel": "Scopes",
"scopesHint": "A key with no scopes can still call <code className=\"text-[11px]\">GET /api/v1/me</code> to verify it works.",
"scopesHint": "A key with no scopes can still call <code>GET /api/v1/me</code> to verify it works.",
"cancel": "Cancel",
"creating": "Creating…",
"createKey": "Create key",
Expand Down Expand Up @@ -1629,7 +1630,7 @@
},
"aiConfig": {
"title": "Agent setup",
"description": "Bring your own OpenAI or Anthropic key. wacrm calls the provider directly with your key — no per-seat AI fees, and your data stays yours. This powers AI-drafted replies in the inbox, the auto-reply bot, and the Playground.",
"description": "Bring your own OpenAI, Anthropic, or Groq key. wacrm calls the provider directly with your key — no per-seat AI fees, and your data stays yours. This powers AI-drafted replies in the inbox, the auto-reply bot, and the Playground.",
"adminOnlyConfig": "Only admins and owners can change the AI configuration.",
"loadFailed": "Failed to load AI configuration",
"providerAndKey": "Provider & key",
Expand Down
5 changes: 3 additions & 2 deletions messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,7 @@
"no": "아니요"
},
"addStep": "단계 추가",
"delete": "삭제",
"config": {
"messageText": "메시지 텍스트",
"placeholderMessageText": "안녕하세요! 문의해주셔서 감사합니다…",
Expand Down Expand Up @@ -1598,7 +1599,7 @@
"nameLabel": "이름",
"namePlaceholder": "예: Zapier 자동화",
"scopesLabel": "권한 범위",
"scopesHint": "권한 범위가 없는 키도 <code className=\"text-[11px]\">GET /api/v1/me</code>를 호출해 동작을 확인할 수 있습니다.",
"scopesHint": "권한 범위가 없는 키도 <code>GET /api/v1/me</code>를 호출해 동작을 확인할 수 있습니다.",
"cancel": "취소",
"creating": "만드는 중…",
"createKey": "키 생성",
Expand Down Expand Up @@ -1627,7 +1628,7 @@
},
"aiConfig": {
"title": "에이전트 설정",
"description": "직접 발급받은 OpenAI 또는 Anthropic 키를 사용하세요. wacrm은 회원님의 키로 공급자에 직접 요청하므로 좌석당 AI 비용이 없고 데이터도 온전히 회원님의 것입니다. 인박스의 AI 답장 초안, 자동 응답 봇, 플레이그라운드에 사용됩니다.",
"description": "직접 발급받은 OpenAI, Anthropic, 또는 Groq 키를 사용하세요. wacrm은 회원님의 키로 공급자에 직접 요청하므로 좌석당 AI 비용이 없고 데이터도 온전히 회원님의 것입니다. 인박스의 AI 답장 초안, 자동 응답 봇, 플레이그라운드에 사용됩니다.",
"adminOnlyConfig": "관리자와 소유자만 AI 설정을 변경할 수 있습니다.",
"loadFailed": "AI 설정을 불러오지 못했습니다",
"providerAndKey": "공급자 & 키",
Expand Down
11 changes: 0 additions & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/app/api/ai/config/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ export async function POST(request: Request) {
if (!body || typeof body !== 'object') return bad('Invalid request body')

const provider = body.provider as AiProvider
if (provider !== 'openai' && provider !== 'anthropic') {
return bad('provider must be "openai" or "anthropic"')
if (provider !== 'openai' && provider !== 'anthropic' && provider !== 'groq') {
return bad('provider must be "openai", "anthropic", or "groq"')
}
const model = typeof body.model === 'string' ? body.model.trim() : ''
if (!model) return bad('model is required')
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/ai/test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ export async function POST(request: Request) {
}

const provider = body.provider as AiProvider
if (provider !== 'openai' && provider !== 'anthropic') {
if (provider !== 'openai' && provider !== 'anthropic' && provider !== 'groq') {
return NextResponse.json(
{ error: 'provider must be "openai" or "anthropic"' },
{ error: 'provider must be "openai", "anthropic", or "groq"' },
{ status: 400 },
)
}
Expand Down
3 changes: 3 additions & 0 deletions src/components/settings/ai-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ const HANDOFF_QUEUE = '__queue__';
const PROVIDER_LABEL: Record<AiProvider, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic (Claude)',
groq: 'Groq',
};

const KEY_PLACEHOLDER: Record<AiProvider, string> = {
openai: 'sk-...',
anthropic: 'sk-ant-...',
groq: 'gsk_...',
};

export function AiConfig() {
Expand Down Expand Up @@ -281,6 +283,7 @@ export function AiConfig() {
<SelectItem value="anthropic">
{PROVIDER_LABEL.anthropic}
</SelectItem>
<SelectItem value="groq">{PROVIDER_LABEL.groq}</SelectItem>
</SelectContent>
</Select>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/settings/settings-overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function SettingsOverview({
useAuth();
const { mode, theme } = useTheme();
const t = useTranslations('Settings.overview');
const tRoles = useTranslations('roles');
const tRoles = useTranslations('Settings.roles');
const tSections = useTranslations('Settings.sections');

const [counts, setCounts] = useState<OverviewCounts | null>(null);
Expand Down
4 changes: 2 additions & 2 deletions src/components/settings/template-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -865,7 +865,7 @@ export function TemplateManager() {
<div className="space-y-2">
<Label className="text-muted-foreground">{t('bodyText')}</Label>
<Textarea
placeholder={t('bodyPlaceholder')}
placeholder={t.raw('bodyPlaceholder')}
value={form.body_text}
onChange={(e) =>
setForm({ ...form, body_text: e.target.value })
Expand All @@ -875,7 +875,7 @@ export function TemplateManager() {
className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none"
/>
<p className="text-[11px] text-muted-foreground">
{t('bodyHint')}
{t.raw('bodyHint')}
</p>

{bodyVarCount > 0 && (
Expand Down
10 changes: 5 additions & 5 deletions src/components/settings/whatsapp-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -794,9 +794,9 @@ export function WhatsAppConfig() {
<AccordionContent className="text-muted-foreground">
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>{t('step3_1')}</li>
<li dangerouslySetInnerHTML={{ __html: t('step3_2') }} />
<li dangerouslySetInnerHTML={{ __html: t('step3_3') }} />
<li dangerouslySetInnerHTML={{ __html: t('step3_4') }} />
<li dangerouslySetInnerHTML={{ __html: t.raw('step3_2') }} />
<li dangerouslySetInnerHTML={{ __html: t.raw('step3_3') }} />
<li dangerouslySetInnerHTML={{ __html: t.raw('step3_4') }} />
</ol>
</AccordionContent>
</AccordionItem>
Expand All @@ -812,8 +812,8 @@ export function WhatsAppConfig() {
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>{t('step4_1')}</li>
<li>{t('step4_2')}</li>
<li dangerouslySetInnerHTML={{ __html: t('step4_3') }} />
<li dangerouslySetInnerHTML={{ __html: t('step4_4') }} />
<li dangerouslySetInnerHTML={{ __html: t.raw('step4_3') }} />
<li dangerouslySetInnerHTML={{ __html: t.raw('step4_4') }} />
<li>{t('step4_5')}</li>
</ol>
</AccordionContent>
Expand Down
1 change: 1 addition & 0 deletions src/lib/ai/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { AiProvider } from './types'
export const AI_PROVIDER_DEFAULT_MODEL: Record<AiProvider, string> = {
openai: 'gpt-5.4-mini',
anthropic: 'claude-haiku-4-5-20251001',
groq: 'llama-3.1-8b-instant',
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/lib/ai/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { HANDOFF_SENTINEL, aiRequestTimeoutMs } from './defaults'
import { generateOpenAi } from './providers/openai'
import { generateAnthropic } from './providers/anthropic'
import { generateGroq } from './providers/groq'

export interface GenerateArgs {
config: AiConfig
Expand Down Expand Up @@ -41,6 +42,9 @@ export async function generateReply(args: GenerateArgs): Promise<GenerateResult>
case 'anthropic':
result = await generateAnthropic(providerArgs)
break
case 'groq':
result = await generateGroq(providerArgs)
break
default:
throw new AiError(`Unsupported AI provider: ${config.provider}`, {
code: 'unsupported_provider',
Expand Down
69 changes: 69 additions & 0 deletions src/lib/ai/providers/groq.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { AiError, type ProviderResult } from '../types'
import { MAX_OUTPUT_TOKENS } from '../defaults'
import {
mergeConsecutive,
normalizeUsage,
providerHttpError,
toNetworkError,
type ProviderArgs,
} from './shared'

const GROQ_URL = 'https://api.groq.com/openai/v1/chat/completions'

interface GroqResponse {
choices?: { message?: { content?: string } }[]
usage?: {
prompt_tokens?: number
completion_tokens?: number
total_tokens?: number
}
}

/**
* Call Groq's Chat Completions endpoint with the caller's own key.
* Returns the raw assistant text + token usage (handoff parsing happens
* in `generateReply`).
*/
export async function generateGroq(args: ProviderArgs): Promise<ProviderResult> {
const { apiKey, model, systemPrompt, messages, timeoutMs } = args

let res: Response
try {
res = await fetch(GROQ_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
...mergeConsecutive(messages),
],
max_tokens: MAX_OUTPUT_TOKENS,
}),
signal: AbortSignal.timeout(timeoutMs),
})
} catch (err) {
throw toNetworkError(err)
}

if (!res.ok) {
throw await providerHttpError('Groq', res)
}

const data = (await res.json().catch(() => null)) as GroqResponse | null
const text = data?.choices?.[0]?.message?.content
if (!text || typeof text !== 'string' || !text.trim()) {
throw new AiError('Groq returned an empty response.', {
code: 'empty_response',
})
}
const usage = normalizeUsage({
prompt: data?.usage?.prompt_tokens,
completion: data?.usage?.completion_tokens,
total: data?.usage?.total_tokens,
})
return { text, usage }
}
2 changes: 1 addition & 1 deletion src/lib/ai/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// whether the account is on OpenAI or Anthropic.
// ============================================================

export type AiProvider = 'openai' | 'anthropic'
export type AiProvider = 'openai' | 'anthropic' | 'groq'

/**
* Account AI setup, decrypted and ready to use. Produced by
Expand Down
Loading