Skip to content
Closed
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
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ SUPABASE_EDGE_ANON_JWT=your-legacy-anon-jwt-here
# Site URL (for generating links in emails)
SITE_URL=http://localhost:3000

# OpenAI API Key (for AI-powered features)
# c0mpute (Infernet account key): text AI in the edge functions runs here first;
# without a key, the free tier answers. Set it in the functions container env too.
C0MPUTE_API_KEY=

# OpenAI API Key (banner images; paid fallback for text)
OPENAI_API_KEY=sk-proj-your-openai-api-key-here

# CoinPay (paid listing tiers: hosted checkout sessions, any coin or PayPal).
Expand Down
55 changes: 55 additions & 0 deletions supabase/functions/_shared/c0mpute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// c0mpute (https://c0mpute.com): the peer network that goes first in every AI
// provider chain in these functions, through @profullstack/ai-providers. The
// existing vendor calls (OpenAI, Gemini) stay behind it as the paid tier.
//
// C0MPUTE_API_KEY is an Infernet account key; without one the network's small
// free tier answers. C0MPUTE_DISABLED=1 skips it.
import { createAiProviders, extractJson } from 'npm:@profullstack/ai-providers@0.2.0'

/**
* The self-hosted edge runtime stops a worker after about 60 s of wall clock,
* so c0mpute gets most of that and leaves room for one vendor attempt. Peer
* nodes write about two tokens a second, so long outputs may not finish here.
*/
export const C0MPUTE_EDGE_TIMEOUT_MS = Number(Deno.env.get('C0MPUTE_TIMEOUT_MS')) || 40_000

export interface C0mputeAsk {
system?: string
user: string
maxTokens?: number
temperature?: number
timeoutMs?: number
feature?: string
fetch?: typeof fetch
}

/** True unless C0MPUTE_DISABLED is set, so key gates can count it. */
export function c0mputeEnabled(): boolean {
return !/^(1|true|yes|on)$/i.test(Deno.env.get('C0MPUTE_DISABLED') ?? '')
}

/** Ask c0mpute for a JSON answer; returns the raw text. Throws on any failure. */
export async function askC0mpute(req: C0mputeAsk): Promise<string> {
const [provider] = createAiProviders({
env: Deno.env.toObject(),
paidFallback: false,
...(req.fetch ? { c0mpute: { fetch: req.fetch } } : {}),
}).primary
if (!provider) throw new Error('c0mpute is disabled')
const { text } = await provider.generate({
system: req.system,
user: req.user,
maxTokens: req.maxTokens ?? 1024,
temperature: req.temperature,
timeoutMs: req.timeoutMs ?? C0MPUTE_EDGE_TIMEOUT_MS,
json: true,
feature: req.feature,
})
if (!text.trim()) throw new Error('c0mpute returned no text')
return text
}

