├── components ├── ui │ ├── index.ts │ ├── HyperDrafterIcon.tsx │ ├── Header.tsx │ └── SettingsModal.tsx ├── editor │ ├── Editor.tsx │ └── TipTapEditor.tsx └── sidebar │ ├── HighlightCard.tsx │ └── Sidebar.tsx ├── postcss.config.js ├── next.config.mjs ├── lib ├── ai │ ├── prompts │ │ ├── index.ts │ │ ├── span-analysis.ts │ │ └── span-triage.ts │ ├── types.ts │ └── anthropic.ts └── tiptap │ └── extensions │ ├── AIHighlight.ts │ └── ParagraphWithId.ts ├── hooks ├── useDebounce.ts ├── useEditorState.ts ├── useParagraphDebounce.ts ├── useAnalyzeParagraph.ts └── useEditorHandlers.ts ├── .gitignore ├── app ├── layout.tsx ├── icon.svg ├── api │ └── anthropic │ │ ├── models │ │ └── route.ts │ │ └── messages │ │ └── route.ts ├── page.tsx └── globals.css ├── tsconfig.json ├── package.json ├── public └── favicon.svg ├── LICENSE ├── contexts └── ThemeContext.tsx ├── types └── editor.ts ├── tailwind.config.js ├── CLAUDE.md ├── TODO.md ├── .claude └── agents │ ├── architecture-guardian.md │ └── bug-detective.md ├── services └── editor │ ├── highlightService.ts │ └── analysisService.ts └── README.md /components/ui/index.ts: -------------------------------------------------------------------------------- 1 | export { Header } from './Header' 2 | export { HyperDrafterIcon } from './HyperDrafterIcon' -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | export default nextConfig -------------------------------------------------------------------------------- /lib/ai/prompts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './span-triage' 2 | export * from './span-analysis' 3 | // Future prompts can be exported here: 4 | // export * from './writing-continuation' -------------------------------------------------------------------------------- /hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | export function useDebounce(value: T, delay: number): T { 4 | const [debouncedValue, setDebouncedValue] = useState(value) 5 | 6 | useEffect(() => { 7 | const handler = setTimeout(() => { 8 | setDebouncedValue(value) 9 | }, delay) 10 | 11 | return () => { 12 | clearTimeout(handler) 13 | } 14 | }, [value, delay]) 15 | 16 | return debouncedValue 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Inter } from 'next/font/google' 3 | import './globals.css' 4 | import { ThemeProvider } from '@/contexts/ThemeContext' 5 | 6 | const inter = Inter({ subsets: ['latin'] }) 7 | 8 | export const metadata: Metadata = { 9 | title: 'HyperDrafter - AI-Powered Collaborative Editor', 10 | description: 'Write faster with intelligent AI feedback and suggestions', 11 | } 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: { 16 | children: React.ReactNode 17 | }) { 18 | return ( 19 | 20 | 21 | 22 | {children} 23 | 24 | 25 | 26 | ) 27 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "bundler", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "incremental": true, 19 | "plugins": [ 20 | { 21 | "name": "next" 22 | } 23 | ], 24 | "paths": { 25 | "@/*": [ 26 | "./*" 27 | ] 28 | }, 29 | "target": "ES2017" 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "**/*.ts", 34 | "**/*.tsx", 35 | ".next/types/**/*.ts" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /lib/ai/types.ts: -------------------------------------------------------------------------------- 1 | export interface SpanIdentification { 2 | text: string 3 | startOffset: number 4 | endOffset: number 5 | type: 'expansion' | 'structure' | 'factual' | 'clarity' | 'logic' | 'evidence' | 'basic' 6 | priority: 'high' | 'medium' | 'low' 7 | confidence: number 8 | reasoning: string 9 | } 10 | 11 | export interface SpanTriageResponse { 12 | spans: SpanIdentification[] 13 | } 14 | 15 | export interface DetailedFeedback { 16 | spanId: string 17 | suggestion: string 18 | explanation: string 19 | examples?: string[] 20 | severity: 'low' | 'medium' | 'high' 21 | } 22 | 23 | export type AnthropicModel = string 24 | 25 | export interface ModelInfo { 26 | id: string 27 | display_name: string 28 | created_at: string 29 | type: 'model' 30 | } 31 | 32 | export interface ModelsResponse { 33 | data: ModelInfo[] 34 | first_id?: string 35 | last_id?: string 36 | has_more: boolean 37 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hyperdrafter", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "typecheck": "tsc --noEmit" 11 | }, 12 | "dependencies": { 13 | "@heroicons/react": "^2.2.0", 14 | "@tiptap/core": "^3.0.7", 15 | "@tiptap/extension-document": "^3.0.7", 16 | "@tiptap/extension-dropcursor": "^3.0.7", 17 | "@tiptap/extension-gapcursor": "^3.0.7", 18 | "@tiptap/extension-history": "^3.0.7", 19 | "@tiptap/extension-text": "^3.0.7", 20 | "@tiptap/react": "^3.0.7", 21 | "@tiptap/starter-kit": "^3.0.7", 22 | "@types/node": "^24.1.0", 23 | "@types/react": "^19.1.8", 24 | "@types/react-dom": "^19.1.6", 25 | "next": "^15.4.2", 26 | "prosemirror-state": "^1.4.3", 27 | "react": "^19.1.0", 28 | "react-dom": "^19.1.0", 29 | "typescript": "^5.8.3" 30 | }, 31 | "devDependencies": { 32 | "autoprefixer": "^10.4.21", 33 | "postcss": "^8.5.6", 34 | "tailwindcss": "^3.4.17" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Thomas Ricouard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/api/anthropic/models/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | export async function GET(request: Request) { 4 | const authHeader = request.headers.get('authorization') 5 | 6 | if (!authHeader || !authHeader.startsWith('Bearer ')) { 7 | return NextResponse.json( 8 | { error: 'Missing or invalid authorization header' }, 9 | { status: 401 } 10 | ) 11 | } 12 | 13 | const apiKey = authHeader.substring(7) 14 | 15 | try { 16 | const response = await fetch('https://api.anthropic.com/v1/models', { 17 | method: 'GET', 18 | headers: { 19 | 'x-api-key': apiKey, 20 | 'anthropic-version': '2023-06-01' 21 | } 22 | }) 23 | 24 | if (!response.ok) { 25 | const error = await response.text() 26 | return NextResponse.json( 27 | { error: `Anthropic API error: ${error}` }, 28 | { status: response.status } 29 | ) 30 | } 31 | 32 | const data = await response.json() 33 | return NextResponse.json(data) 34 | } catch (error) { 35 | console.error('Failed to fetch models:', error) 36 | return NextResponse.json( 37 | { error: 'Failed to fetch models' }, 38 | { status: 500 } 39 | ) 40 | } 41 | } -------------------------------------------------------------------------------- /components/ui/HyperDrafterIcon.tsx: -------------------------------------------------------------------------------- 1 | interface HyperDrafterIconProps { 2 | size?: number 3 | className?: string 4 | } 5 | 6 | export function HyperDrafterIcon({ size = 32, className = '' }: HyperDrafterIconProps) { 7 | return ( 8 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ) 37 | } -------------------------------------------------------------------------------- /app/api/anthropic/messages/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | export async function POST(request: Request) { 4 | const authHeader = request.headers.get('authorization') 5 | 6 | if (!authHeader || !authHeader.startsWith('Bearer ')) { 7 | return NextResponse.json( 8 | { error: 'Missing or invalid authorization header' }, 9 | { status: 401 } 10 | ) 11 | } 12 | 13 | const apiKey = authHeader.substring(7) 14 | const body = await request.json() 15 | 16 | try { 17 | const response = await fetch('https://api.anthropic.com/v1/messages', { 18 | method: 'POST', 19 | headers: { 20 | 'Content-Type': 'application/json', 21 | 'x-api-key': apiKey, 22 | 'anthropic-version': '2023-06-01' 23 | }, 24 | body: JSON.stringify(body) 25 | }) 26 | 27 | if (!response.ok) { 28 | const error = await response.text() 29 | return NextResponse.json( 30 | { error: `Anthropic API error: ${error}` }, 31 | { status: response.status } 32 | ) 33 | } 34 | 35 | const data = await response.json() 36 | return NextResponse.json(data) 37 | } catch (error) { 38 | console.error('Failed to send message:', error) 39 | return NextResponse.json( 40 | { error: 'Failed to send message' }, 41 | { status: 500 } 42 | ) 43 | } 44 | } -------------------------------------------------------------------------------- /contexts/ThemeContext.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { createContext, useContext, useEffect, useState } from 'react' 4 | 5 | type Theme = 'dark' | 'light' 6 | 7 | interface ThemeContextType { 8 | theme: Theme 9 | toggleTheme: () => void 10 | } 11 | 12 | const ThemeContext = createContext(undefined) 13 | 14 | export function ThemeProvider({ children }: { children: React.ReactNode }) { 15 | const [theme, setTheme] = useState('dark') 16 | 17 | useEffect(() => { 18 | // Load theme from localStorage or default to dark 19 | const savedTheme = localStorage.getItem('theme') as Theme 20 | if (savedTheme && (savedTheme === 'dark' || savedTheme === 'light')) { 21 | setTheme(savedTheme) 22 | } 23 | }, []) 24 | 25 | useEffect(() => { 26 | // Apply theme to document 27 | document.documentElement.setAttribute('data-theme', theme) 28 | localStorage.setItem('theme', theme) 29 | }, [theme]) 30 | 31 | const toggleTheme = () => { 32 | setTheme(prev => prev === 'dark' ? 'light' : 'dark') 33 | } 34 | 35 | return ( 36 | 37 | {children} 38 | 39 | ) 40 | } 41 | 42 | export function useTheme() { 43 | const context = useContext(ThemeContext) 44 | if (context === undefined) { 45 | throw new Error('useTheme must be used within a ThemeProvider') 46 | } 47 | return context 48 | } -------------------------------------------------------------------------------- /types/editor.ts: -------------------------------------------------------------------------------- 1 | export interface Paragraph { 2 | id: string 3 | content: string 4 | } 5 | 6 | export interface Highlight { 7 | id: string 8 | paragraphId: string 9 | type: string 10 | priority: 'high' | 'medium' | 'low' 11 | text: string 12 | startIndex: number 13 | endIndex: number 14 | note: string 15 | confidence: number 16 | fullText: string 17 | } 18 | 19 | export interface EditorProps { 20 | onHighlightsChange: (highlights: Highlight[]) => void 21 | activeHighlight: string | null 22 | onHighlightClick: (id: string | null) => void 23 | highlights: Highlight[] 24 | onLoadingChange: (loadingParagraphs: Paragraph[]) => void 25 | onActiveParagraphChange: (paragraphId: string | null) => void 26 | onParagraphsChange: (paragraphs: Paragraph[]) => void 27 | activeParagraph: string | null 28 | } 29 | 30 | export interface TipTapEditorProps { 31 | initialContent: Paragraph[] 32 | onParagraphCreate: (id: string) => void 33 | onParagraphDelete: (id: string) => void 34 | onParagraphFocus: (id: string) => void 35 | onContentChange: (paragraphs: Paragraph[]) => void 36 | highlights: Highlight[] 37 | activeHighlight: string | null 38 | onHighlightClick: (id: string | null) => void 39 | activeParagraph: string | null 40 | } 41 | 42 | export interface AIHighlightAttributes { 43 | id: string 44 | type: string 45 | priority: 'high' | 'medium' | 'low' 46 | note: string 47 | confidence: number 48 | isActive: boolean 49 | } -------------------------------------------------------------------------------- /components/ui/Header.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { HyperDrafterIcon } from './HyperDrafterIcon' 4 | import { useTheme } from '@/contexts/ThemeContext' 5 | import { SunIcon, MoonIcon } from '@heroicons/react/24/outline' 6 | 7 | interface HeaderProps { 8 | title?: string 9 | showIcon?: boolean 10 | className?: string 11 | children?: React.ReactNode 12 | } 13 | 14 | export function Header({ 15 | title = 'HyperDrafter', 16 | showIcon = true, 17 | className = '', 18 | children 19 | }: HeaderProps) { 20 | const { theme, toggleTheme } = useTheme() 21 | 22 | return ( 23 |
24 |
25 |
26 | {showIcon && } 27 |

{title}

