diff --git a/src/components/Bars.tsx b/src/components/Bars.tsx index d546ded..6fed084 100644 --- a/src/components/Bars.tsx +++ b/src/components/Bars.tsx @@ -1,13 +1,6 @@ import { Typewriter } from "./Typewriter"; import { useBars } from "~/context/bars"; -import { - onMount, - createEffect, - createSignal, - Show, - For, - onCleanup -} from "solid-js"; +import { onMount, createSignal, Show, For, onCleanup } from "solid-js"; import { api } from "~/lib/api"; import { insertSoftHyphens } from "~/lib/client-utils"; import GitHub from "./icons/GitHub"; diff --git a/src/components/blog/TextEditor.tsx b/src/components/blog/TextEditor.tsx index 6da9f10..0f60bbb 100644 --- a/src/components/blog/TextEditor.tsx +++ b/src/components/blog/TextEditor.tsx @@ -644,6 +644,15 @@ export default function TextEditor(props: TextEditorProps) { let isInitialLoad = true; // Flag to prevent capturing history on initial load let hasAttemptedHistoryLoad = false; // Flag to prevent repeated load attempts + // LLM Infill state + const [currentSuggestion, setCurrentSuggestion] = createSignal(""); + const [isInfillLoading, setIsInfillLoading] = createSignal(false); + const [infillConfig, setInfillConfig] = createSignal<{ + endpoint: string; + token: string; + } | null>(null); + let infillDebounceTimer: ReturnType | null = null; + // Force reactive updates for button states const [editorState, setEditorState] = createSignal(0); @@ -682,6 +691,67 @@ export default function TextEditor(props: TextEditorProps) { return `${baseClasses} ${activeClass} ${hoverClass}`.trim(); }; + // Fetch infill config on mount (admin-only, desktop-only) + createEffect(async () => { + try { + const config = await api.infill.getConfig.query(); + if (config.endpoint && config.token) { + setInfillConfig({ endpoint: config.endpoint, token: config.token }); + console.log("✅ Infill enabled for admin"); + } + } catch (error) { + console.error("Failed to fetch infill config:", error); + } + }); + + // Request LLM infill suggestion + const requestInfill = async (): Promise => { + const config = infillConfig(); + if (!config) return; + + const context = getEditorContext(); + if (!context) return; + + setIsInfillLoading(true); + try { + const response = await fetch(config.endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.token}` + }, + body: JSON.stringify({ + model: "default", + messages: [ + { + role: "user", + content: `Continue writing from this context:\n\nBefore cursor: ${context.prefix}\n\nAfter cursor: ${context.suffix}` + } + ], + max_tokens: 100, + temperature: 0.3, + stop: ["\n\n"] + }) + }); + + if (!response.ok) { + throw new Error(`Infill request failed: ${response.status}`); + } + + const data = await response.json(); + const suggestion = data.choices?.[0]?.message?.content || ""; + + if (suggestion.trim()) { + setCurrentSuggestion(suggestion.trim()); + } + } catch (error) { + console.error("Infill request failed:", error); + setCurrentSuggestion(""); + } finally { + setIsInfillLoading(false); + } + }; + // Capture history snapshot const captureHistory = async (editorInstance: any) => { // Skip if initial load @@ -856,6 +926,34 @@ export default function TextEditor(props: TextEditorProps) { } }; + // Extract editor context for LLM infill (512 chars before/after cursor) + const getEditorContext = (): { + prefix: string; + suffix: string; + cursorPos: number; + } | null => { + const instance = editor(); + if (!instance) return null; + + const { state } = instance; + const cursorPos = state.selection.$anchor.pos; + const text = state.doc.textContent; + + if (text.length === 0) return null; + + const prefix = text.slice(Math.max(0, cursorPos - 512), cursorPos); + const suffix = text.slice( + cursorPos, + Math.min(text.length, cursorPos + 512) + ); + + return { + prefix, + suffix, + cursorPos + }; + }; + const editor = createTiptapEditor(() => ({ element: editorRef, extensions: [ diff --git a/src/env/client.ts b/src/env/client.ts index 142a2cc..aef29a6 100644 --- a/src/env/client.ts +++ b/src/env/client.ts @@ -7,7 +7,8 @@ const clientEnvSchema = z.object({ VITE_GOOGLE_CLIENT_ID: z.string().min(1), VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1), VITE_GITHUB_CLIENT_ID: z.string().min(1), - VITE_WEBSOCKET: z.string().min(1) + VITE_WEBSOCKET: z.string().min(1), + VITE_INFILL_ENDPOINT: z.string().min(1) }); // Type inference diff --git a/src/env/server.ts b/src/env/server.ts index 43564f8..3725858 100644 --- a/src/env/server.ts +++ b/src/env/server.ts @@ -30,7 +30,9 @@ const serverEnvSchema = z.object({ VITE_GOOGLE_CLIENT_ID: z.string().min(1), VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1), VITE_GITHUB_CLIENT_ID: z.string().min(1), - VITE_WEBSOCKET: z.string().min(1) + VITE_WEBSOCKET: z.string().min(1), + VITE_INFILL_ENDPOINT: z.string().min(1), + INFILL_BEARER_TOKEN: z.string().min(1) }); // Type inference diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 070a947..02cdb45 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -7,6 +7,7 @@ import { userRouter } from "./routers/user"; import { blogRouter } from "./routers/blog"; import { gitActivityRouter } from "./routers/git-activity"; import { postHistoryRouter } from "./routers/post-history"; +import { infillRouter } from "./routers/infill"; import { createTRPCRouter } from "./utils"; export const appRouter = createTRPCRouter({ @@ -18,7 +19,8 @@ export const appRouter = createTRPCRouter({ user: userRouter, blog: blogRouter, gitActivity: gitActivityRouter, - postHistory: postHistoryRouter + postHistory: postHistoryRouter, + infill: infillRouter }); export type AppRouter = typeof appRouter; diff --git a/src/server/api/routers/blog.ts b/src/server/api/routers/blog.ts index c1d925b..6bbe142 100644 --- a/src/server/api/routers/blog.ts +++ b/src/server/api/routers/blog.ts @@ -2,55 +2,20 @@ import { createTRPCRouter, publicProcedure } from "../utils"; import { ConnectionFactory } from "~/server/utils"; import { withCacheAndStale } from "~/server/cache"; import { incrementPostReadSchema } from "../schemas/blog"; -import type { Post, PostWithCommentsAndLikes } from "~/db/types"; +import type { PostWithCommentsAndLikes } from "~/db/types"; const BLOG_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours -export const blogRouter = createTRPCRouter({ - getRecentPosts: publicProcedure.query(async () => { - return withCacheAndStale("blog-recent-posts", BLOG_CACHE_TTL, async () => { - // Get database connection +// Shared cache function for all blog posts +const getAllPostsData = async (privilegeLevel: string) => { + return withCacheAndStale( + `blog-posts-${privilegeLevel}`, + BLOG_CACHE_TTL, + async () => { const conn = ConnectionFactory(); - // Query for the 3 most recent published posts - const query = ` - SELECT - p.id, - p.title, - p.subtitle, - p.date, - p.published, - p.category, - p.author_id, - p.banner_photo, - p.reads, - COUNT(DISTINCT pl.user_id) as total_likes, - COUNT(DISTINCT c.id) as total_comments - FROM Post p - LEFT JOIN PostLike pl ON p.id = pl.post_id - LEFT JOIN Comment c ON p.id = c.post_id - WHERE p.published = TRUE - GROUP BY p.id, p.title, p.subtitle, p.date, p.published, p.category, p.author_id, p.reads - ORDER BY p.date DESC - LIMIT 3; - `; - - const results = await conn.execute(query); - return results.rows as unknown as PostWithCommentsAndLikes[]; - }); - }), - - getPosts: publicProcedure.query(async ({ ctx }) => { - const privilegeLevel = ctx.privilegeLevel; - - return withCacheAndStale( - `blog-posts-${privilegeLevel}`, - BLOG_CACHE_TTL, - async () => { - const conn = ConnectionFactory(); - - // Fetch all posts with aggregated data - let postsQuery = ` + // Fetch all posts with aggregated data + let postsQuery = ` SELECT p.id, p.title, @@ -70,17 +35,17 @@ export const blogRouter = createTRPCRouter({ LEFT JOIN Comment c ON p.id = c.post_id `; - if (privilegeLevel !== "admin") { - postsQuery += ` WHERE p.published = TRUE`; - } + if (privilegeLevel !== "admin") { + postsQuery += ` WHERE p.published = TRUE`; + } - postsQuery += ` GROUP BY p.id, p.title, p.subtitle, p.body, p.banner_photo, p.date, p.published, p.category, p.author_id, p.reads, p.attachments`; - postsQuery += ` ORDER BY p.date ASC;`; + postsQuery += ` GROUP BY p.id, p.title, p.subtitle, p.body, p.banner_photo, p.date, p.published, p.category, p.author_id, p.reads, p.attachments`; + postsQuery += ` ORDER BY p.date DESC;`; - const postsResult = await conn.execute(postsQuery); - const posts = postsResult.rows as unknown as PostWithCommentsAndLikes[]; + const postsResult = await conn.execute(postsQuery); + const posts = postsResult.rows as unknown as PostWithCommentsAndLikes[]; - const tagsQuery = ` + const tagsQuery = ` SELECT t.value, t.post_id FROM Tag t JOIN Post p ON t.post_id = p.id @@ -88,21 +53,35 @@ export const blogRouter = createTRPCRouter({ ORDER BY t.value ASC `; - const tagsResult = await conn.execute(tagsQuery); - const tags = tagsResult.rows as unknown as { - value: string; - post_id: number; - }[]; + const tagsResult = await conn.execute(tagsQuery); + const tags = tagsResult.rows as unknown as { + value: string; + post_id: number; + }[]; - const tagMap: Record = {}; - tags.forEach((tag) => { - const key = `${tag.value}`; - tagMap[key] = (tagMap[key] || 0) + 1; - }); + const tagMap: Record = {}; + tags.forEach((tag) => { + const key = `${tag.value}`; + tagMap[key] = (tagMap[key] || 0) + 1; + }); - return { posts, tags, tagMap, privilegeLevel }; - } - ); + return { posts, tags, tagMap, privilegeLevel }; + } + ); +}; + +export const blogRouter = createTRPCRouter({ + getRecentPosts: publicProcedure.query(async ({ ctx }) => { + // Always use public privilege level for recent posts (only show published) + const allPostsData = await getAllPostsData("public"); + + // Return only the 3 most recent posts (already sorted DESC by date) + return allPostsData.posts.slice(0, 3); + }), + + getPosts: publicProcedure.query(async ({ ctx }) => { + const privilegeLevel = ctx.privilegeLevel; + return getAllPostsData(privilegeLevel); }), incrementPostRead: publicProcedure diff --git a/src/server/api/routers/infill.ts b/src/server/api/routers/infill.ts new file mode 100644 index 0000000..1976d18 --- /dev/null +++ b/src/server/api/routers/infill.ts @@ -0,0 +1,33 @@ +import { publicProcedure, createTRPCRouter } from "~/server/api/utils"; +import { env } from "~/env/server"; + +// Helper to detect mobile devices from User-Agent +const isMobileDevice = (userAgent: string | undefined): boolean => { + if (!userAgent) return false; + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + userAgent + ); +}; + +export const infillRouter = createTRPCRouter({ + getConfig: publicProcedure.query(({ ctx }) => { + // Only admins get the config + if (ctx.privilegeLevel !== "admin") { + return { endpoint: null, token: null }; + } + + // Get User-Agent from request headers + const userAgent = ctx.event.nativeEvent.node.req.headers["user-agent"]; + + // Block mobile devices - infill is desktop only + if (isMobileDevice(userAgent)) { + return { endpoint: null, token: null }; + } + + // Return endpoint and token (or null if not configured) + return { + endpoint: env.VITE_INFILL_ENDPOINT || null, + token: env.INFILL_BEARER_TOKEN || null + }; + }) +});