/** The JSON object in a model's answer, fenced or not, re-serialized. */
export function jsonText(raw: string): string {
return JSON.stringify(extractJson(raw))
}
22 changes: 19 additions & 3 deletions supabase/functions/draft-email-broadcast/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Drafts an email broadcast (subject + markdown body) from the admin's description.
// Admin-only; nothing is sent or stored, the page drops the draft into its form.
//
// Providers are tried in order, like fetch-metadata: OpenAI, then Gemini. A provider
// with no key is skipped.
// Providers are tried in order, like fetch-metadata: c0mpute, then OpenAI, then
// Gemini. A provider with no key is skipped (c0mpute needs none).
import { createClient } from 'npm:@supabase/supabase-js@2.76.1'
import { requireAdmin } from '../_shared/adminAuth.ts'
import {
Expand All @@ -13,6 +13,7 @@ import {
type BroadcastAudience,
type BroadcastDraft,
} from '../_shared/broadcastDraft.ts'
import { askC0mpute, c0mputeEnabled, jsonText } from '../_shared/c0mpute.ts'

const corsHeaders = {
'Access-Control-Allow-Origin': '*',
Expand Down Expand Up @@ -84,11 +85,26 @@ Deno.serve(async (req: Request) => {

const user = buildDraftUserPrompt(description, audience, { subject, content })
const providers: Array<[string, () => Promise<string>]> = []
if (c0mputeEnabled()) {
providers.push([
'c0mpute',
async () =>
jsonText(
await askC0mpute({
system: DRAFT_SYSTEM_PROMPT,
user,
temperature: 0.7,
maxTokens: 1200,
feature: 'saasrow.draft-email-broadcast',
}),
),
])
}
const openaiKey = Deno.env.get('OPENAI_API_KEY')
const geminiKey = Deno.env.get('GEMINI_API_KEY')
if (openaiKey) providers.push(['openai', () => askOpenAI(user, openaiKey)])
if (geminiKey) providers.push(['gemini', () => askGemini(user, geminiKey)])
if (providers.length === 0) return json({ error: 'No AI provider configured (OPENAI_API_KEY or GEMINI_API_KEY)' }, 500)
if (providers.length === 0) return json({ error: 'No AI provider configured (c0mpute disabled, no OPENAI_API_KEY or GEMINI_API_KEY)' }, 500)

let lastError = ''
for (const [name, ask] of providers) {
Expand Down
16 changes: 13 additions & 3 deletions supabase/functions/fetch-metadata/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createClient } from 'npm:@supabase/supabase-js@2.76.1'
import { OpenAI } from 'npm:openai@4.47.1'
import { askC0mpute, c0mputeEnabled, jsonText } from '../_shared/c0mpute.ts'

const corsHeaders = {
'Access-Control-Allow-Origin': '*',
Expand Down Expand Up @@ -213,20 +214,29 @@ async function askGemini(prompt: string, apiKey: string): Promise<string> {

/**
* Title, description, category and tags from a model. Providers are tried
* in order and the first one that answers wins: OpenAI when it has credits,
* otherwise Gemini. A provider with no key is skipped; if all fail we fall
* in order and the first one that answers wins: c0mpute (peer network, no
* vendor key), then OpenAI when it has credits, otherwise Gemini. A provider with no key is skipped; if all fail we fall
* back to the page's own title and meta description rather than blocking
* the submission.
*/
async function generateWithAI(url: string, metadata: MetaData): Promise<AiListing> {
console.log('[AI] Starting AI generation for URL:', url)
const prompt = buildPrompt(url, metadata)
const providers: Array<[string, () => Promise<string>]> = []
// Shorter than the other functions: a listing submission waits on this,
// and the page's own metadata is an acceptable answer.
if (c0mputeEnabled()) {
providers.push([
'c0mpute',
async () =>
jsonText(await askC0mpute({ user: prompt, temperature: 0.7, maxTokens: 600, timeoutMs: 30_000, feature: 'saasrow.fetch-metadata' })),
])
}
const openaiKey = Deno.env.get('OPENAI_API_KEY')
const geminiKey = Deno.env.get('GEMINI_API_KEY')
if (openaiKey) providers.push(['openai', () => askOpenAI(prompt, openaiKey)])
if (geminiKey) providers.push(['gemini', () => askGemini(prompt, geminiKey)])
if (providers.length === 0) console.warn('[AI] No OPENAI_API_KEY or GEMINI_API_KEY configured, falling back to metadata only')
if (providers.length === 0) console.warn('[AI] c0mpute disabled and no OPENAI_API_KEY or GEMINI_API_KEY configured, falling back to metadata only')

for (const [name, ask] of providers) {
try {
Expand Down
91 changes: 55 additions & 36 deletions supabase/functions/generate-news-post/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "npm:@supabase/supabase-js@2.76.1";
import { askC0mpute, c0mputeEnabled, jsonText } from "../_shared/c0mpute.ts";

const corsHeaders = {
"Access-Control-Allow-Origin": "*",
Expand Down Expand Up @@ -52,9 +53,9 @@ Deno.serve(async (req: Request) => {
const openAIKey = Deno.env.get('OPENAI_API_KEY');
console.log('OpenAI key present:', !!openAIKey);

if (!openAIKey) {
if (!openAIKey && !c0mputeEnabled()) {
return new Response(
JSON.stringify({ error: 'OpenAI API key not configured' }),
JSON.stringify({ error: 'No AI provider configured (c0mpute disabled and no OPENAI_API_KEY)' }),
{
status: 500,
headers: {
Expand Down Expand Up @@ -109,35 +110,57 @@ Return a JSON object with:
- excerpt: A hook that makes people want to read more (120-160 characters)
- content: Full HTML with <h2>, <p>, <ul>, <li> tags. Write like a human, not a content template.`;

console.log('Calling OpenAI API...');
const openAIResponse = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${openAIKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: 'You are an experienced tech blogger with a casual, authentic writing voice. Write like a real person sharing genuine insights, not like an AI following a template. Your writing should have personality and feel conversational. Always respond with valid JSON only.'
},
{
role: 'user',
content: prompt
}
],
temperature: 0.9,
response_format: { type: 'json_object' }
}),
});

console.log('OpenAI response status:', openAIResponse.status);

if (!openAIResponse.ok) {
const errorText = await openAIResponse.text();
console.error('OpenAI API error:', openAIResponse.status, errorText);
const SYSTEM = 'You are an experienced tech blogger with a casual, authentic writing voice. Write like a real person sharing genuine insights, not like an AI following a template. Your writing should have personality and feel conversational. Always respond with valid JSON only.';

// c0mpute first, then OpenAI as the paid fallback.
let generatedContent: { title?: string; excerpt?: string; content?: string } | null = null;
if (c0mputeEnabled()) {
try {
console.log('Calling c0mpute...');
const parsed = JSON.parse(jsonText(await askC0mpute({
system: SYSTEM,
user: prompt,
temperature: 0.9,
maxTokens: 1500,
feature: 'saasrow.generate-news-post',
})));
if (typeof parsed?.title === 'string' && typeof parsed?.content === 'string') generatedContent = parsed;
else console.error('c0mpute answer is missing title or content');
} catch (err) {
console.error('c0mpute failed:', err instanceof Error ? err.message : String(err));
}
}

if (!generatedContent && openAIKey) {
console.log('Calling OpenAI API...');
const openAIResponse = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${openAIKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: SYSTEM },
{ role: 'user', content: prompt },
],
temperature: 0.9,
response_format: { type: 'json_object' }
}),
});

console.log('OpenAI response status:', openAIResponse.status);
if (openAIResponse.ok) {
const openAIData = await openAIResponse.json();
generatedContent = JSON.parse(openAIData.choices[0].message.content);
} else {
const errorText = await openAIResponse.text();
console.error('OpenAI API error:', openAIResponse.status, errorText);
}
}

if (!generatedContent?.title) {
return new Response(
JSON.stringify({ error: 'Failed to generate content' }),
{
Expand All @@ -150,10 +173,6 @@ Return a JSON object with:
);
}

console.log('Parsing OpenAI response...');
const openAIData = await openAIResponse.json();
console.log('OpenAI data received, parsing content...');
const generatedContent = JSON.parse(openAIData.choices[0].message.content);
console.log('Generated content:', generatedContent.title);

const slug = generatedContent.title
Expand Down Expand Up @@ -203,7 +222,7 @@ Return a JSON object with:
}
);
} catch (error) {
console.error('Unexpected error:', error, error.message, error.stack);
console.error('Unexpected error:', error, error instanceof Error ? error.stack : '');
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{
Expand Down
Loading