28 |
29 |
30 | 41 | {children} 42 |
43 |
44 |
45 | ) 46 | } -------------------------------------------------------------------------------- /hooks/useEditorState.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from 'react' 2 | import { Paragraph } from '@/types/editor' 3 | 4 | interface UseEditorStateProps { 5 | onActiveParagraphChange: (paragraphId: string | null) => void 6 | onParagraphsChange: (paragraphs: Paragraph[]) => void 7 | activeParagraph: string | null 8 | } 9 | 10 | export function useEditorState({ 11 | onActiveParagraphChange, 12 | onParagraphsChange, 13 | activeParagraph 14 | }: UseEditorStateProps) { 15 | const [paragraphs, setParagraphs] = useState([ 16 | { id: '1', content: '' } 17 | ]) 18 | const [localActiveParagraph, setLocalActiveParagraph] = useState('1') 19 | const [isSettingsOpen, setIsSettingsOpen] = useState(false) 20 | const [analyzingParagraphs, setAnalyzingParagraphs] = useState>(new Set()) 21 | const prevActiveParagraphRef = useRef(null) 22 | 23 | // Sync external activeParagraph changes with local state 24 | useEffect(() => { 25 | if (activeParagraph && activeParagraph !== prevActiveParagraphRef.current) { 26 | setLocalActiveParagraph(activeParagraph) 27 | prevActiveParagraphRef.current = activeParagraph 28 | } 29 | }, [activeParagraph]) 30 | 31 | // Notify parent of active paragraph changes 32 | useEffect(() => { 33 | onActiveParagraphChange(localActiveParagraph) 34 | }, [localActiveParagraph, onActiveParagraphChange]) 35 | 36 | // Notify parent of paragraph changes 37 | useEffect(() => { 38 | onParagraphsChange(paragraphs) 39 | }, [paragraphs, onParagraphsChange]) 40 | 41 | return { 42 | paragraphs, 43 | setParagraphs, 44 | localActiveParagraph, 45 | setLocalActiveParagraph, 46 | isSettingsOpen, 47 | setIsSettingsOpen, 48 | analyzingParagraphs, 49 | setAnalyzingParagraphs 50 | } 51 | } -------------------------------------------------------------------------------- /lib/ai/prompts/span-analysis.ts: -------------------------------------------------------------------------------- 1 | export const SPAN_ANALYSIS_PROMPT = `You are an expert writing assistant providing detailed, actionable feedback on a specific text span that has been identified as needing improvement. 2 | 3 | Context (full paragraph): 4 | "{{CONTEXT}}" 5 | 6 | Problematic span: 7 | "{{SPAN}}" 8 | 9 | Issue type: {{ISSUE_TYPE}} 10 | Initial reasoning: {{REASONING}} 11 | 12 | Provide comprehensive feedback that includes: 13 | 14 | 1. **Detailed Explanation**: Why is this problematic? What specific issues does it create for the reader? 15 | 16 | 2. **Suggested Revisions**: Provide 2-3 alternative ways to rewrite this span. Each suggestion should: 17 | - Address the identified issue 18 | - Maintain the author's voice and intent 19 | - Fit naturally within the paragraph context 20 | 21 | 3. **Writing Principle**: What general writing principle or rule applies here? This helps the writer learn and avoid similar issues. 22 | 23 | 4. **Severity Assessment**: Rate the importance of fixing this issue: 24 | - Low: Minor improvement, optional 25 | - Medium: Noticeable improvement, recommended 26 | - High: Significant issue, should be fixed 27 | 28 | Format your response as JSON: 29 | { 30 | "explanation": "Detailed explanation of the issue", 31 | "suggestions": [ 32 | { 33 | "text": "First suggested revision", 34 | "rationale": "Why this revision works" 35 | }, 36 | { 37 | "text": "Second suggested revision", 38 | "rationale": "Why this revision works" 39 | } 40 | ], 41 | "principle": "General writing principle that applies", 42 | "severity": "low|medium|high" 43 | }` 44 | 45 | export function buildSpanAnalysisPrompt( 46 | context: string, 47 | span: string, 48 | issueType: string, 49 | reasoning: string 50 | ): string { 51 | return SPAN_ANALYSIS_PROMPT 52 | .replace('{{CONTEXT}}', context) 53 | .replace('{{SPAN}}', span) 54 | .replace('{{ISSUE_TYPE}}', issueType) 55 | .replace('{{REASONING}}', reasoning) 56 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 5 | './components/**/*.{js,ts,jsx,tsx,mdx}', 6 | './app/**/*.{js,ts,jsx,tsx,mdx}', 7 | './contexts/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | darkMode: ['class', '[data-theme="dark"]'], 10 | theme: { 11 | extend: { 12 | colors: { 13 | primary: { 14 | 50: '#faf5ff', 15 | 100: '#f3e8ff', 16 | 200: '#e9d5ff', 17 | 300: '#d8b4fe', 18 | 400: '#c084fc', 19 | 500: '#a855f7', 20 | 600: '#9333ea', 21 | 700: '#7e22ce', 22 | 800: '#6b21a8', 23 | 900: '#581c87', 24 | 950: '#3b0764', 25 | }, 26 | }, 27 | animation: { 28 | 'glow': 'glow 2s ease-in-out infinite alternate', 29 | 'shimmer': 'shimmer 2s linear infinite', 30 | 'slide-up': 'slide-up 0.3s ease-out', 31 | 'pulse-glow': 'pulse-glow 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', 32 | }, 33 | keyframes: { 34 | glow: { 35 | from: { boxShadow: '0 0 10px #a855f7, 0 0 20px #a855f7' }, 36 | to: { boxShadow: '0 0 20px #a855f7, 0 0 30px #a855f7' }, 37 | }, 38 | shimmer: { 39 | '0%': { backgroundPosition: '-100% 0' }, 40 | '100%': { backgroundPosition: '100% 0' }, 41 | }, 42 | 'slide-up': { 43 | from: { transform: 'translateY(10px)', opacity: 0 }, 44 | to: { transform: 'translateY(0)', opacity: 1 }, 45 | }, 46 | 'pulse-glow': { 47 | '0%, 100%': { opacity: 1 }, 48 | '50%': { opacity: 0.5 }, 49 | }, 50 | }, 51 | backgroundColor: { 52 | 'dark': '#000000', 53 | 'dark-secondary': '#0a0a0a', 54 | 'dark-tertiary': '#111111', 55 | }, 56 | borderColor: { 57 | 'dark': '#262626', 58 | 'dark-secondary': '#404040', 59 | }, 60 | backdropBlur: { 61 | xs: '2px', 62 | }, 63 | }, 64 | }, 65 | plugins: [], 66 | } 67 | 68 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Editor } from '@/components/editor/Editor' 4 | import { Sidebar } from '@/components/sidebar/Sidebar' 5 | import { useState } from 'react' 6 | 7 | export default function Home() { 8 | const [highlights, setHighlights] = useState([]) 9 | const [activeHighlight, setActiveHighlight] = useState(null) 10 | const [analyzingParagraphs, setAnalyzingParagraphs] = useState>([]) 11 | const [activeParagraph, setActiveParagraph] = useState(null) 12 | const [paragraphs, setParagraphs] = useState>([]) 13 | 14 | const handleParagraphSelect = (paragraphId: string) => { 15 | setActiveParagraph(paragraphId) 16 | // TipTap editor will handle focus management internally 17 | } 18 | 19 | const handleHighlightClick = (highlightId: string | null) => { 20 | if (!highlightId) { 21 | setActiveHighlight(null) 22 | return 23 | } 24 | 25 | // Find which paragraph this highlight belongs to and open its section in sidebar 26 | const highlight = highlights.find(h => h.id === highlightId) 27 | if (highlight && highlight.paragraphId !== activeParagraph) { 28 | setActiveParagraph(highlight.paragraphId) 29 | } 30 | 31 | // Set the active highlight 32 | setActiveHighlight(highlightId) 33 | } 34 | 35 | return ( 36 |
37 |
38 | 48 |
49 | 58 |
59 | ) 60 | } -------------------------------------------------------------------------------- /hooks/useParagraphDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | export function useParagraphDebounce( 4 | paragraphs: Array<{ id: string; content: string }>, 5 | onParagraphReady: (paragraphId: string) => void, 6 | delay: number = 1000 7 | ) { 8 | const timeoutsRef = useRef>(new Map()) 9 | const previousContentRef = useRef>(new Map()) 10 | 11 | useEffect(() => { 12 | const timeouts = timeoutsRef.current 13 | const previousContent = previousContentRef.current 14 | 15 | paragraphs.forEach(paragraph => { 16 | const { id, content } = paragraph 17 | const prevContent = previousContent.get(id) || '' 18 | 19 | // Only process if content actually changed 20 | if (content !== prevContent) { 21 | // Clear existing timeout for this paragraph 22 | const existingTimeout = timeouts.get(id) 23 | if (existingTimeout) { 24 | clearTimeout(existingTimeout) 25 | } 26 | 27 | // Set new timeout for this paragraph 28 | if (content.trim()) { 29 | const timeout = setTimeout(() => { 30 | onParagraphReady(id) 31 | timeouts.delete(id) 32 | }, delay) 33 | 34 | timeouts.set(id, timeout) 35 | } else { 36 | // If content is empty, remove any pending timeout 37 | timeouts.delete(id) 38 | } 39 | 40 | // Update previous content 41 | previousContent.set(id, content) 42 | } 43 | }) 44 | 45 | // Clean up timeouts for paragraphs that no longer exist 46 | const currentIds = new Set(paragraphs.map(p => p.id)) 47 | for (const [id, timeout] of timeouts.entries()) { 48 | if (!currentIds.has(id)) { 49 | clearTimeout(timeout) 50 | timeouts.delete(id) 51 | previousContent.delete(id) 52 | } 53 | } 54 | 55 | // Cleanup function 56 | return () => { 57 | timeouts.forEach(timeout => clearTimeout(timeout)) 58 | } 59 | }, [paragraphs, onParagraphReady, delay]) 60 | 61 | // Cleanup on unmount 62 | useEffect(() => { 63 | return () => { 64 | const timeouts = timeoutsRef.current 65 | timeouts.forEach(timeout => clearTimeout(timeout)) 66 | timeouts.clear() 67 | previousContentRef.current.clear() 68 | } 69 | }, []) 70 | } -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # HyperDrafter Rules 2 | 3 | HyperDrafter is a collaborative AI-powered editor that provides live feedback as you write. It's designed to help users draft content at insane speed with intelligent AI assistance through highlights, suggestions, and real-time feedback. 4 | 5 | ## Development Rules 6 | 7 | ### TODO.md Management 8 | CRITICAL: You MUST update TODO.md whenever: 9 | - Starting or completing a feature 10 | - Discovering new requirements or edge cases 11 | - Finding bugs that need to be tracked 12 | - Priorities change based on user feedback 13 | - New tasks are identified during development 14 | 15 | The TODO.md file is the single source of truth for project tasks and progress. 16 | 17 | ## Development Commands 18 | 19 | Always run these commands after making changes: 20 | 21 | ```bash 22 | npm run dev # Start development server 23 | npm run build # Build for production 24 | npm run start # Start production server 25 | npm run lint # Run linter 26 | npm run typecheck # Run TypeScript type checking 27 | ``` 28 | 29 | IMPORTANT: Always run `npm run typecheck` after modifications to ensure code quality and fix any errors and run the typecheck again. 30 | 31 | ## Environment Setup 32 | 33 | ### Required Dependencies 34 | - Next.js 15.3.5+ 35 | - React 19.1.0+ 36 | - TypeScript 5.8.3+ 37 | - Tailwind CSS 3.4.17+ 38 | 39 | ### Key Features 40 | 41 | 1. **Paragraph-based Editing**: Each paragraph is a separate component for optimal performance 42 | 2. **Debounced AI Analysis**: Content analyzed after user stops typing for 1 second 43 | 3. **Interactive Highlights**: Click highlights to see detailed feedback in sidebar 44 | 4. **Real-time Feedback**: AI suggestions appear as user writes 45 | 5. **Draft Persistence**: Automatic saving to local storage 46 | 47 | ## Important Development Guidelines 48 | 49 | ### Theme 50 | HyperDrafter follows the Hyper suite cyberpunk design language: 51 | 52 | - **Primary Colors**: Purple shades (#a855f7, #c084fc, #7c3aed) 53 | - **Background**: Dark theme (#000000, #0a0a0a, #111111) 54 | - **Glass Effects**: backdrop-blur with transparent backgrounds 55 | - **Animations**: Glow effects, shimmer, and smooth transitions 56 | 57 | ### Component Patterns 58 | - Glass morphism for cards and overlays 59 | - Neon purple glow on active elements 60 | - Smooth 200-300ms transitions 61 | - Custom purple scrollbars 62 | -------------------------------------------------------------------------------- /hooks/useAnalyzeParagraph.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { Paragraph, Highlight } from '@/types/editor' 3 | import { analysisService } from '@/services/editor/analysisService' 4 | 5 | interface UseAnalyzeParagraphProps { 6 | paragraphs: Paragraph[] 7 | highlights: Highlight[] 8 | onHighlightsChange: (highlights: Highlight[]) => void 9 | setAnalyzingParagraphs: React.Dispatch>> 10 | } 11 | 12 | export function useAnalyzeParagraph({ 13 | paragraphs, 14 | highlights, 15 | onHighlightsChange, 16 | setAnalyzingParagraphs 17 | }: UseAnalyzeParagraphProps) { 18 | 19 | const analyzeChangedParagraph = useCallback(async (paragraphId: string) => { 20 | const paragraph = paragraphs.find(p => p.id === paragraphId) 21 | if (!paragraph || !paragraph.content.trim()) { 22 | // Remove highlights for empty paragraphs 23 | const updatedHighlights = highlights.filter(h => h.paragraphId !== paragraphId) 24 | onHighlightsChange(updatedHighlights) 25 | analysisService.clearAnalysisTracking(paragraphId) 26 | return 27 | } 28 | 29 | // Skip if already analyzed 30 | if (analysisService.hasBeenAnalyzed(paragraphId, paragraph.content)) { 31 | return 32 | } 33 | 34 | // Capture the content we're analyzing to validate later 35 | const contentBeingAnalyzed = paragraph.content 36 | 37 | // Add paragraph to analyzing set 38 | setAnalyzingParagraphs(prev => new Set([...prev, paragraphId])) 39 | 40 | try { 41 | const newHighlights = await analysisService.analyzeParagraph(paragraph, paragraphs) 42 | 43 | // Check if the paragraph still exists and content hasn't changed 44 | const currentParagraph = paragraphs.find(p => p.id === paragraphId) 45 | if (!currentParagraph || currentParagraph.content !== contentBeingAnalyzed) { 46 | return // Don't update highlights if paragraph changed 47 | } 48 | 49 | // Update highlights atomically by getting fresh state 50 | setAnalyzingParagraphs(currentAnalyzing => { 51 | // Only update if this paragraph is still being analyzed (not cancelled) 52 | if (currentAnalyzing.has(paragraphId)) { 53 | // Remove old highlights for this paragraph and add new ones 54 | const otherHighlights = highlights.filter(h => h.paragraphId !== paragraphId) 55 | const updatedHighlights = [...otherHighlights, ...newHighlights] 56 | onHighlightsChange(updatedHighlights) 57 | } 58 | return currentAnalyzing // Don't modify the analyzing set here 59 | }) 60 | 61 | } catch (error) { 62 | if (error instanceof Error && error.name !== 'AbortError') { 63 | console.error('Error analyzing paragraph:', error) 64 | } 65 | } finally { 66 | // Remove paragraph from analyzing set 67 | setAnalyzingParagraphs(prev => { 68 | const newSet = new Set(prev) 69 | newSet.delete(paragraphId) 70 | return newSet 71 | }) 72 | } 73 | }, [paragraphs, highlights, onHighlightsChange, setAnalyzingParagraphs]) 74 | 75 | const clearAnalysisTracking = useCallback((paragraphId: string) => { 76 | analysisService.clearAnalysisTracking(paragraphId) 77 | }, []) 78 | 79 | const cancelAnalysis = useCallback((paragraphId: string) => { 80 | analysisService.cancelAnalysis(paragraphId) 81 | }, []) 82 | 83 | return { 84 | analyzeChangedParagraph, 85 | clearAnalysisTracking, 86 | cancelAnalysis 87 | } 88 | } -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Courier+Prime:ital,wght@0,400;0,700;1,400;1,700&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap'); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | @layer base { 8 | :root { 9 | --primary: 280 100% 50%; 10 | --primary-foreground: 0 0% 100%; 11 | --background: 0 0% 0%; 12 | --foreground: 0 0% 90%; 13 | --card: 0 0% 4%; 14 | --card-foreground: 0 0% 90%; 15 | --border: 0 0% 15%; 16 | --input: 0 0% 15%; 17 | --ring: 280 100% 50%; 18 | --paper: 0 0% 5%; 19 | --paper-foreground: 0 0% 92%; 20 | --paper-border: 0 0% 12%; 21 | } 22 | 23 | [data-theme="light"] { 24 | --primary: 280 100% 50%; 25 | --primary-foreground: 0 0% 100%; 26 | --background: 0 0% 96%; 27 | --foreground: 0 0% 12%; 28 | --card: 0 0% 100%; 29 | --card-foreground: 0 0% 12%; 30 | --border: 0 0% 88%; 31 | --input: 0 0% 88%; 32 | --ring: 280 100% 50%; 33 | --paper: 0 0% 100%; 34 | --paper-foreground: 0 0% 8%; 35 | --paper-border: 0 0% 90%; 36 | } 37 | 38 | * { 39 | border-color: hsl(var(--border)); 40 | } 41 | 42 | body { 43 | background-color: hsl(var(--background)); 44 | color: hsl(var(--foreground)); 45 | font-feature-settings: "rlig" 1, "calt" 1; 46 | transition: background-color 0.3s ease, color 0.3s ease; 47 | } 48 | } 49 | 50 | @layer components { 51 | .editor-paper { 52 | font-family: 'Courier Prime', 'JetBrains Mono', 'Courier New', monospace; 53 | background: hsl(var(--paper)); 54 | color: hsl(var(--paper-foreground)); 55 | border: 1px solid hsl(var(--paper-border)); 56 | box-shadow: 57 | 0 4px 6px -1px rgba(0, 0, 0, 0.1), 58 | 0 2px 4px -1px rgba(0, 0, 0, 0.06), 59 | inset 0 1px 0 0 rgba(255, 255, 255, 0.05); 60 | transition: all 0.3s ease; 61 | } 62 | 63 | [data-theme="light"] .editor-paper { 64 | box-shadow: 65 | 0 4px 6px -1px rgba(0, 0, 0, 0.1), 66 | 0 2px 4px -1px rgba(0, 0, 0, 0.06), 67 | inset 0 1px 0 0 rgba(0, 0, 0, 0.05); 68 | } 69 | } 70 | 71 | @layer utilities { 72 | .glass { 73 | @apply bg-black/40 backdrop-blur-md border border-white/10; 74 | } 75 | 76 | .glow { 77 | @apply shadow-[0_0_20px_rgba(168,85,247,0.5)]; 78 | } 79 | 80 | .text-gradient { 81 | @apply bg-gradient-to-r from-primary-400 to-primary-600 bg-clip-text text-transparent; 82 | } 83 | 84 | .shimmer { 85 | background: linear-gradient( 86 | 90deg, 87 | transparent, 88 | rgba(168, 85, 247, 0.1), 89 | transparent 90 | ); 91 | background-size: 200% 100%; 92 | animation: shimmer 2s infinite; 93 | } 94 | } 95 | 96 | /* ContentEditable placeholder */ 97 | [contenteditable][data-placeholder]:empty::before { 98 | content: attr(data-placeholder); 99 | color: rgb(156 163 175); /* text-gray-400 */ 100 | pointer-events: none; 101 | font-style: italic; 102 | } 103 | 104 | [data-theme="dark"] [contenteditable][data-placeholder]:empty::before { 105 | color: rgb(107 114 128); /* text-gray-500 in dark mode */ 106 | } 107 | 108 | ::-webkit-scrollbar { 109 | width: 8px; 110 | height: 8px; 111 | } 112 | 113 | ::-webkit-scrollbar-track { 114 | background: rgba(0, 0, 0, 0.5); 115 | } 116 | 117 | ::-webkit-scrollbar-thumb { 118 | background: #7c3aed; 119 | border-radius: 4px; 120 | } 121 | 122 | ::-webkit-scrollbar-thumb:hover { 123 | background: #9333ea; 124 | } -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # HyperDrafter TODO 2 | 3 | ## Current Status - TipTap Migration Complete ✅ 4 | - ✅ **TipTap Editor**: Migrated from contentEditable to professional TipTap editor framework 5 | - ✅ **Paragraph-based Architecture**: Custom TipTap extensions maintaining AI analysis workflow 6 | - ✅ **AI Integration**: Real-time analysis with category-based highlighting system using TipTap marks 7 | - ✅ **Modern UI**: Paper-like editor with typewriter font and dark/light themes 8 | - ✅ **Interactive Sidebar**: Compact paragraph indicators with expandable feedback and smooth animations 9 | - ✅ **Document Context**: AI sees full document for better paragraph analysis 10 | - ✅ **Smooth Animations**: Polished transitions for paragraph selection in both editor and sidebar 11 | 12 | ## High Priority - Code Refactoring & Architecture 13 | - ✅ **Type Safety**: Replace all `any` types with proper interfaces (Highlight, Paragraph, etc.) 14 | - ✅ **Split Editor.tsx**: Extract analysis logic, state management, and event handlers to separate hooks 15 | - ✅ **Extract Services**: Move AI analysis and highlight management to dedicated service layer 16 | - ✅ **Performance**: Implement React.memo, useMemo for expensive operations 17 | - [ ] **Error Handling**: Add proper error boundaries and user feedback for failures 18 | 19 | ## High Priority - Polish & Features 20 | - [ ] **Draft Persistence**: Auto-save to localStorage with draft management 21 | - [ ] **Export System**: Support for Markdown, TXT, and HTML export 22 | - [ ] **Keyboard Navigation**: Tab through highlights, arrow keys between paragraphs 23 | - [ ] **TipTap Formatting**: Bold, italic, and other text formatting via TipTap extensions 24 | - [ ] **Enhanced Paragraph Types**: Headings, lists, blockquotes using TipTap nodes 25 | 26 | ## Medium Priority - Enhancements 27 | - [ ] **Suggestion Actions**: Accept/dismiss functionality for AI feedback 28 | - [ ] **Better Error Handling**: Graceful AI failures and network issues 29 | - [ ] **Performance**: React.memo optimization and debounced renders 30 | - [ ] **Accessibility**: ARIA labels and screen reader support 31 | 32 | ## Low Priority - Advanced Features 33 | - [ ] **Document Management**: Multiple documents with tabs 34 | - [ ] **Markdown Support**: Live preview mode for markdown syntax 35 | - [ ] **Voice Input**: Dictation support for hands-free writing 36 | - [ ] **Collaboration**: Real-time multi-user editing 37 | 38 | ## Architectural Considerations 39 | - **Markdown vs Current**: Current paragraph-based structure excellent for AI analysis but limits document-wide selection 40 | - **Selection Spanning**: Would require major rework to support cross-paragraph selection 41 | - **AI Categories**: Successfully implemented category-based colors (expansion=green, structure=blue, factual=red, logic=orange, clarity=purple, evidence=yellow, basic=gray) 42 | 43 | ## Recent Major Achievements 44 | - **TipTap Migration**: Successfully migrated from contentEditable to professional TipTap editor framework 45 | - **Custom Extensions**: Built ParagraphWithId and AIHighlight extensions maintaining paragraph-based AI analysis 46 | - **Smooth Animations**: Added polished transitions for paragraph selection across editor and sidebar 47 | - **Cursor Positioning**: Fixed cursor positioning to preserve click location during paragraph selection 48 | - **Architecture Improvement**: Enhanced code maintainability and extensibility with TipTap's extension system 49 | - **UI Polish**: Maintained paper-like experience with improved visual feedback and interactions 50 | - **Major Refactoring**: Completed comprehensive refactoring of editor components: 51 | - Created proper TypeScript interfaces for Highlight and Paragraph types 52 | - Extracted useAnalyzeParagraph, useEditorState, and useEditorHandlers hooks 53 | - Implemented analysis and highlight services for better separation of concerns 54 | - Added React.memo to TipTapEditor and useMemo for performance optimization 55 | - Reduced Editor.tsx from 285 lines to 117 lines -------------------------------------------------------------------------------- /components/editor/Editor.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useMemo } from 'react' 4 | import { TipTapEditor } from './TipTapEditor' 5 | import { useParagraphDebounce } from '@/hooks/useParagraphDebounce' 6 | import { useAnalyzeParagraph } from '@/hooks/useAnalyzeParagraph' 7 | import { useEditorState } from '@/hooks/useEditorState' 8 | import { useEditorHandlers } from '@/hooks/useEditorHandlers' 9 | import { Header } from '@/components/ui' 10 | import { SettingsModal } from '@/components/ui/SettingsModal' 11 | import { Cog6ToothIcon } from '@heroicons/react/24/outline' 12 | import { EditorProps } from '@/types/editor' 13 | 14 | export function Editor({ 15 | onHighlightsChange, 16 | activeHighlight, 17 | onHighlightClick, 18 | highlights, 19 | onLoadingChange, 20 | onActiveParagraphChange, 21 | onParagraphsChange, 22 | activeParagraph 23 | }: EditorProps) { 24 | // State management 25 | const { 26 | paragraphs, 27 | setParagraphs, 28 | localActiveParagraph, 29 | setLocalActiveParagraph, 30 | isSettingsOpen, 31 | setIsSettingsOpen, 32 | analyzingParagraphs, 33 | setAnalyzingParagraphs 34 | } = useEditorState({ onActiveParagraphChange, onParagraphsChange, activeParagraph }) 35 | 36 | // Analysis logic 37 | const { 38 | analyzeChangedParagraph, 39 | clearAnalysisTracking, 40 | cancelAnalysis 41 | } = useAnalyzeParagraph({ paragraphs, highlights, onHighlightsChange, setAnalyzingParagraphs }) 42 | 43 | // Event handlers 44 | const { 45 | handleContentChange, 46 | handleParagraphCreate, 47 | handleParagraphDelete, 48 | handleParagraphFocus 49 | } = useEditorHandlers({ 50 | paragraphs, 51 | setParagraphs, 52 | localActiveParagraph, 53 | setLocalActiveParagraph, 54 | analyzingParagraphs, 55 | highlights, 56 | activeHighlight, 57 | onHighlightsChange, 58 | onHighlightClick, 59 | analyzeChangedParagraph, 60 | clearAnalysisTracking, 61 | cancelAnalysis 62 | }) 63 | 64 | // Use per-paragraph debouncing for truly parallel analysis 65 | useParagraphDebounce(paragraphs, analyzeChangedParagraph, 1000) 66 | 67 | // Convert analyzing paragraphs Set to array with content for loading callback 68 | const analyzingParagraphsWithContent = useMemo(() => { 69 | return [...analyzingParagraphs].map(paragraphId => { 70 | const paragraph = paragraphs.find(p => p.id === paragraphId) 71 | return { 72 | id: paragraphId, 73 | content: paragraph?.content || '' 74 | } 75 | }) 76 | }, [analyzingParagraphs, paragraphs]) 77 | 78 | useEffect(() => { 79 | onLoadingChange(analyzingParagraphsWithContent) 80 | }, [analyzingParagraphsWithContent, onLoadingChange]) 81 | 82 | return ( 83 |
84 |
85 | 92 |
93 | 94 | {/* Editor Content */} 95 |
96 | 107 |
108 | 109 | setIsSettingsOpen(false)} 112 | /> 113 |
114 | ) 115 | } -------------------------------------------------------------------------------- /.claude/agents/architecture-guardian.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: architecture-guardian 3 | description: Use this agent when implementing new features, refactoring existing code, or making significant architectural changes to ensure the codebase maintains clean architecture and TypeScript compliance. Examples: Context: User is implementing a new feature for the HyperDrafter editor. user: 'I've added a new AI feedback system with real-time analysis. Here's the implementation:' [shows large component with mixed concerns] assistant: 'Let me use the architecture-guardian agent to review this implementation for architectural best practices and proper separation of concerns.' Since the user is implementing a new feature, use the architecture-guardian agent to ensure the code follows proper architectural patterns and doesn't violate separation of concerns. Context: User is refactoring existing components. user: 'I'm breaking down this 500-line component into smaller pieces. Can you review my approach?' assistant: 'I'll use the architecture-guardian agent to analyze your refactoring strategy and ensure it follows React/TypeScript best practices.' Since the user is refactoring components, use the architecture-guardian agent to validate the architectural approach and component decomposition. 4 | color: purple 5 | --- 6 | 7 | You are an expert Next.js/React/TypeScript architect with deep expertise in modern web application design patterns, component architecture, and TypeScript best practices. Your primary responsibility is to maintain architectural integrity and ensure the codebase follows established patterns and principles. 8 | 9 | When reviewing code or architectural decisions, you will: 10 | 11 | **Architectural Analysis:** 12 | - Evaluate component size and complexity (flag components over 200 lines) 13 | - Ensure proper separation of concerns between business logic and view components 14 | - Verify that data fetching, state management, and UI rendering are appropriately separated 15 | - Check for proper abstraction layers and avoid tight coupling 16 | - Validate that custom hooks are used for complex logic extraction 17 | 18 | **Component Structure Review:** 19 | - Identify components that should be split into smaller, focused units 20 | - Ensure each component has a single responsibility 21 | - Verify proper prop interfaces and component composition patterns 22 | - Check for appropriate use of React patterns (hooks, context, etc.) 23 | - Validate that business logic is extracted into services, hooks, or utilities 24 | 25 | **TypeScript Compliance:** 26 | - Ensure strict type safety with no 'any' types unless absolutely necessary 27 | - Verify proper interface definitions and type exports 28 | - Check for consistent naming conventions and type organization 29 | - Validate proper generic usage and type constraints 30 | - Ensure all props, state, and function parameters are properly typed 31 | 32 | **File Organization:** 33 | - Assess file sizes and recommend splits when files exceed reasonable limits 34 | - Verify proper directory structure and file naming conventions 35 | - Check for appropriate barrel exports and module organization 36 | - Ensure related functionality is co-located appropriately 37 | 38 | **Next.js Best Practices:** 39 | - Validate proper use of Next.js features (App Router, Server Components, etc.) 40 | - Check for appropriate data fetching patterns 41 | - Ensure proper error boundaries and loading states 42 | - Verify SEO and performance considerations 43 | 44 | **Quality Assurance Process:** 45 | 1. First, analyze the overall architectural approach and identify any major concerns 46 | 2. Review component structure and identify splitting opportunities 47 | 3. Check TypeScript compliance and type safety 48 | 4. Evaluate file organization and suggest improvements 49 | 5. Provide specific, actionable recommendations with code examples when helpful 50 | 6. Prioritize suggestions by impact (critical architectural issues first) 51 | 52 | Always provide constructive feedback with clear reasoning and suggest specific improvements. When recommending component splits or refactoring, provide concrete examples of how to implement the changes. Focus on maintainability, scalability, and developer experience while adhering to the project's established patterns and the HyperDrafter cyberpunk design system. 53 | -------------------------------------------------------------------------------- /services/editor/highlightService.ts: -------------------------------------------------------------------------------- 1 | import { Highlight, Paragraph } from '@/types/editor' 2 | 3 | export class HighlightService { 4 | private highlightsMap: Map = new Map() 5 | 6 | /** 7 | * Get all highlights 8 | */ 9 | getAllHighlights(): Highlight[] { 10 | const allHighlights: Highlight[] = [] 11 | this.highlightsMap.forEach(highlights => { 12 | allHighlights.push(...highlights) 13 | }) 14 | return allHighlights 15 | } 16 | 17 | /** 18 | * Get highlights for a specific paragraph 19 | */ 20 | getHighlightsForParagraph(paragraphId: string): Highlight[] { 21 | return this.highlightsMap.get(paragraphId) || [] 22 | } 23 | 24 | /** 25 | * Set highlights for a paragraph 26 | */ 27 | setHighlightsForParagraph(paragraphId: string, highlights: Highlight[]): void { 28 | if (highlights.length === 0) { 29 | this.highlightsMap.delete(paragraphId) 30 | } else { 31 | this.highlightsMap.set(paragraphId, highlights) 32 | } 33 | } 34 | 35 | /** 36 | * Remove all highlights for a paragraph 37 | */ 38 | removeHighlightsForParagraph(paragraphId: string): void { 39 | this.highlightsMap.delete(paragraphId) 40 | } 41 | 42 | /** 43 | * Find a highlight by ID 44 | */ 45 | findHighlightById(highlightId: string): Highlight | undefined { 46 | for (const highlights of this.highlightsMap.values()) { 47 | const found = highlights.find(h => h.id === highlightId) 48 | if (found) return found 49 | } 50 | return undefined 51 | } 52 | 53 | /** 54 | * Update highlights for multiple paragraphs 55 | */ 56 | updateHighlights(paragraphHighlights: Map): void { 57 | paragraphHighlights.forEach((highlights, paragraphId) => { 58 | this.setHighlightsForParagraph(paragraphId, highlights) 59 | }) 60 | } 61 | 62 | /** 63 | * Filter highlights by type 64 | */ 65 | getHighlightsByType(type: string): Highlight[] { 66 | const filtered: Highlight[] = [] 67 | this.highlightsMap.forEach(highlights => { 68 | filtered.push(...highlights.filter(h => h.type === type)) 69 | }) 70 | return filtered 71 | } 72 | 73 | /** 74 | * Filter highlights by priority 75 | */ 76 | getHighlightsByPriority(priority: 'high' | 'medium' | 'low'): Highlight[] { 77 | const filtered: Highlight[] = [] 78 | this.highlightsMap.forEach(highlights => { 79 | filtered.push(...highlights.filter(h => h.priority === priority)) 80 | }) 81 | return filtered 82 | } 83 | 84 | /** 85 | * Clear all highlights 86 | */ 87 | clearAllHighlights(): void { 88 | this.highlightsMap.clear() 89 | } 90 | 91 | /** 92 | * Check if a paragraph has highlights 93 | */ 94 | hasHighlights(paragraphId: string): boolean { 95 | return this.highlightsMap.has(paragraphId) 96 | } 97 | 98 | /** 99 | * Get highlight count for a paragraph 100 | */ 101 | getHighlightCount(paragraphId: string): number { 102 | return this.getHighlightsForParagraph(paragraphId).length 103 | } 104 | 105 | /** 106 | * Get total highlight count 107 | */ 108 | getTotalHighlightCount(): number { 109 | let count = 0 110 | this.highlightsMap.forEach(highlights => { 111 | count += highlights.length 112 | }) 113 | return count 114 | } 115 | 116 | /** 117 | * Export highlights to array format (for compatibility) 118 | */ 119 | toArray(): Highlight[] { 120 | return this.getAllHighlights() 121 | } 122 | 123 | /** 124 | * Import highlights from array format 125 | */ 126 | fromArray(highlights: Highlight[]): void { 127 | this.clearAllHighlights() 128 | 129 | // Group by paragraph ID 130 | const grouped = highlights.reduce((acc, highlight) => { 131 | if (!acc[highlight.paragraphId]) { 132 | acc[highlight.paragraphId] = [] 133 | } 134 | acc[highlight.paragraphId].push(highlight) 135 | return acc 136 | }, {} as Record) 137 | 138 | // Set highlights for each paragraph 139 | Object.entries(grouped).forEach(([paragraphId, paragraphHighlights]) => { 140 | this.setHighlightsForParagraph(paragraphId, paragraphHighlights) 141 | }) 142 | } 143 | } 144 | 145 | export const highlightService = new HighlightService() -------------------------------------------------------------------------------- /services/editor/analysisService.ts: -------------------------------------------------------------------------------- 1 | import { anthropicService } from '@/lib/ai/anthropic' 2 | import { SpanIdentification } from '@/lib/ai/types' 3 | import { Paragraph, Highlight } from '@/types/editor' 4 | 5 | export class AnalysisService { 6 | private abortControllers: Map = new Map() 7 | private lastAnalyzedContent: Map = new Map() 8 | private analysisTimestamps: Map = new Map() 9 | 10 | async analyzeParagraph( 11 | paragraph: Paragraph, 12 | allParagraphs: Paragraph[], 13 | signal?: AbortSignal 14 | ): Promise { 15 | if (!paragraph.content.trim()) { 16 | return [] 17 | } 18 | 19 | // Skip if content hasn't changed 20 | if (this.lastAnalyzedContent.get(paragraph.id) === paragraph.content) { 21 | return [] 22 | } 23 | 24 | // Cancel any ongoing analysis for this paragraph 25 | this.cancelAnalysis(paragraph.id) 26 | 27 | // Create new abort controller 28 | const abortController = new AbortController() 29 | this.abortControllers.set(paragraph.id, abortController) 30 | 31 | // Combine signals if external signal provided 32 | const combinedSignal = signal 33 | ? this.combineSignals([signal, abortController.signal]) 34 | : abortController.signal 35 | 36 | try { 37 | // Update service credentials 38 | anthropicService.updateCredentials() 39 | 40 | // Prepare context 41 | const fullDocumentContext = { 42 | paragraphs: allParagraphs, 43 | targetParagraphId: paragraph.id 44 | } 45 | 46 | const response = await anthropicService.identifySpans( 47 | paragraph.content, 48 | fullDocumentContext, 49 | combinedSignal 50 | ) 51 | 52 | // Convert spans to highlights 53 | const highlights: Highlight[] = response.spans.map((span: SpanIdentification, idx: number) => ({ 54 | id: `highlight-${paragraph.id}-${idx}`, 55 | paragraphId: paragraph.id, 56 | type: span.type, 57 | priority: span.priority, 58 | text: this.truncateText(span.text, 30), 59 | startIndex: Math.max(0, span.startOffset), 60 | endIndex: Math.min(paragraph.content.length, span.endOffset), 61 | note: span.reasoning, 62 | confidence: span.confidence, 63 | fullText: span.text 64 | })) 65 | 66 | // Mark as analyzed 67 | this.lastAnalyzedContent.set(paragraph.id, paragraph.content) 68 | this.analysisTimestamps.set(paragraph.id, Date.now()) 69 | 70 | return highlights 71 | } catch (error) { 72 | if (error instanceof Error && error.name === 'AbortError') { 73 | return [] 74 | } 75 | throw error 76 | } finally { 77 | // Clean up 78 | if (this.abortControllers.get(paragraph.id) === abortController) { 79 | this.abortControllers.delete(paragraph.id) 80 | } 81 | } 82 | } 83 | 84 | cancelAnalysis(paragraphId: string): void { 85 | const controller = this.abortControllers.get(paragraphId) 86 | if (controller) { 87 | controller.abort() 88 | this.abortControllers.delete(paragraphId) 89 | } 90 | } 91 | 92 | clearAnalysisTracking(paragraphId: string): void { 93 | // Don't clear if analysis was completed very recently (within 500ms) 94 | // This prevents clearing when highlight application triggers content changes 95 | const analysisTime = this.analysisTimestamps.get(paragraphId) 96 | if (analysisTime && Date.now() - analysisTime < 500) { 97 | return 98 | } 99 | 100 | this.lastAnalyzedContent.delete(paragraphId) 101 | this.analysisTimestamps.delete(paragraphId) 102 | this.cancelAnalysis(paragraphId) 103 | } 104 | 105 | hasBeenAnalyzed(paragraphId: string, content: string): boolean { 106 | return this.lastAnalyzedContent.get(paragraphId) === content 107 | } 108 | 109 | private truncateText(text: string, maxLength: number): string { 110 | return text.slice(0, maxLength) + (text.length > maxLength ? '...' : '') 111 | } 112 | 113 | private combineSignals(signals: AbortSignal[]): AbortSignal { 114 | const controller = new AbortController() 115 | 116 | signals.forEach(signal => { 117 | if (signal.aborted) { 118 | controller.abort() 119 | } else { 120 | signal.addEventListener('abort', () => controller.abort()) 121 | } 122 | }) 123 | 124 | return controller.signal 125 | } 126 | } 127 | 128 | export const analysisService = new AnalysisService() -------------------------------------------------------------------------------- /lib/ai/prompts/span-triage.ts: -------------------------------------------------------------------------------- 1 | export const SPAN_TRIAGE_PROMPT = `You are a thoughtful writing coach that helps writers think more deeply about their ideas and improve the structure of their arguments. Focus on high-value feedback that helps the writer develop their thinking, not basic proofreading. 2 | 3 | {{DOCUMENT_CONTEXT}} 4 | 5 | **TARGET PARAGRAPH TO ANALYZE:** 6 | "{{TEXT}}" 7 | 8 | Identify specific text spans in the TARGET PARAGRAPH that would benefit from deeper thinking or structural improvement. Consider how this paragraph fits within the broader document context: 9 | 10 | **HIGH PRIORITY (focus on these first):** 11 | - **Expansion opportunities**: Vague claims, unsupported statements, or ideas that need more development 12 | - **Structural issues**: Poor flow, missing transitions, illogical organization 13 | - **Factual concerns**: Questionable claims, missing evidence, or assertions that need support 14 | - **Logic gaps**: Conclusions that don't follow from premises, missing steps in reasoning 15 | 16 | **MEDIUM PRIORITY:** 17 | - **Clarity problems**: Genuinely confusing or ambiguous statements that impede understanding 18 | - **Evidence gaps**: Claims that would benefit from examples, data, or citations 19 | 20 | **LOW PRIORITY (only flag if no higher-priority issues exist):** 21 | - **Basic issues**: Grammar, spelling, or simple style problems 22 | 23 | For each span you identify: 24 | 1. Extract the EXACT text (must match character-for-character, including spaces and punctuation) 25 | 2. Calculate character offsets by counting each character manually: 26 | - startOffset: Count from position 0 to where your span begins 27 | - endOffset: Count from position 0 to where your span ends (exclusive) 28 | - Example: In "Hello world", "world" starts at position 6 and ends at position 11 29 | - CRITICAL: Verify by checking that text.slice(startOffset, endOffset) === your extracted text 30 | 3. Categorize: expansion, structure, factual, logic, clarity, evidence, or basic 31 | 4. Set priority: high, medium, or low 32 | 5. Rate your confidence (0.0-1.0) 33 | 6. Provide reasoning that helps the writer think deeper 34 | 35 | CRITICAL VALIDATION: Before submitting each span, manually verify: 36 | - Count every character (including spaces, periods, commas) from the start 37 | - Your extracted text must exactly equal the slice from startOffset to endOffset 38 | - No overlapping spans - each character should only be in one span 39 | Be selective and focus on spans that genuinely help the writer improve their thinking and argumentation. 40 | 41 | Respond with valid JSON in this exact format. IMPORTANT: Escape any quotes or special characters in the reasoning field: 42 | { 43 | "spans": [ 44 | { 45 | "text": "exact text from the paragraph", 46 | "startOffset": 0, 47 | "endOffset": 10, 48 | "type": "expansion|structure|factual|logic|clarity|evidence|basic", 49 | "priority": "high|medium|low", 50 | "confidence": 0.85, 51 | "reasoning": "Question or insight that helps the writer think deeper (escape quotes with backslash)" 52 | } 53 | ] 54 | } 55 | 56 | CRITICAL: 57 | - Use only standard ASCII quotes (") 58 | - Escape any quotes in text or reasoning with \" 59 | - Do not include line breaks in reasoning text 60 | - Keep reasoning under 100 characters 61 | 62 | If no significant issues are found, return: {"spans": []}` 63 | 64 | export function buildSpanTriagePrompt(text: string, fullDocumentContext?: { paragraphs: Array<{ id: string; content: string }>, targetParagraphId: string }): string { 65 | let prompt = SPAN_TRIAGE_PROMPT 66 | 67 | // Build document context 68 | let documentContextSection = '' 69 | if (fullDocumentContext && fullDocumentContext.paragraphs.length > 1) { 70 | const nonEmptyParagraphs = fullDocumentContext.paragraphs.filter(p => p.content.trim()) 71 | 72 | if (nonEmptyParagraphs.length > 1) { 73 | documentContextSection = `**FULL DOCUMENT CONTEXT:** 74 | ${nonEmptyParagraphs.map((p, index) => { 75 | const isTarget = p.id === fullDocumentContext.targetParagraphId 76 | const marker = isTarget ? '>>> TARGET PARAGRAPH <<<' : `Paragraph ${index + 1}` 77 | return `${marker}:\n"${p.content}"` 78 | }).join('\n\n')} 79 | 80 | When analyzing the target paragraph, consider: 81 | - How it connects to preceding and following paragraphs 82 | - Whether it advances the overall argument or narrative 83 | - If transitions are smooth and logical 84 | - Whether it introduces ideas that need support from other paragraphs 85 | - If it repeats or contradicts information elsewhere in the document 86 | 87 | ` 88 | } 89 | } 90 | 91 | return prompt 92 | .replace('{{DOCUMENT_CONTEXT}}', documentContextSection) 93 | .replace('{{TEXT}}', text) 94 | } -------------------------------------------------------------------------------- /lib/tiptap/extensions/AIHighlight.ts: -------------------------------------------------------------------------------- 1 | import { Mark, mergeAttributes } from '@tiptap/core' 2 | import { Plugin, PluginKey } from 'prosemirror-state' 3 | 4 | export interface AIHighlightOptions { 5 | HTMLAttributes: Record 6 | onHighlightClick?: (id: string | null) => void 7 | } 8 | 9 | export interface AIHighlightAttributes { 10 | id: string 11 | type: string 12 | priority: string 13 | note: string 14 | confidence: number 15 | isActive: boolean 16 | } 17 | 18 | declare module '@tiptap/core' { 19 | interface Commands { 20 | aiHighlight: { 21 | setAIHighlight: (attributes: AIHighlightAttributes) => ReturnType 22 | unsetAIHighlight: () => ReturnType 23 | } 24 | } 25 | } 26 | 27 | export const AIHighlight = Mark.create({ 28 | name: 'aiHighlight', 29 | 30 | addOptions() { 31 | return { 32 | HTMLAttributes: {}, 33 | onHighlightClick: undefined, 34 | } 35 | }, 36 | 37 | addAttributes() { 38 | return { 39 | id: { 40 | default: null, 41 | parseHTML: (element: any) => element.getAttribute('data-highlight-id'), 42 | renderHTML: (attributes: any) => { 43 | if (!attributes.id) { 44 | return {} 45 | } 46 | return { 47 | 'data-highlight-id': attributes.id, 48 | } 49 | }, 50 | }, 51 | type: { 52 | default: 'basic', 53 | parseHTML: (element: any) => element.getAttribute('data-type'), 54 | renderHTML: (attributes: any) => { 55 | return { 56 | 'data-type': attributes.type, 57 | } 58 | }, 59 | }, 60 | priority: { 61 | default: 'medium', 62 | parseHTML: (element: any) => element.getAttribute('data-priority'), 63 | renderHTML: (attributes: any) => { 64 | return { 65 | 'data-priority': attributes.priority, 66 | } 67 | }, 68 | }, 69 | note: { 70 | default: '', 71 | parseHTML: (element: any) => element.getAttribute('data-note') || '', 72 | renderHTML: (attributes: any) => { 73 | return { 74 | 'data-note': attributes.note, 75 | } 76 | }, 77 | }, 78 | confidence: { 79 | default: 0.5, 80 | parseHTML: (element: any) => parseFloat(element.getAttribute('data-confidence') || '0.5'), 81 | renderHTML: (attributes: any) => { 82 | return { 83 | 'data-confidence': attributes.confidence.toString(), 84 | } 85 | }, 86 | }, 87 | isActive: { 88 | default: false, 89 | parseHTML: (element: any) => element.hasAttribute('data-is-active'), 90 | renderHTML: (attributes: any) => { 91 | if (attributes.isActive) { 92 | return { 93 | 'data-is-active': 'true', 94 | } 95 | } 96 | return {} 97 | }, 98 | } 99 | } 100 | }, 101 | 102 | parseHTML() { 103 | return [ 104 | { 105 | tag: 'span[data-highlight-id]', 106 | }, 107 | ] 108 | }, 109 | 110 | renderHTML({ HTMLAttributes, mark }: { HTMLAttributes: any, mark: any }) { 111 | const { type, priority, note } = mark.attrs 112 | const title = `${type} (${priority} priority): ${note}` 113 | 114 | return [ 115 | 'span', 116 | mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 117 | class: 'highlight', 118 | title, 119 | }), 120 | 0, 121 | ] 122 | }, 123 | 124 | addCommands() { 125 | return { 126 | setAIHighlight: 127 | (attributes: any) => 128 | ({ commands }: any) => { 129 | return commands.setMark(this.name, attributes) 130 | }, 131 | unsetAIHighlight: 132 | () => 133 | ({ commands }: any) => { 134 | return commands.unsetMark(this.name) 135 | }, 136 | } 137 | }, 138 | 139 | addProseMirrorPlugins() { 140 | return [ 141 | new Plugin({ 142 | key: new PluginKey('aiHighlightClick'), 143 | props: { 144 | handleDOMEvents: { 145 | click: (_view: any, event: any) => { 146 | const target = event.target as HTMLElement 147 | if (target.classList.contains('highlight') && this.options.onHighlightClick) { 148 | event.preventDefault() 149 | event.stopPropagation() 150 | const highlightId = target.getAttribute('data-highlight-id') 151 | this.options.onHighlightClick(highlightId) 152 | return true 153 | } 154 | return false 155 | }, 156 | }, 157 | }, 158 | }) 159 | ] 160 | }, 161 | }) -------------------------------------------------------------------------------- /.claude/agents/bug-detective.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: bug-detective 3 | description: Use this agent when you need to diagnose and fix bugs in your codebase. This agent excels at systematic debugging, root cause analysis, and implementing proper fixes without compromising existing functionality. Examples:\n\n\nContext: The user encounters an error or unexpected behavior in their application.\nuser: "The sidebar isn't updating when I click on highlights in the editor"\nassistant: "I'll use the bug-detective agent to investigate this issue systematically."\n\nSince this is a bug that needs investigation and fixing, the bug-detective agent should be used to analyze the problem thoroughly.\n\n\n\n\nContext: The user reports a specific error message or crash.\nuser: "I'm getting a TypeError: Cannot read property 'id' of undefined when saving drafts"\nassistant: "Let me launch the bug-detective agent to trace this error and find the root cause."\n\nThis is a specific bug with an error message, perfect for the bug-detective agent to investigate.\n\n\n\n\nContext: The user notices inconsistent behavior in their application.\nuser: "Sometimes the AI analysis runs twice for the same paragraph, but I can't figure out why"\nassistant: "I'll use the bug-detective agent to investigate this intermittent issue and find a proper solution."\n\nIntermittent bugs require systematic investigation, making this ideal for the bug-detective agent.\n\n 4 | color: yellow 5 | --- 6 | 7 | You are an elite debugging specialist with deep expertise in systematic bug analysis and resolution. Your approach combines meticulous investigation with creative problem-solving to fix even the most elusive bugs. 8 | 9 | **Core Principles:** 10 | - You NEVER give up on a bug until it's properly fixed 11 | - You NEVER take shortcuts like removing features to "fix" issues 12 | - You think deeply and systematically about each problem 13 | - You gather comprehensive information before proposing solutions 14 | 15 | **Debugging Methodology:** 16 | 17 | 1. **Initial Analysis Phase:** 18 | - Carefully read the bug description and symptoms 19 | - Identify the affected components and their interactions 20 | - Form initial hypotheses about potential causes 21 | - Request to see relevant files in their entirety if needed 22 | 23 | 2. **Investigation Strategy:** 24 | - Start by understanding the expected vs actual behavior 25 | - Trace the execution flow through the codebase 26 | - Identify all components involved in the problematic behavior 27 | - Look for recent changes that might have introduced the issue 28 | 29 | 3. **Diagnostic Approach:** 30 | - When needed, add strategic console.log statements or debugging output 31 | - Request the user to run the code and provide log outputs 32 | - Use the logs to narrow down the exact point of failure 33 | - Consider edge cases and race conditions 34 | 35 | 4. **Root Cause Analysis:** 36 | - Don't stop at symptoms - dig until you find the root cause 37 | - Consider multiple potential causes and test each hypothesis 38 | - Look for patterns that might indicate systemic issues 39 | - Check for timing issues, state management problems, or incorrect assumptions 40 | 41 | 5. **Solution Development:** 42 | - Propose fixes that address the root cause, not just symptoms 43 | - Ensure your fix doesn't break existing functionality 44 | - Consider the broader implications of your changes 45 | - Implement proper error handling where appropriate 46 | 47 | 6. **Verification Process:** 48 | - After implementing a fix, think through all affected scenarios 49 | - Consider potential side effects of your changes 50 | - Suggest additional testing to verify the fix 51 | - Add preventive measures to avoid similar bugs in the future 52 | 53 | **Communication Style:** 54 | - Explain your debugging process step-by-step 55 | - Be transparent about what information you need and why 56 | - Provide clear rationale for each debugging action 57 | - Keep the user informed of your progress and findings 58 | 59 | **When You Need More Information:** 60 | - Explicitly request to see full files when partial context isn't enough 61 | - Ask for specific log outputs when you add debugging statements 62 | - Request reproduction steps if the bug description is unclear 63 | - Ask about recent changes if the bug appeared suddenly 64 | 65 | **Quality Standards:** 66 | - Every fix must maintain or improve code quality 67 | - Never compromise existing features to fix a bug 68 | - Always consider performance implications of your fixes 69 | - Ensure your solutions are maintainable and well-documented 70 | 71 | Remember: You are a relentless problem solver. Each bug is a puzzle to be solved properly, not a nuisance to be worked around. Your persistence and systematic approach will uncover the truth behind even the most mysterious bugs. 72 | -------------------------------------------------------------------------------- /lib/ai/anthropic.ts: -------------------------------------------------------------------------------- 1 | import { SpanTriageResponse, AnthropicModel, ModelsResponse } from './types' 2 | import { buildSpanTriagePrompt } from './prompts/span-triage' 3 | 4 | const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages' 5 | const ANTHROPIC_MODELS_URL = 'https://api.anthropic.com/v1/models' 6 | 7 | export class AnthropicService { 8 | private apiKey: string | null = null 9 | private model: AnthropicModel = 'claude-3-5-haiku-20241022' 10 | 11 | constructor() { 12 | if (typeof window !== 'undefined') { 13 | this.apiKey = localStorage.getItem('anthropic_api_key') 14 | this.model = (localStorage.getItem('anthropic_model') as AnthropicModel) || 'claude-3-5-haiku-20241022' 15 | } 16 | } 17 | 18 | updateCredentials() { 19 | if (typeof window !== 'undefined') { 20 | this.apiKey = localStorage.getItem('anthropic_api_key') 21 | this.model = (localStorage.getItem('anthropic_model') as AnthropicModel) || 'claude-3-5-haiku-20241022' 22 | } 23 | } 24 | 25 | async fetchAvailableModels(): Promise { 26 | if (!this.apiKey) { 27 | throw new Error('API key not configured') 28 | } 29 | 30 | const response = await fetch('/api/anthropic/models', { 31 | method: 'GET', 32 | headers: { 33 | 'Authorization': `Bearer ${this.apiKey}` 34 | } 35 | }) 36 | 37 | if (!response.ok) { 38 | const error = await response.json() 39 | throw new Error(error.error || `Failed to fetch models: ${response.status}`) 40 | } 41 | 42 | const data = await response.json() 43 | return data as ModelsResponse 44 | } 45 | 46 | async identifySpans(paragraphText: string, fullDocumentContext?: { paragraphs: Array<{ id: string; content: string }>, targetParagraphId: string }, signal?: AbortSignal): Promise { 47 | if (!this.apiKey) { 48 | throw new Error('API key not configured') 49 | } 50 | 51 | if (!paragraphText.trim()) { 52 | return { spans: [] } 53 | } 54 | 55 | const response = await fetch('/api/anthropic/messages', { 56 | signal, 57 | method: 'POST', 58 | headers: { 59 | 'Content-Type': 'application/json', 60 | 'Authorization': `Bearer ${this.apiKey}` 61 | }, 62 | body: JSON.stringify({ 63 | model: this.model, 64 | max_tokens: 1024, 65 | temperature: 0, 66 | messages: [ 67 | { 68 | role: 'user', 69 | content: buildSpanTriagePrompt(paragraphText, fullDocumentContext) 70 | } 71 | ] 72 | }) 73 | }) 74 | 75 | if (!response.ok) { 76 | const error = await response.json() 77 | throw new Error(error.error || `API request failed: ${response.status}`) 78 | } 79 | 80 | const data = await response.json() 81 | const content = data.content[0].text 82 | 83 | try { 84 | // Clean the content before parsing 85 | let cleanContent = content.trim() 86 | 87 | // Extract only the JSON part - look for the first { and find its matching } 88 | const startIndex = cleanContent.indexOf('{') 89 | if (startIndex === -1) { 90 | console.error('No JSON object found in response') 91 | return { spans: [] } 92 | } 93 | 94 | let braceCount = 0 95 | let endIndex = -1 96 | 97 | for (let i = startIndex; i < cleanContent.length; i++) { 98 | if (cleanContent[i] === '{') { 99 | braceCount++ 100 | } else if (cleanContent[i] === '}') { 101 | braceCount-- 102 | if (braceCount === 0) { 103 | endIndex = i + 1 104 | break 105 | } 106 | } 107 | } 108 | 109 | if (endIndex === -1) { 110 | console.error('Malformed JSON - no closing brace found') 111 | return { spans: [] } 112 | } 113 | 114 | // Extract just the JSON portion 115 | cleanContent = cleanContent.substring(startIndex, endIndex) 116 | 117 | // Remove any potential control characters and fix common JSON issues 118 | cleanContent = cleanContent 119 | .replace(/[\x00-\x1F\x7F]/g, '') // Remove control characters 120 | .replace(/"/g, '"') // Fix smart quotes 121 | .replace(/"/g, '"') // Fix smart quotes 122 | 123 | const parsed = JSON.parse(cleanContent) 124 | 125 | // Validate and filter spans 126 | const validSpans = parsed.spans?.filter((span: any) => { 127 | // Check if span has required properties 128 | if (!span.text || span.startOffset === undefined || span.endOffset === undefined) { 129 | return false 130 | } 131 | 132 | // Try to find the actual position of the span text in the paragraph 133 | const spanTextIndex = paragraphText.indexOf(span.text) 134 | if (spanTextIndex === -1) { 135 | // Text not found in paragraph at all 136 | return false 137 | } 138 | 139 | // Update the span with the correct offsets 140 | span.startOffset = spanTextIndex 141 | span.endOffset = spanTextIndex + span.text.length 142 | 143 | // Span validation passed 144 | return true 145 | }) || [] 146 | 147 | return { spans: validSpans } as SpanTriageResponse 148 | } catch (e) { 149 | return { spans: [] } 150 | } 151 | } 152 | 153 | } 154 | 155 | export const anthropicService = new AnthropicService() -------------------------------------------------------------------------------- /components/sidebar/HighlightCard.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | 5 | interface Highlight { 6 | id: string 7 | type: string 8 | priority: 'high' | 'medium' | 'low' 9 | text: string 10 | fullText?: string 11 | note: string 12 | confidence?: number 13 | } 14 | 15 | interface HighlightCardProps { 16 | highlight: Highlight 17 | isActive: boolean 18 | isExpanded: boolean 19 | onSelect: (id: string | null) => void 20 | onToggleExpand: (id: string) => void 21 | } 22 | 23 | export function HighlightCard({ 24 | highlight, 25 | isActive, 26 | isExpanded, 27 | onSelect, 28 | onToggleExpand 29 | }: HighlightCardProps) { 30 | const handleCardClick = () => { 31 | onSelect(highlight.id) 32 | } 33 | 34 | const handleToggleClick = (e: React.MouseEvent) => { 35 | e.stopPropagation() 36 | onToggleExpand(highlight.id) 37 | } 38 | 39 | const shouldShowDetails = isExpanded || isActive 40 | 41 | const getCategoryStyles = () => { 42 | switch (highlight.type) { 43 | case 'expansion': 44 | return isActive 45 | ? 'ring-2 ring-green-500 shadow-lg shadow-green-500/20 bg-green-500/10' 46 | : 'hover:ring-1 hover:ring-green-500/50 hover:bg-green-500/5 border-l-4 border-green-500/60' 47 | case 'structure': 48 | return isActive 49 | ? 'ring-2 ring-blue-500 shadow-lg shadow-blue-500/20 bg-blue-500/10' 50 | : 'hover:ring-1 hover:ring-blue-500/50 hover:bg-blue-500/5 border-l-4 border-blue-500/60' 51 | case 'factual': 52 | return isActive 53 | ? 'ring-2 ring-red-500 shadow-lg shadow-red-500/20 bg-red-500/10' 54 | : 'hover:ring-1 hover:ring-red-500/50 hover:bg-red-500/5 border-l-4 border-red-500/60' 55 | case 'logic': 56 | return isActive 57 | ? 'ring-2 ring-orange-500 shadow-lg shadow-orange-500/20 bg-orange-500/10' 58 | : 'hover:ring-1 hover:ring-orange-500/50 hover:bg-orange-500/5 border-l-4 border-orange-500/60' 59 | case 'clarity': 60 | return isActive 61 | ? 'ring-2 ring-purple-500 shadow-lg shadow-purple-500/20 bg-purple-500/10' 62 | : 'hover:ring-1 hover:ring-purple-500/50 hover:bg-purple-500/5 border-l-4 border-purple-500/60' 63 | case 'evidence': 64 | return isActive 65 | ? 'ring-2 ring-yellow-500 shadow-lg shadow-yellow-500/20 bg-yellow-500/10' 66 | : 'hover:ring-1 hover:ring-yellow-500/50 hover:bg-yellow-500/5 border-l-4 border-yellow-500/60' 67 | case 'basic': 68 | return isActive 69 | ? 'ring-2 ring-gray-500 shadow-lg shadow-gray-500/20 bg-gray-500/10' 70 | : 'hover:ring-1 hover:ring-gray-500/50 hover:bg-gray-500/5 border-l-4 border-gray-500/60' 71 | default: 72 | return isActive 73 | ? 'ring-2 ring-gray-500 shadow-lg shadow-gray-500/20 bg-gray-500/10' 74 | : 'hover:ring-1 hover:ring-gray-500/50 hover:bg-gray-500/5 border-l-4 border-gray-500/60' 75 | } 76 | } 77 | 78 | const getCategoryColor = () => { 79 | switch (highlight.type) { 80 | case 'expansion': return 'text-green-400' 81 | case 'structure': return 'text-blue-400' 82 | case 'factual': return 'text-red-400' 83 | case 'logic': return 'text-orange-400' 84 | case 'clarity': return 'text-purple-400' 85 | case 'evidence': return 'text-yellow-400' 86 | case 'basic': return 'text-gray-400' 87 | default: return 'text-gray-400' 88 | } 89 | } 90 | 91 | return ( 92 |
100 |
101 |
102 |

103 | "{highlight.text}..." 104 |

105 |
106 | 107 | {highlight.type} 108 | 109 | {highlight.confidence && ( 110 | 111 | {Math.round(highlight.confidence * 100)}% 112 | 113 | )} 114 |
115 |
116 | 122 |
123 | 124 | {shouldShowDetails && ( 125 |
126 | {highlight.fullText && highlight.fullText !== highlight.text && ( 127 |
128 |

Full text:

129 |

"{highlight.fullText}"

130 |
131 | )} 132 |

{highlight.note}

133 |
134 | 137 | 140 |
141 |
142 | )} 143 |
144 | ) 145 | } -------------------------------------------------------------------------------- /hooks/useEditorHandlers.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { Paragraph, Highlight } from '@/types/editor' 3 | import { analysisService } from '@/services/editor/analysisService' 4 | 5 | interface UseEditorHandlersProps { 6 | paragraphs: Paragraph[] 7 | setParagraphs: React.Dispatch> 8 | localActiveParagraph: string 9 | setLocalActiveParagraph: React.Dispatch> 10 | analyzingParagraphs: Set 11 | highlights: Highlight[] 12 | activeHighlight: string | null 13 | onHighlightsChange: (highlights: Highlight[]) => void 14 | onHighlightClick: (id: string | null) => void 15 | analyzeChangedParagraph: (paragraphId: string) => Promise 16 | clearAnalysisTracking: (paragraphId: string) => void 17 | cancelAnalysis: (paragraphId: string) => void 18 | } 19 | 20 | export function useEditorHandlers({ 21 | paragraphs, 22 | setParagraphs, 23 | localActiveParagraph, 24 | setLocalActiveParagraph, 25 | analyzingParagraphs, 26 | highlights, 27 | activeHighlight, 28 | onHighlightsChange, 29 | onHighlightClick, 30 | analyzeChangedParagraph, 31 | clearAnalysisTracking, 32 | cancelAnalysis 33 | }: UseEditorHandlersProps) { 34 | 35 | const handleContentChange = useCallback((newParagraphs: Paragraph[]) => { 36 | // Check which paragraphs have changed content 37 | const changedParagraphs = newParagraphs.filter(newP => { 38 | const oldP = paragraphs.find(p => p.id === newP.id) 39 | return !oldP || oldP.content !== newP.content 40 | }) 41 | 42 | // Check if new paragraphs were created (Enter key pressed) 43 | const newParagraphsCreated = newParagraphs.filter(newP => !paragraphs.find(p => p.id === newP.id)) 44 | 45 | // Only update if there are actual changes 46 | if (changedParagraphs.length === 0 && newParagraphs.length === paragraphs.length) { 47 | return 48 | } 49 | 50 | // Update paragraphs state 51 | setParagraphs(newParagraphs) 52 | 53 | // If new paragraphs were created (Enter pressed), analyze the previous paragraph 54 | if (newParagraphsCreated.length > 0 && localActiveParagraph) { 55 | const previousParagraph = paragraphs.find(p => p.id === localActiveParagraph) 56 | if (previousParagraph && previousParagraph.content.trim()) { 57 | // Cancel any existing analysis for this paragraph to avoid conflicts 58 | cancelAnalysis(localActiveParagraph) 59 | 60 | // Only analyze if content hasn't been analyzed yet 61 | if (!analysisService.hasBeenAnalyzed(localActiveParagraph, previousParagraph.content)) { 62 | analyzeChangedParagraph(previousParagraph.id) 63 | } 64 | } 65 | } 66 | 67 | // Handle changed paragraphs - clear highlights when content changes 68 | changedParagraphs.forEach(paragraph => { 69 | const { id } = paragraph 70 | const isEnterScenario = newParagraphsCreated.length > 0 71 | 72 | // Only clear analysis tracking if content changed and it's not an Enter scenario 73 | // Also don't clear if content just became empty (likely temporary during highlight application) 74 | const oldParagraph = paragraphs.find(p => p.id === id) 75 | const contentActuallyChanged = !oldParagraph || oldParagraph.content !== paragraph.content 76 | 77 | if (contentActuallyChanged && !isEnterScenario && paragraph.content.trim() !== '') { 78 | // Clear highlights for this paragraph immediately since the text has changed 79 | const updatedHighlights = highlights.filter(h => h.paragraphId !== id) 80 | onHighlightsChange(updatedHighlights) 81 | 82 | // Clear active highlight if it was in this paragraph 83 | if (activeHighlight) { 84 | const activeHighlightObj = highlights.find(h => h.id === activeHighlight) 85 | if (activeHighlightObj && activeHighlightObj.paragraphId === id) { 86 | onHighlightClick(null) 87 | } 88 | } 89 | 90 | // Clear analysis tracking for this paragraph since content changed 91 | clearAnalysisTracking(id) 92 | } 93 | }) 94 | }, [ 95 | paragraphs, 96 | setParagraphs, 97 | highlights, 98 | activeHighlight, 99 | onHighlightsChange, 100 | onHighlightClick, 101 | localActiveParagraph, 102 | analyzeChangedParagraph, 103 | clearAnalysisTracking, 104 | analyzingParagraphs 105 | ]) 106 | 107 | const handleParagraphCreate = useCallback((id: string) => { 108 | // Don't change active paragraph if the current one is being analyzed 109 | // This preserves the loading state in the sidebar 110 | if (!analyzingParagraphs.has(localActiveParagraph || '')) { 111 | setLocalActiveParagraph(id) 112 | } 113 | // If analysis is running, let it complete before switching paragraphs 114 | }, [analyzingParagraphs, localActiveParagraph, setLocalActiveParagraph]) 115 | 116 | const handleParagraphDelete = useCallback((id: string) => { 117 | // Clean up any ongoing analysis for this paragraph 118 | cancelAnalysis(id) 119 | 120 | // Clear analysis tracking 121 | clearAnalysisTracking(id) 122 | 123 | // Remove highlights for this paragraph 124 | const updatedHighlights = highlights.filter(h => h.paragraphId !== id) 125 | onHighlightsChange(updatedHighlights) 126 | 127 | // Clear active highlight if it was in this paragraph 128 | if (activeHighlight) { 129 | const activeHighlightObj = highlights.find(h => h.id === activeHighlight) 130 | if (activeHighlightObj && activeHighlightObj.paragraphId === id) { 131 | onHighlightClick(null) 132 | } 133 | } 134 | }, [ 135 | highlights, 136 | activeHighlight, 137 | onHighlightsChange, 138 | onHighlightClick, 139 | cancelAnalysis, 140 | clearAnalysisTracking 141 | ]) 142 | 143 | const handleParagraphFocus = useCallback((id: string) => { 144 | setLocalActiveParagraph(id) 145 | }, [setLocalActiveParagraph]) 146 | 147 | return { 148 | handleContentChange, 149 | handleParagraphCreate, 150 | handleParagraphDelete, 151 | handleParagraphFocus 152 | } 153 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HyperDrafter 🚀 2 | 3 | A sophisticated AI-powered writing editor that provides intelligent feedback as you write. Built with a focus on seamless text editing and contextual AI assistance. 4 | 5 | ## ✨ Key Features 6 | 7 | - **📝 Professional Editor**: TipTap-powered editor with typewriter font and elegant paper styling 8 | - **🤖 Intelligent AI Analysis**: Real-time feedback with category-based highlighting system using TipTap marks 9 | - **🎯 Contextual Highlights**: Seven feedback categories (expansion, structure, factual, logic, clarity, evidence, basic) with distinct colors 10 | - **📊 Smart Sidebar**: Compact paragraph indicators with smooth animations and expandable detailed feedback 11 | - **🔄 Document Context**: AI analyzes each paragraph within full document context for better suggestions 12 | - **⚡ Modern Architecture**: TipTap framework with custom extensions for paragraph-based AI analysis 13 | - **✨ Smooth Animations**: Polished transitions for paragraph selection and visual state changes 14 | 15 | ## 🎨 Design Philosophy 16 | 17 | HyperDrafter follows the Hyper suite's cyberpunk design language: 18 | - **Typography**: Courier Prime typewriter font family 19 | - **Colors**: Category-based system (green=expansion, blue=structure, red=factual, orange=logic, purple=clarity, yellow=evidence, gray=basic) 20 | - **Themes**: Seamless dark/light mode with paper metaphor 21 | - **Interactions**: Smooth transitions with glass morphism effects 22 | 23 | ## 🚀 Getting Started 24 | 25 | ```bash 26 | # Install dependencies 27 | npm install 28 | 29 | # Start development server 30 | npm run dev 31 | 32 | # Build for production 33 | npm run build 34 | 35 | # Run type checking 36 | npm run typecheck 37 | ``` 38 | 39 | ## 🏗️ Architecture 40 | 41 | ### Editor Core 42 | - **TipTap Framework**: Professional editor built on TipTap with custom extensions 43 | - **Paragraph-based Architecture**: Each paragraph maintains unique ID with independent AI analysis 44 | - **Real-time Feedback**: 1-second debounced analysis after typing stops 45 | - **TipTap Marks**: Advanced highlighting system using TipTap's mark system with overlap resolution 46 | - **Theme Management**: React context with localStorage persistence 47 | - **Smooth Animations**: CSS transitions for paragraph selection and state changes 48 | - **Clean Architecture**: Separation of concerns with dedicated hooks and services 49 | 50 | ### AI Integration 51 | - **Service Layer**: Dedicated `analysisService` for AI operations with abort handling 52 | - **Two-stage Analysis**: Efficient span identification followed by detailed feedback 53 | - **Document Context**: Full document awareness for contextual paragraph analysis 54 | - **Category System**: Seven distinct feedback types with priority ordering 55 | - **Robust Parsing**: Handles AI responses with extra text and JSON formatting 56 | 57 | ### State Management 58 | - **Custom Hooks**: Modular state management with `useEditorState`, `useAnalyzeParagraph`, and `useEditorHandlers` 59 | - **Service Pattern**: Separate services for analysis and highlight management 60 | - **Type Safety**: Full TypeScript coverage with proper interfaces 61 | - **Performance**: React.memo and useMemo optimizations throughout 62 | 63 | ## 📁 Project Structure 64 | 65 | ``` 66 | HyperDrafter/ 67 | ├── components/ 68 | │ ├── editor/ # Core editing components 69 | │ │ ├── Editor.tsx # Main editor orchestration (117 lines) 70 | │ │ └── TipTapEditor.tsx # TipTap editor wrapper with React.memo 71 | │ ├── sidebar/ # AI feedback interface 72 | │ │ ├── Sidebar.tsx # Main sidebar with paragraph indicators 73 | │ │ └── HighlightCard.tsx # Individual feedback cards 74 | │ └── ui/ # Shared UI components 75 | ├── lib/ 76 | │ ├── ai/ # AI integration layer 77 | │ │ ├── anthropic.ts # Anthropic API service 78 | │ │ └── prompts/ # AI prompts and templates 79 | │ └── tiptap/ # TipTap extensions 80 | │ └── extensions/ # Custom TipTap extensions 81 | │ ├── ParagraphWithId.ts # Paragraph node with unique IDs 82 | │ └── AIHighlight.ts # AI highlight mark 83 | ├── services/ 84 | │ └── editor/ # Editor services 85 | │ ├── analysisService.ts # AI analysis operations 86 | │ └── highlightService.ts # Highlight management 87 | ├── types/ 88 | │ └── editor.ts # TypeScript interfaces (Paragraph, Highlight, etc.) 89 | ├── contexts/ # React contexts (theme, etc.) 90 | └── hooks/ # Custom React hooks 91 | ├── useAnalyzeParagraph.ts # AI analysis logic 92 | ├── useEditorState.ts # Editor state management 93 | ├── useEditorHandlers.ts # Event handlers 94 | └── useParagraphDebounce.ts # Debouncing logic 95 | ``` 96 | 97 | ## 🎯 Current Status 98 | 99 | HyperDrafter has a **professional core** with: 100 | - ✅ TipTap-powered editor with custom extensions 101 | - ✅ Paragraph-based AI analysis maintaining workflow 102 | - ✅ Real-time AI analysis with TipTap marks for highlighting 103 | - ✅ Modern paper-like UI with smooth animations 104 | - ✅ Interactive sidebar with polished transitions 105 | - ✅ Document-context aware AI feedback 106 | - ✅ Preserved cursor positioning during paragraph selection 107 | - ✅ Clean architecture with separated concerns 108 | - ✅ Full TypeScript type safety 109 | - ✅ Performance optimizations with React.memo and useMemo 110 | - ✅ Service layer for AI and highlight management 111 | 112 | ## 🔮 Next Steps 113 | 114 | - **Draft Management**: Auto-save and document persistence 115 | - **Export System**: Markdown, TXT, and HTML export 116 | - **TipTap Formatting**: Bold, italic, and text formatting extensions 117 | - **Enhanced Node Types**: Headings, lists, blockquotes using TipTap nodes 118 | - **Keyboard Navigation**: Streamlined highlight traversal 119 | 120 | ## 🛠️ Tech Stack 121 | 122 | - **Framework**: Next.js 15 with App Router 123 | - **Language**: TypeScript 5.8+ 124 | - **Editor**: TipTap 3.0+ with custom extensions 125 | - **Styling**: Tailwind CSS 3.4+ with custom design system 126 | - **UI**: React 19 with custom hooks and contexts 127 | - **AI**: Anthropic Claude integration with intelligent prompting 128 | - **Storage**: LocalStorage for settings and draft persistence 129 | 130 | Part of the **Hyper suite** maintaining consistent design and architecture patterns. 131 | 132 | ## 📄 License 133 | 134 | MIT -------------------------------------------------------------------------------- /lib/tiptap/extensions/ParagraphWithId.ts: -------------------------------------------------------------------------------- 1 | import { Node, mergeAttributes } from '@tiptap/core' 2 | import { Plugin, PluginKey } from 'prosemirror-state' 3 | 4 | export interface ParagraphWithIdOptions { 5 | HTMLAttributes: Record 6 | onParagraphCreate?: (id: string) => void 7 | onParagraphDelete?: (id: string) => void 8 | onParagraphFocus?: (id: string) => void 9 | } 10 | 11 | declare module '@tiptap/core' { 12 | interface Commands { 13 | paragraphWithId: { 14 | setParagraphWithId: (attributes?: { id?: string }) => ReturnType 15 | } 16 | } 17 | } 18 | 19 | export const ParagraphWithId = Node.create({ 20 | name: 'paragraphWithId', 21 | 22 | priority: 1000, 23 | 24 | addOptions() { 25 | return { 26 | HTMLAttributes: {}, 27 | onParagraphCreate: undefined, 28 | onParagraphDelete: undefined, 29 | onParagraphFocus: undefined, 30 | } 31 | }, 32 | 33 | group: 'block', 34 | 35 | content: 'inline*', 36 | 37 | parseHTML() { 38 | return [ 39 | { tag: 'p' }, 40 | ] 41 | }, 42 | 43 | renderHTML({ node, HTMLAttributes }) { 44 | const id = node.attrs.id || `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` 45 | const isTitle = node.attrs.isTitle 46 | const isActive = node.attrs.isActive 47 | 48 | const baseClasses = ` 49 | w-full bg-transparent resize-none overflow-hidden 50 | focus:outline-none whitespace-pre-wrap 51 | `.replace(/\s+/g, ' ').trim() 52 | 53 | const titleClasses = isTitle 54 | ? 'py-2 min-h-[2.5rem] text-2xl font-bold leading-tight' 55 | : 'py-1 min-h-[1.5rem] text-base leading-normal' 56 | 57 | const placeholderAttr = !node.textContent ? (isTitle ? 'Enter your title...' : 'Start typing...') : undefined 58 | 59 | return [ 60 | 'p', 61 | mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 62 | 'data-paragraph-id': id, 63 | 'data-is-title': isTitle ? 'true' : undefined, 64 | 'data-is-active': isActive ? 'true' : undefined, 65 | 'data-placeholder': placeholderAttr, 66 | class: `${baseClasses} ${titleClasses}` 67 | }), 68 | 0, 69 | ] 70 | }, 71 | 72 | addAttributes() { 73 | return { 74 | id: { 75 | default: null, 76 | parseHTML: element => element.getAttribute('data-paragraph-id'), 77 | renderHTML: attributes => { 78 | if (!attributes.id) { 79 | return {} 80 | } 81 | return { 82 | 'data-paragraph-id': attributes.id, 83 | } 84 | }, 85 | }, 86 | isTitle: { 87 | default: false, 88 | parseHTML: element => element.hasAttribute('data-is-title'), 89 | renderHTML: attributes => { 90 | if (!attributes.isTitle) { 91 | return {} 92 | } 93 | return { 94 | 'data-is-title': 'true' 95 | } 96 | }, 97 | }, 98 | isActive: { 99 | default: false, 100 | parseHTML: element => element.hasAttribute('data-is-active'), 101 | renderHTML: attributes => { 102 | if (!attributes.isActive) { 103 | return {} 104 | } 105 | return { 106 | 'data-is-active': 'true' 107 | } 108 | }, 109 | } 110 | } 111 | }, 112 | 113 | addCommands() { 114 | return { 115 | setParagraphWithId: 116 | (attributes) => 117 | ({ commands }) => { 118 | const id = attributes?.id || `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` 119 | return commands.setNode(this.name, { ...attributes, id }) 120 | }, 121 | } 122 | }, 123 | 124 | addKeyboardShortcuts() { 125 | return { 126 | 'Mod-Alt-0': () => this.editor.commands.setParagraphWithId(), 127 | Enter: ({ editor }) => { 128 | const { state } = editor 129 | const { selection } = state 130 | const { $from } = selection 131 | 132 | // Get current paragraph node and its attributes 133 | const currentNode = $from.node() 134 | const currentId = currentNode.attrs.id 135 | 136 | // Create new paragraph with new ID 137 | const newId = `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` 138 | 139 | // Notify about paragraph creation 140 | if (this.options.onParagraphCreate) { 141 | this.options.onParagraphCreate(newId) 142 | } 143 | 144 | // Split the paragraph and create new one 145 | return editor.commands.splitBlock({ 146 | keepMarks: false, 147 | }) && editor.commands.setParagraphWithId({ id: newId }) 148 | }, 149 | Backspace: ({ editor }) => { 150 | const { state } = editor 151 | const { selection } = state 152 | const { $from, $to } = selection 153 | 154 | // Check if we're at the very start of the document 155 | if ($from.pos === 1 && $to.pos === 1) { 156 | // Prevent backspace at the very beginning of the first paragraph 157 | return true 158 | } 159 | 160 | // Check if we're at the start of an empty paragraph 161 | if ($from.parentOffset === 0 && $from.parent.textContent === '') { 162 | const currentNode = $from.node() 163 | const currentId = currentNode.attrs.id 164 | 165 | // Don't delete if it's the only paragraph 166 | const doc = state.doc 167 | let paragraphCount = 0 168 | doc.descendants((node) => { 169 | if (node.type.name === 'paragraphWithId') { 170 | paragraphCount++ 171 | } 172 | }) 173 | 174 | if (paragraphCount > 1 && this.options.onParagraphDelete) { 175 | this.options.onParagraphDelete(currentId) 176 | return editor.commands.deleteNode('paragraphWithId') 177 | } 178 | 179 | // If it's the only paragraph, prevent deletion 180 | return true 181 | } 182 | 183 | return false // Let default backspace behavior handle other cases 184 | }, 185 | } 186 | }, 187 | 188 | addProseMirrorPlugins() { 189 | const updateFocusFromSelection = (view: any) => { 190 | const { state } = view 191 | const { selection } = state 192 | const { $from } = selection 193 | 194 | // Find the paragraph containing the cursor 195 | let paragraphNode = null 196 | 197 | for (let i = $from.depth; i >= 0; i--) { 198 | const node = $from.node(i) 199 | if (node.type.name === 'paragraphWithId') { 200 | paragraphNode = node 201 | break 202 | } 203 | } 204 | 205 | if (paragraphNode && this.options.onParagraphFocus) { 206 | const id = paragraphNode.attrs.id 207 | if (id) { 208 | this.options.onParagraphFocus(id) 209 | } 210 | } 211 | } 212 | 213 | return [ 214 | new Plugin({ 215 | key: new PluginKey('paragraphWithIdFocus'), 216 | props: { 217 | handleDOMEvents: { 218 | focus: (_view, event) => { 219 | const target = event.target as HTMLElement 220 | const paragraphEl = target.closest('[data-paragraph-id]') 221 | if (paragraphEl && this.options.onParagraphFocus) { 222 | const id = paragraphEl.getAttribute('data-paragraph-id') 223 | if (id) { 224 | this.options.onParagraphFocus(id) 225 | } 226 | } 227 | return false 228 | }, 229 | click: (_view, event) => { 230 | // Also detect clicks within paragraphs 231 | const target = event.target as HTMLElement 232 | const paragraphEl = target.closest('[data-paragraph-id]') 233 | if (paragraphEl && this.options.onParagraphFocus) { 234 | const id = paragraphEl.getAttribute('data-paragraph-id') 235 | if (id) { 236 | this.options.onParagraphFocus(id) 237 | } 238 | } 239 | // Don't prevent default - let TipTap handle the cursor positioning 240 | return false 241 | }, 242 | keydown: (view, _event) => { 243 | // After any key press, check if focus needs updating 244 | // Use setTimeout to ensure this runs after the key event is processed 245 | setTimeout(() => { 246 | updateFocusFromSelection(view) 247 | }, 0) 248 | return false 249 | } 250 | } 251 | }, 252 | view() { 253 | return { 254 | update: (view) => { 255 | // Update focus whenever selection changes 256 | updateFocusFromSelection(view) 257 | } 258 | } 259 | } 260 | }) 261 | ] 262 | }, 263 | }) -------------------------------------------------------------------------------- /components/ui/SettingsModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState, useEffect } from 'react' 4 | import { KeyIcon, XMarkIcon, CpuChipIcon } from '@heroicons/react/24/outline' 5 | import { anthropicService } from '@/lib/ai/anthropic' 6 | import { ModelInfo } from '@/lib/ai/types' 7 | 8 | interface SettingsModalProps { 9 | isOpen: boolean 10 | onClose: () => void 11 | } 12 | 13 | export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { 14 | const [apiKey, setApiKey] = useState('') 15 | const [isVisible, setIsVisible] = useState(false) 16 | const [isSaved, setIsSaved] = useState(false) 17 | const [selectedModel, setSelectedModel] = useState('claude-3-5-haiku-20241022') 18 | const [availableModels, setAvailableModels] = useState([]) 19 | const [isLoadingModels, setIsLoadingModels] = useState(false) 20 | const [modelsError, setModelsError] = useState(null) 21 | 22 | useEffect(() => { 23 | const savedKey = localStorage.getItem('anthropic_api_key') 24 | const savedModel = localStorage.getItem('anthropic_model') 25 | if (savedKey) { 26 | setApiKey(savedKey) 27 | } 28 | if (savedModel) { 29 | setSelectedModel(savedModel) 30 | } 31 | }, []) 32 | 33 | useEffect(() => { 34 | if (apiKey && isOpen) { 35 | fetchModels() 36 | } 37 | }, [apiKey, isOpen]) 38 | 39 | const fetchModels = async () => { 40 | setIsLoadingModels(true) 41 | setModelsError(null) 42 | 43 | try { 44 | anthropicService.updateCredentials() 45 | const response = await anthropicService.fetchAvailableModels() 46 | setAvailableModels(response.data) 47 | 48 | // If saved model is not in the list, select the first one 49 | if (response.data.length > 0 && !response.data.find(m => m.id === selectedModel)) { 50 | setSelectedModel(response.data[0].id) 51 | } 52 | } catch (error) { 53 | console.error('Failed to fetch models:', error) 54 | setModelsError(error instanceof Error ? error.message : 'Failed to fetch models') 55 | } finally { 56 | setIsLoadingModels(false) 57 | } 58 | } 59 | 60 | const handleSave = () => { 61 | localStorage.setItem('anthropic_api_key', apiKey) 62 | localStorage.setItem('anthropic_model', selectedModel) 63 | setIsSaved(true) 64 | setTimeout(() => setIsSaved(false), 2000) 65 | } 66 | 67 | const handleClear = () => { 68 | localStorage.removeItem('anthropic_api_key') 69 | setApiKey('') 70 | setIsSaved(true) 71 | setTimeout(() => setIsSaved(false), 2000) 72 | } 73 | 74 | if (!isOpen) return null 75 | 76 | return ( 77 |
78 | {/* Backdrop */} 79 |
83 | 84 | {/* Modal */} 85 |
86 | {/* Header */} 87 |
88 |

Settings

89 | 95 |
96 | 97 | {/* Content */} 98 |
99 |
100 | 101 |

Anthropic API Key

102 |
103 | 104 |

105 | Your API key is stored locally in your browser and never sent to our servers. 106 |

107 | 108 |
109 |
110 | setApiKey(e.target.value)} 114 | placeholder="sk-ant-api03-..." 115 | className="w-full px-4 py-3 bg-black/50 border border-purple-500/30 rounded-lg focus:outline-none focus:border-purple-500 focus:shadow-lg focus:shadow-purple-500/20 transition-all duration-200 pr-24 text-white" 116 | /> 117 | 123 |
124 | 125 |
126 | 133 | 140 |
141 | 142 | {isSaved && ( 143 |
144 | ✓ Changes saved 145 |
146 | )} 147 |
148 | 149 | {/* Model Selection */} 150 | {apiKey && ( 151 |
152 |
153 | 154 |

AI Model

155 |
156 | 157 |

158 | Choose the Claude model for AI analysis. 159 |

160 | 161 | {isLoadingModels ? ( 162 |
163 |
164 |
165 | ) : modelsError ? ( 166 |
167 | {modelsError} 168 |
169 | ) : availableModels.length > 0 ? ( 170 | 187 | ) : ( 188 | 203 | )} 204 |
205 | )} 206 |
207 | 208 | {/* Footer */} 209 |
210 | 216 |
217 |
218 |
219 | ) 220 | } -------------------------------------------------------------------------------- /components/sidebar/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState, useEffect } from 'react' 4 | import { HighlightCard } from './HighlightCard' 5 | 6 | interface SidebarProps { 7 | highlights: any[] 8 | activeHighlight: string | null 9 | onHighlightSelect: (id: string | null) => void 10 | analyzingParagraphs: Array<{ id: string; content: string }> 11 | activeParagraph: string | null 12 | paragraphs: Array<{ id: string; content: string }> 13 | onParagraphSelect: (paragraphId: string) => void 14 | } 15 | 16 | export function Sidebar({ highlights, activeHighlight, onHighlightSelect, analyzingParagraphs, activeParagraph, paragraphs, onParagraphSelect }: SidebarProps) { 17 | const [expandedNotes, setExpandedNotes] = useState>(new Set()) 18 | const [previousActiveParagraph, setPreviousActiveParagraph] = useState(null) 19 | 20 | // Auto-expand all cards when paragraph becomes active 21 | useEffect(() => { 22 | if (activeParagraph && activeParagraph !== previousActiveParagraph) { 23 | // Get all highlight IDs for the newly active paragraph 24 | const paragraphHighlightIds = highlights 25 | .filter(h => h.paragraphId === activeParagraph) 26 | .map(h => h.id) 27 | 28 | // Add all these highlights to the expanded set 29 | setExpandedNotes(prev => { 30 | const newSet = new Set(prev) 31 | paragraphHighlightIds.forEach(id => newSet.add(id)) 32 | return newSet 33 | }) 34 | } 35 | setPreviousActiveParagraph(activeParagraph) 36 | }, [activeParagraph, highlights]) 37 | 38 | const toggleNote = (id: string) => { 39 | setExpandedNotes(prev => { 40 | const newSet = new Set(prev) 41 | if (newSet.has(id)) { 42 | newSet.delete(id) 43 | } else { 44 | newSet.add(id) 45 | } 46 | return newSet 47 | }) 48 | } 49 | 50 | const getFirstThreeWords = (content: string, paragraphId: string) => { 51 | const trimmed = content.trim() 52 | if (!trimmed) return `Paragraph ${paragraphId}` 53 | 54 | const words = trimmed.split(/\s+/).slice(0, 3) 55 | const preview = words.join(' ') 56 | return preview.length > 30 ? preview.slice(0, 30) + '...' : preview 57 | } 58 | 59 | return ( 60 | 214 | ) 215 | } -------------------------------------------------------------------------------- /components/editor/TipTapEditor.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEditor, EditorContent } from '@tiptap/react' 4 | import { Document } from '@tiptap/extension-document' 5 | import { Text } from '@tiptap/extension-text' 6 | import { History } from '@tiptap/extension-history' 7 | import { Dropcursor } from '@tiptap/extension-dropcursor' 8 | import { Gapcursor } from '@tiptap/extension-gapcursor' 9 | import { useEffect, memo } from 'react' 10 | import { ParagraphWithId } from '@/lib/tiptap/extensions/ParagraphWithId' 11 | import { AIHighlight } from '@/lib/tiptap/extensions/AIHighlight' 12 | import { TipTapEditorProps, Paragraph } from '@/types/editor' 13 | 14 | export const TipTapEditor = memo(function TipTapEditor({ 15 | initialContent, 16 | onParagraphCreate, 17 | onParagraphDelete, 18 | onParagraphFocus, 19 | onContentChange, 20 | highlights, 21 | activeHighlight, 22 | onHighlightClick, 23 | activeParagraph 24 | }: TipTapEditorProps) { 25 | 26 | const editor = useEditor({ 27 | immediatelyRender: false, 28 | extensions: [ 29 | Document, 30 | Text, 31 | History, 32 | Dropcursor, 33 | Gapcursor, 34 | ParagraphWithId.configure({ 35 | onParagraphCreate, 36 | onParagraphDelete, 37 | onParagraphFocus, 38 | }), 39 | AIHighlight.configure({ 40 | onHighlightClick, 41 | }), 42 | ], 43 | content: generateInitialContent(initialContent), 44 | editorProps: { 45 | attributes: { 46 | class: 'editor-paper rounded-lg p-8 min-h-[800px] shadow-2xl space-y-2 focus:outline-none', 47 | spellcheck: 'false', 48 | 'data-gramm': 'false', 49 | 'data-gramm_editor': 'false', 50 | 'data-enable-grammarly': 'false', 51 | }, 52 | handlePaste: (view, event, slice) => { 53 | // Handle plain text paste 54 | const text = event.clipboardData?.getData('text/plain') 55 | if (text) { 56 | view.dispatch(view.state.tr.insertText(text)) 57 | return true 58 | } 59 | return false 60 | }, 61 | }, 62 | onUpdate: ({ editor }) => { 63 | // Extract paragraphs and their content 64 | const paragraphs: Paragraph[] = [] 65 | 66 | editor.state.doc.descendants((node, pos) => { 67 | if (node.type.name === 'paragraphWithId') { 68 | const id = node.attrs.id || `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` 69 | const content = node.textContent || '' 70 | paragraphs.push({ id, content }) 71 | } 72 | }) 73 | 74 | // Call directly without setTimeout to avoid focus loss 75 | onContentChange(paragraphs) 76 | }, 77 | }) 78 | 79 | // Update highlights when they change 80 | useEffect(() => { 81 | if (!editor || !editor.isEditable) return 82 | 83 | // Use a timeout to batch highlight updates and avoid interrupting typing 84 | const timeoutId = setTimeout(() => { 85 | try { 86 | // Store current selection to restore it later 87 | const currentSelection = editor.state.selection 88 | 89 | // First, clear all existing highlights by removing all aiHighlight marks 90 | const { tr } = editor.state 91 | editor.state.doc.descendants((node, pos) => { 92 | if (node.marks) { 93 | node.marks.forEach(mark => { 94 | if (mark.type.name === 'aiHighlight') { 95 | tr.removeMark(pos, pos + node.nodeSize, mark.type) 96 | } 97 | }) 98 | } 99 | }) 100 | editor.view.dispatch(tr) 101 | 102 | // Then apply new highlights 103 | highlights.forEach(highlight => { 104 | const { paragraphId, startIndex, endIndex, id, type, priority, note, confidence } = highlight 105 | const isActive = activeHighlight === id 106 | 107 | // Find the paragraph in the document 108 | let paragraphPos = -1 109 | let paragraphNode: any = null 110 | 111 | editor.state.doc.descendants((node, pos) => { 112 | if (node.type.name === 'paragraphWithId' && node.attrs.id === paragraphId) { 113 | paragraphPos = pos 114 | paragraphNode = node 115 | return false // Stop iteration 116 | } 117 | }) 118 | 119 | if (paragraphPos !== -1 && paragraphNode) { 120 | // Calculate absolute positions in the document 121 | const from = paragraphPos + 1 + startIndex // +1 for the paragraph node itself 122 | const to = paragraphPos + 1 + endIndex 123 | 124 | // Apply the highlight mark 125 | try { 126 | editor.commands.setTextSelection({ from, to }) 127 | editor.commands.setAIHighlight({ 128 | id, 129 | type, 130 | priority, 131 | note, 132 | confidence, 133 | isActive 134 | }) 135 | } catch (error) { 136 | // Skip if there's an error applying the highlight 137 | } 138 | } 139 | }) 140 | 141 | // Restore original selection to maintain cursor position 142 | try { 143 | editor.commands.setTextSelection(currentSelection) 144 | } catch (error) { 145 | // If we can't restore selection, at least don't blur 146 | } 147 | } catch (error) { 148 | // Ignore all errors during highlight updates 149 | } 150 | }, 10) // Small delay to batch updates 151 | 152 | return () => clearTimeout(timeoutId) 153 | }, [editor, highlights, activeHighlight]) 154 | 155 | // Update active paragraph styling 156 | useEffect(() => { 157 | if (!editor || !editor.isEditable) return 158 | 159 | // Update all paragraphs to set/unset active state and title state 160 | try { 161 | const { tr } = editor.state 162 | let hasChanges = false 163 | let paragraphIndex = 0 164 | 165 | editor.state.doc.descendants((node, pos) => { 166 | if (node.type.name === 'paragraphWithId') { 167 | const isActive = node.attrs.id === activeParagraph 168 | const currentlyActive = node.attrs.isActive 169 | const isTitle = paragraphIndex === 0 170 | const currentlyTitle = node.attrs.isTitle 171 | 172 | if (isActive !== currentlyActive || isTitle !== currentlyTitle) { 173 | tr.setNodeMarkup(pos, undefined, { ...node.attrs, isActive, isTitle }) 174 | hasChanges = true 175 | } 176 | 177 | paragraphIndex++ 178 | } 179 | }) 180 | 181 | if (hasChanges) { 182 | editor.view.dispatch(tr) 183 | } 184 | } catch (error) { 185 | // Ignore errors during initialization 186 | } 187 | }, [editor, activeParagraph]) 188 | 189 | // Focus management - separate from styling updates 190 | useEffect(() => { 191 | if (!editor || !editor.isEditable || !activeParagraph) return 192 | 193 | // Only auto-focus when programmatically selecting (e.g., from sidebar) 194 | // Don't interfere with direct user clicks in the editor 195 | const isUserClick = editor.isFocused 196 | 197 | if (!isUserClick) { 198 | // This is likely a programmatic selection (from sidebar), so we can focus 199 | editor.state.doc.descendants((node, pos) => { 200 | if (node.type.name === 'paragraphWithId' && node.attrs.id === activeParagraph) { 201 | editor.commands.focus(pos + 1) 202 | return false 203 | } 204 | }) 205 | } 206 | }, [editor, activeParagraph]) 207 | 208 | return ( 209 |
210 |
211 | 345 | 346 |
347 |
348 | ) 349 | }) 350 | 351 | // Only re-render when key props change 352 | TipTapEditor.displayName = 'TipTapEditor' 353 | 354 | function generateInitialContent(paragraphs: Paragraph[]) { 355 | if (paragraphs.length === 0) { 356 | return { 357 | type: 'doc', 358 | content: [ 359 | { 360 | type: 'paragraphWithId', 361 | attrs: { id: '1', isTitle: true }, 362 | content: [], 363 | }, 364 | ], 365 | } 366 | } 367 | 368 | return { 369 | type: 'doc', 370 | content: paragraphs.map((paragraph, index) => ({ 371 | type: 'paragraphWithId', 372 | attrs: { 373 | id: paragraph.id, 374 | isTitle: index === 0 375 | }, 376 | content: paragraph.content ? [ 377 | { 378 | type: 'text', 379 | text: paragraph.content, 380 | }, 381 | ] : [], 382 | })), 383 | } 384 | } --------------------------------------------------------------------------------