mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
feat: add AI Agent chat integration with Anthropic Claude API
Add AI writing assistant (Agent) powered by Anthropic Claude API. Agent can be enabled on Write page (replaces textarea) and optionally in RightSidebar on posts/pages via frontmatter. Features: - AIChatView component with per-page chat history - Page content context support for AI responses - Markdown rendering for AI responses - User-friendly error handling for missing API keys - System prompt configurable via Convex environment variables - Anonymous session authentication using localStorage Environment variables required: - ANTHROPIC_API_KEY (required) - CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES (optional split prompts) - CLAUDE_SYSTEM_PROMPT (optional single prompt fallback) Configuration: - siteConfig.aiChat.enabledOnWritePage: Enable Agent toggle on /write page - siteConfig.aiChat.enabledOnContent: Allow Agent on posts/pages via frontmatter - Frontmatter aiChat: true (requires rightSidebar: true) Updated files: - src/components/AIChatView.tsx: AI chat interface component - src/components/RightSidebar.tsx: Conditional Agent rendering - src/pages/Write.tsx: Agent mode toggle (title changes to Agent) - convex/aiChats.ts: Chat history queries and mutations - convex/aiChatActions.ts: Claude API integration with error handling - convex/schema.ts: aiChats table with indexes - src/config/siteConfig.ts: AIChatConfig interface - Documentation updated across all files Documentation: - files.md: Updated component descriptions - changelog.md: Added v1.33.0 entry - TASK.md: Marked AI chat tasks as completed - README.md: Added AI Agent Chat section - content/pages/docs.md: Added AI Agent chat documentation - content/blog/setup-guide.md: Added AI Agent chat setup instructions - public/raw/changelog.md: Added v1.33.0 entry
This commit is contained in:
269
.cursor/plans/ai_chat_write_agent_9b3e5be2.plan.md
Normal file
269
.cursor/plans/ai_chat_write_agent_9b3e5be2.plan.md
Normal file
@@ -0,0 +1,269 @@
|
||||
---
|
||||
name: AI Chat Write Agent
|
||||
overview: Implement an AI-powered chat write agent that integrates with the Write page (replacing textarea when active) and optionally appears in the RightSidebar on posts/pages. Uses Anthropic Claude API with anonymous sessions, per-page chat history stored in Convex, and optional page content loading.
|
||||
todos:
|
||||
- id: schema
|
||||
content: Add aiChats table to convex/schema.ts with indexes
|
||||
status: completed
|
||||
- id: backend-mutations
|
||||
content: Create convex/aiChats.ts with queries and mutations
|
||||
status: completed
|
||||
dependencies:
|
||||
- schema
|
||||
- id: backend-action
|
||||
content: Create convex/aiChatActions.ts with Claude API integration
|
||||
status: completed
|
||||
dependencies:
|
||||
- backend-mutations
|
||||
- id: siteconfig
|
||||
content: Add AIChatConfig interface and defaults to siteConfig.ts
|
||||
status: completed
|
||||
- id: chat-component
|
||||
content: Create AIChatView.tsx component with full chat UI
|
||||
status: completed
|
||||
dependencies:
|
||||
- backend-mutations
|
||||
- backend-action
|
||||
- id: chat-styles
|
||||
content: Add AI chat CSS styles to global.css (theme-aware)
|
||||
status: completed
|
||||
dependencies:
|
||||
- chat-component
|
||||
- id: right-sidebar
|
||||
content: Update RightSidebar.tsx to conditionally render AIChatView
|
||||
status: completed
|
||||
dependencies:
|
||||
- chat-component
|
||||
- siteconfig
|
||||
- id: write-page
|
||||
content: Update Write.tsx with AI chat mode toggle
|
||||
status: completed
|
||||
dependencies:
|
||||
- chat-component
|
||||
- siteconfig
|
||||
- id: post-page
|
||||
content: Update Post.tsx to pass content context to RightSidebar
|
||||
status: completed
|
||||
dependencies:
|
||||
- right-sidebar
|
||||
- id: frontmatter
|
||||
content: Add aiChat field to sync-posts.ts and Write.tsx field definitions
|
||||
status: completed
|
||||
- id: dependencies
|
||||
content: Add @anthropic-ai/sdk to package.json
|
||||
status: completed
|
||||
---
|
||||
|
||||
# AI Chat Write Agent Implementation
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph frontend [Frontend Components]
|
||||
AIChatView[AIChatView Component]
|
||||
WritePage[Write.tsx]
|
||||
RightSidebar[RightSidebar.tsx]
|
||||
PostPage[Post.tsx]
|
||||
end
|
||||
|
||||
subgraph convex [Convex Backend]
|
||||
aiChats[aiChats.ts - Queries/Mutations]
|
||||
aiChatActions[aiChatActions.ts - Claude API Action]
|
||||
schema[schema.ts - aiChats table]
|
||||
end
|
||||
|
||||
subgraph external [External Services]
|
||||
Claude[Anthropic Claude API]
|
||||
end
|
||||
|
||||
WritePage -->|mode toggle| AIChatView
|
||||
RightSidebar -->|when aiChat enabled| AIChatView
|
||||
PostPage -->|passes content context| RightSidebar
|
||||
AIChatView -->|mutations| aiChats
|
||||
AIChatView -->|action| aiChatActions
|
||||
aiChatActions -->|API call| Claude
|
||||
aiChats -->|read/write| schema
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- **Anonymous Sessions**: Uses localStorage `sessionId` (UUID) until auth is added
|
||||
- **Chat Scope**: Per-page using slug as context identifier (e.g., "write-page", "about", "my-post-slug")
|
||||
- **Page Context**: Optional button to load current page's markdown into chat context
|
||||
- **Mode Toggle**: Write page switches between textarea and chat interface
|
||||
- **Configuration**: Separate siteConfig toggles for Write page and RightSidebar
|
||||
|
||||
---
|
||||
|
||||
## 1. Database Schema Updates
|
||||
|
||||
Update [`convex/schema.ts`](convex/schema.ts) to add the `aiChats` table:
|
||||
|
||||
```typescript
|
||||
aiChats: defineTable({
|
||||
sessionId: v.string(),
|
||||
contextId: v.string(), // slug or "write-page"
|
||||
messages: v.array(
|
||||
v.object({
|
||||
role: v.union(v.literal("user"), v.literal("assistant")),
|
||||
content: v.string(),
|
||||
timestamp: v.number(),
|
||||
}),
|
||||
),
|
||||
pageContext: v.optional(v.string()), // loaded page content
|
||||
lastMessageAt: v.optional(v.number()),
|
||||
})
|
||||
.index("by_session_and_context", ["sessionId", "contextId"])
|
||||
.index("by_session", ["sessionId"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Convex Backend
|
||||
|
||||
### Create [`convex/aiChats.ts`](convex/aiChats.ts)
|
||||
|
||||
Queries and mutations for chat management:
|
||||
|
||||
- `getAIChatByContext` - Fetch chat for sessionId + contextId
|
||||
- `getOrCreateAIChat` - Create chat if none exists
|
||||
- `addUserMessage` - Add user message
|
||||
- `addAssistantMessage` - Internal mutation for AI response
|
||||
- `clearChat` - Clear messages
|
||||
- `setPageContext` - Store loaded page content
|
||||
|
||||
### Create [`convex/aiChatActions.ts`](convex/aiChatActions.ts)
|
||||
|
||||
Node.js action for Claude API:
|
||||
|
||||
- `generateResponse` - Calls Claude API with conversation history
|
||||
- Uses `ANTHROPIC_API_KEY` environment variable
|
||||
- Loads system prompt from `CLAUDE_SYSTEM_PROMPT` or split `CLAUDE_PROMPT_*` variables
|
||||
- Model: `claude-sonnet-4-20250514` with 2048 max tokens
|
||||
- Includes last 20 messages as context
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend Components
|
||||
|
||||
### Create [`src/components/AIChatView.tsx`](src/components/AIChatView.tsx)
|
||||
|
||||
Main chat component with:
|
||||
|
||||
- Message list with markdown rendering (react-markdown)
|
||||
- Auto-expanding textarea input
|
||||
- Send button and keyboard shortcuts (Enter to send, Shift+Enter newline)
|
||||
- Loading state with stop generation button
|
||||
- Clear chat command ("clear")
|
||||
- Copy message button on AI responses
|
||||
- "Load Page Content" button (when page content available)
|
||||
- Theme-aware styling matching existing UI
|
||||
|
||||
### Update [`src/components/RightSidebar.tsx`](src/components/RightSidebar.tsx)
|
||||
|
||||
Transform from empty placeholder to conditional chat container:
|
||||
|
||||
- Accept `aiChatEnabled`, `pageContent`, and `slug` props
|
||||
- Render `AIChatView` when enabled
|
||||
- Pass page context for "Load Content" feature
|
||||
|
||||
---
|
||||
|
||||
## 4. Page Updates
|
||||
|
||||
### Update [`src/pages/Write.tsx`](src/pages/Write.tsx)
|
||||
|
||||
Add AI chat mode toggle:
|
||||
|
||||
- New state: `isAIChatMode` (boolean)
|
||||
- Add "AI Chat" button in Actions section (using `ChatCircle` from Phosphor Icons)
|
||||
- When active: replace `<textarea>` with `<AIChatView contextId="write-page" />`
|
||||
- Show "Back to Editor" button to switch back
|
||||
- Respect `siteConfig.aiChat.enabledOnWritePage` setting
|
||||
|
||||
### Update [`src/pages/Post.tsx`](src/pages/Post.tsx)
|
||||
|
||||
Pass content to RightSidebar:
|
||||
|
||||
- Check `siteConfig.aiChat.enabledOnContent` AND frontmatter `aiChat: true`
|
||||
- Pass `pageContent={content}` and `slug` to RightSidebar
|
||||
- Add new frontmatter field `aiChat` to field definitions
|
||||
|
||||
---
|
||||
|
||||
## 5. Configuration
|
||||
|
||||
### Update [`src/config/siteConfig.ts`](src/config/siteConfig.ts)
|
||||
|
||||
Add AI chat configuration interface and defaults:
|
||||
|
||||
```typescript
|
||||
export interface AIChatConfig {
|
||||
enabledOnWritePage: boolean; // Show AI chat on /write
|
||||
enabledOnContent: boolean; // Allow AI chat on posts/pages via frontmatter
|
||||
}
|
||||
|
||||
// In SiteConfig interface:
|
||||
aiChat: AIChatConfig;
|
||||
|
||||
// Default values:
|
||||
aiChat: {
|
||||
enabledOnWritePage: true,
|
||||
enabledOnContent: true,
|
||||
},
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Update Frontmatter Fields
|
||||
|
||||
Add `aiChat` field to sync scripts and field definitions:
|
||||
|
||||
- [`scripts/sync-posts.ts`](scripts/sync-posts.ts) - Add to PostFrontmatter and PageFrontmatter
|
||||
- [`src/pages/Write.tsx`](src/pages/Write.tsx) - Add to POST_FIELDS and PAGE_FIELDS
|
||||
|
||||
---
|
||||
|
||||
## 6. Styling
|
||||
|
||||
### Update [`src/styles/global.css`](src/styles/global.css)
|
||||
|
||||
Add AI chat styles (approximately 300-400 lines):
|
||||
|
||||
- `.ai-chat-view` - Main container
|
||||
- `.ai-chat-messages` - Scrollable message list
|
||||
- `.ai-chat-message`, `.ai-chat-message-user`, `.ai-chat-message-assistant`
|
||||
- `.ai-chat-input-container`, `.ai-chat-input`, `.ai-chat-send-button`
|
||||
- `.ai-chat-loading`, `.ai-chat-stop-button`
|
||||
- `.ai-chat-copy-button`, `.ai-chat-clear-button`
|
||||
- `.ai-chat-load-context-button`
|
||||
- Theme variants for dark, light, tan, cloud
|
||||
- Mobile responsive styles
|
||||
|
||||
---
|
||||
|
||||
## 7. Dependencies
|
||||
|
||||
### Update [`package.json`](package.json)
|
||||
|
||||
Add required packages:
|
||||
|
||||
```json
|
||||
"@anthropic-ai/sdk": "^0.71.2"
|
||||
```
|
||||
|
||||
Note: `react-markdown` and `remark-gfm` already exist in the project.---
|
||||
|
||||
## 8. Environment Variables
|
||||
|
||||
Add to Convex deployment:
|
||||
|
||||
- `ANTHROPIC_API_KEY` (required) - Claude API key
|
||||
- `CLAUDE_SYSTEM_PROMPT` (optional) - Custom system prompt for writing assistant
|
||||
|
||||
---
|
||||
|
||||
## File Summary
|
||||
301
.cursor/plans/custom_homepage_configuration_83419476.plan.md
Normal file
301
.cursor/plans/custom_homepage_configuration_83419476.plan.md
Normal file
@@ -0,0 +1,301 @@
|
||||
---
|
||||
name: Custom Homepage Configuration
|
||||
overview: Add configuration to set any page or blog post as the homepage, with all Post component features (sidebar, copy dropdown, etc.) but without the featured section. Original homepage remains accessible at /home.
|
||||
todos: []
|
||||
---
|
||||
|
||||
# Cus
|
||||
|
||||
tom Homepage ConfigurationAllow configuring any page or blog post to serve as the homepage while preserving all Post component features (sidebar, copy dropdown, author info, etc.) and using the page/post's metadata for SEO.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User visits /] --> B{homepage.type?}
|
||||
B -->|default| C[Home Component]
|
||||
B -->|page| D[Post Component with page slug]
|
||||
B -->|post| E[Post Component with post slug]
|
||||
|
||||
D --> F[Render page content]
|
||||
E --> G[Render post content]
|
||||
|
||||
F --> H[No featured section]
|
||||
G --> H
|
||||
H --> I[All Post features: sidebar, copy dropdown, etc.]
|
||||
|
||||
J[User visits /home] --> C
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Update `src/config/siteConfig.ts`
|
||||
|
||||
Add homepage configuration interface and default:
|
||||
|
||||
```typescript
|
||||
// Add to SiteConfig interface
|
||||
export interface HomepageConfig {
|
||||
type: "default" | "page" | "post";
|
||||
slug?: string; // Required if type is "page" or "post"
|
||||
originalHomeRoute?: string; // Route to access original homepage (default: "/home")
|
||||
}
|
||||
|
||||
export interface SiteConfig {
|
||||
// ... existing fields ...
|
||||
homepage: HomepageConfig;
|
||||
}
|
||||
|
||||
// Add to siteConfig object
|
||||
export const siteConfig: SiteConfig = {
|
||||
// ... existing config ...
|
||||
homepage: {
|
||||
type: "default", // Options: "default", "page", "post"
|
||||
slug: undefined, // e.g., "about" or "welcome-post"
|
||||
originalHomeRoute: "/home", // Route to access original homepage
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 2. Update `src/App.tsx`
|
||||
|
||||
Modify routing to conditionally render homepage:
|
||||
|
||||
```typescript
|
||||
function App() {
|
||||
usePageTracking();
|
||||
const location = useLocation();
|
||||
|
||||
if (location.pathname === "/write") {
|
||||
return <Write />;
|
||||
}
|
||||
|
||||
// Determine if we should use a custom homepage
|
||||
const useCustomHomepage =
|
||||
siteConfig.homepage.type !== "default" &&
|
||||
siteConfig.homepage.slug;
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
{/* Homepage route - either default Home or custom page/post */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
useCustomHomepage ? (
|
||||
<Post
|
||||
slug={siteConfig.homepage.slug!}
|
||||
isHomepage={true}
|
||||
homepageType={siteConfig.homepage.type}
|
||||
/>
|
||||
) : (
|
||||
<Home />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Original homepage route (when custom homepage is set) */}
|
||||
{useCustomHomepage && (
|
||||
<Route
|
||||
path={siteConfig.homepage.originalHomeRoute || "/home"}
|
||||
element={<Home />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ... rest of routes ... */}
|
||||
</Routes>
|
||||
</Layout>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 3. Update `src/pages/Post.tsx`
|
||||
|
||||
Add props to support homepage mode and hide back button:
|
||||
|
||||
```typescript
|
||||
interface PostProps {
|
||||
slug?: string; // Optional slug prop when used as homepage
|
||||
isHomepage?: boolean; // Flag to indicate this is the homepage
|
||||
homepageType?: "page" | "post"; // Type of homepage content
|
||||
}
|
||||
|
||||
export default function Post({
|
||||
slug: propSlug,
|
||||
isHomepage = false,
|
||||
homepageType
|
||||
}: PostProps = {}) {
|
||||
const { slug: routeSlug } = useParams<{ slug: string }>();
|
||||
const slug = propSlug || routeSlug;
|
||||
|
||||
// ... existing queries ...
|
||||
|
||||
// Conditionally hide back button when used as homepage
|
||||
// In the render section:
|
||||
{!isHomepage && (
|
||||
<button onClick={() => navigate("/")} className="back-button">
|
||||
<ArrowLeft size={16} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
// ... rest of component unchanged ...
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 4. Update `scripts/configure-fork.ts`
|
||||
|
||||
Add homepage configuration support:
|
||||
|
||||
```typescript
|
||||
interface ForkConfig {
|
||||
// ... existing fields ...
|
||||
homepage?: {
|
||||
type: "default" | "page" | "post";
|
||||
slug?: string;
|
||||
originalHomeRoute?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Add update function
|
||||
function updateSiteConfig(config: ForkConfig): void {
|
||||
// ... existing updates ...
|
||||
|
||||
if (config.homepage) {
|
||||
const homepageConfig = JSON.stringify(config.homepage, null, 2)
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\n/g, '\n ');
|
||||
|
||||
updateFile(
|
||||
"src/config/siteConfig.ts",
|
||||
[
|
||||
{
|
||||
search: /homepage:\s*\{[^}]*\},/s,
|
||||
replace: `homepage: ${homepageConfig},`,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 5. Update `FORK_CONFIG.md`
|
||||
|
||||
Add homepage configuration section:
|
||||
|
||||
````markdown
|
||||
## Homepage Configuration
|
||||
|
||||
You can set any page or blog post to serve as your homepage.
|
||||
|
||||
### In fork-config.json
|
||||
|
||||
```json
|
||||
{
|
||||
"homepage": {
|
||||
"type": "page",
|
||||
"slug": "about",
|
||||
"originalHomeRoute": "/home"
|
||||
}
|
||||
}
|
||||
````
|
||||
|
||||
Options:
|
||||
|
||||
- `type`: `"default"` (standard homepage), `"page"` (use a static page), or `"post"` (use a blog post)
|
||||
- `slug`: The slug of the page or post to use (required if type is "page" or "post")
|
||||
- `originalHomeRoute`: Route to access the original homepage (default: "/home")
|
||||
|
||||
### Manual Configuration
|
||||
|
||||
In `src/config/siteConfig.ts`:
|
||||
|
||||
```typescript
|
||||
homepage: {
|
||||
type: "page", // or "post" or "default"
|
||||
slug: "about", // slug of page/post to use
|
||||
originalHomeRoute: "/home", // route to original homepage
|
||||
},
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Notes
|
||||
|
||||
- Custom homepage uses the page/post's full content and features (sidebar, copy dropdown, etc.)
|
||||
- Featured section is NOT shown on custom homepage
|
||||
- SEO metadata comes from the page/post's frontmatter
|
||||
- Original homepage remains accessible at `/home` when custom homepage is set
|
||||
````javascript
|
||||
|
||||
### 6. Update `fork-config.json.example`
|
||||
|
||||
Add homepage configuration example:
|
||||
|
||||
```json
|
||||
{
|
||||
"homepage": {
|
||||
"type": "default",
|
||||
"slug": null,
|
||||
"originalHomeRoute": "/home"
|
||||
}
|
||||
}
|
||||
````
|
||||
|
||||
|
||||
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `src/config/siteConfig.ts` - Add HomepageConfig interface and default config
|
||||
2. `src/App.tsx` - Conditional homepage routing
|
||||
3. `src/pages/Post.tsx` - Add props for homepage mode, hide back button
|
||||
4. `scripts/configure-fork.ts` - Add homepage config parsing and file updates
|
||||
5. `FORK_CONFIG.md` - Document homepage configuration
|
||||
6. `fork-config.json.example` - Add homepage config example
|
||||
|
||||
## Behavior Details
|
||||
|
||||
### Custom Homepage (page/post)
|
||||
|
||||
- Renders using Post component with all features
|
||||
- Shows page/post content with markdown rendering
|
||||
- Includes sidebar if `layout: "sidebar"` in frontmatter
|
||||
- Includes copy dropdown, author info, tags (for posts)
|
||||
- Does NOT show back button
|
||||
- Does NOT show featured section
|
||||
- Uses page/post metadata for SEO (title, description, OG image)
|
||||
|
||||
### Original Homepage
|
||||
|
||||
- Remains accessible at `/home` (or configured route)
|
||||
- Shows all standard features (featured section, posts list, etc.)
|
||||
- Unchanged functionality
|
||||
|
||||
### Default Behavior
|
||||
|
||||
- When `homepage.type === "default"`, standard Home component renders at `/`
|
||||
- No `/home` route is created
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Default homepage works at `/`
|
||||
- [ ] Custom page homepage renders at `/` with all Post features
|
||||
- [ ] Custom post homepage renders at `/` with all Post features
|
||||
- [ ] Back button hidden on custom homepage
|
||||
- [ ] Featured section not shown on custom homepage
|
||||
- [ ] Original homepage accessible at `/home` when custom homepage set
|
||||
- [ ] SEO metadata uses page/post frontmatter
|
||||
- [ ] Sidebar works on custom homepage if layout is "sidebar"
|
||||
@@ -22,7 +22,7 @@ Your content is instantly available to browsers, LLMs, and AI agents.. Write mar
|
||||
- **Total Posts**: 12
|
||||
- **Total Pages**: 4
|
||||
- **Latest Post**: 2025-12-25
|
||||
- **Last Updated**: 2025-12-25T20:18:52.316Z
|
||||
- **Last Updated**: 2025-12-26T20:30:35.290Z
|
||||
|
||||
## Tech stack
|
||||
|
||||
|
||||
@@ -153,6 +153,14 @@ export const siteConfig: SiteConfig = {
|
||||
showOnBlogPage: true,
|
||||
},
|
||||
|
||||
// Homepage configuration
|
||||
// Set any page or blog post to serve as the homepage
|
||||
homepage: {
|
||||
type: "default", // Options: "default" (standard Home component), "page" (use a static page), or "post" (use a blog post)
|
||||
slug: undefined, // Required if type is "page" or "post" - the slug of the page/post to use
|
||||
originalHomeRoute: "/home", // Route to access the original homepage when custom homepage is set
|
||||
},
|
||||
|
||||
links: {
|
||||
docs: "/setup-guide",
|
||||
convex: "https://convex.dev",
|
||||
@@ -400,6 +408,79 @@ const DEFAULT_THEME: Theme = "tan"; // Options: dark, light, tan, cloud
|
||||
|
||||
---
|
||||
|
||||
## Homepage Configuration
|
||||
|
||||
You can set any page or blog post to serve as your homepage instead of the default Home component.
|
||||
|
||||
### In fork-config.json
|
||||
|
||||
```json
|
||||
{
|
||||
"homepage": {
|
||||
"type": "page",
|
||||
"slug": "about",
|
||||
"originalHomeRoute": "/home"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Manual Configuration
|
||||
|
||||
In `src/config/siteConfig.ts`:
|
||||
|
||||
```typescript
|
||||
homepage: {
|
||||
type: "page", // Options: "default", "page", or "post"
|
||||
slug: "about", // Required if type is "page" or "post" - the slug of the page/post to use
|
||||
originalHomeRoute: "/home", // Route to access the original homepage when custom homepage is set
|
||||
},
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
- `type`: `"default"` (standard Home component), `"page"` (use a static page), or `"post"` (use a blog post)
|
||||
- `slug`: The slug of the page or post to use (required if type is "page" or "post")
|
||||
- `originalHomeRoute`: Route to access the original homepage (default: "/home")
|
||||
|
||||
### Behavior
|
||||
|
||||
- Custom homepage uses the page/post's full content and features (sidebar, copy dropdown, footer, etc.)
|
||||
- Featured section is NOT shown on custom homepage (only on default Home component)
|
||||
- SEO metadata comes from the page/post's frontmatter
|
||||
- Original homepage remains accessible at `/home` (or configured route) when custom homepage is set
|
||||
- Back button is hidden when a page/post is used as the homepage
|
||||
|
||||
### Examples
|
||||
|
||||
**Use a static page as homepage:**
|
||||
```typescript
|
||||
homepage: {
|
||||
type: "page",
|
||||
slug: "about",
|
||||
originalHomeRoute: "/home",
|
||||
},
|
||||
```
|
||||
|
||||
**Use a blog post as homepage:**
|
||||
```typescript
|
||||
homepage: {
|
||||
type: "post",
|
||||
slug: "welcome-post",
|
||||
originalHomeRoute: "/home",
|
||||
},
|
||||
```
|
||||
|
||||
**Switch back to default homepage:**
|
||||
```typescript
|
||||
homepage: {
|
||||
type: "default",
|
||||
slug: undefined,
|
||||
originalHomeRoute: "/home",
|
||||
},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI Agent Prompt
|
||||
|
||||
Copy this prompt to have an AI agent apply all changes:
|
||||
|
||||
27
README.md
27
README.md
@@ -82,6 +82,7 @@ Follow the step-by-step guide in `FORK_CONFIG.md` to update each file manually.
|
||||
- Static raw markdown files at `/raw/{slug}.md`
|
||||
- Dedicated blog page with configurable navigation order
|
||||
- Markdown writing page at `/write` with frontmatter reference
|
||||
- AI Agent chat (powered by Anthropic Claude) on Write page and optionally in right sidebar
|
||||
|
||||
### SEO and Discovery
|
||||
|
||||
@@ -460,6 +461,32 @@ A public markdown writing page at `/write` (not linked in navigation).
|
||||
|
||||
Access directly at `yourdomain.com/write`. Content is stored in localStorage only (not synced to database). Use it to draft posts, then copy the content to a markdown file in `content/blog/` or `content/pages/` and run `npm run sync`.
|
||||
|
||||
**AI Agent mode:** When `siteConfig.aiChat.enabledOnWritePage` is enabled, a toggle button appears in the Actions section. Clicking it replaces the textarea with the AI Agent chat interface. The page title changes to "Agent" when in chat mode. Requires `ANTHROPIC_API_KEY` environment variable in Convex.
|
||||
|
||||
## AI Agent Chat
|
||||
|
||||
The site includes an AI writing assistant (Agent) powered by Anthropic Claude API. Agent can be enabled in two places:
|
||||
|
||||
**1. Write page (`/write`):** Enable via `siteConfig.aiChat.enabledOnWritePage`. Toggle replaces textarea with Agent chat interface.
|
||||
|
||||
**2. Right sidebar on posts/pages:** Enable via `aiChat: true` frontmatter field (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`).
|
||||
|
||||
**Environment variables required:**
|
||||
|
||||
- `ANTHROPIC_API_KEY` (required): Your Anthropic API key
|
||||
- `CLAUDE_PROMPT_STYLE`, `CLAUDE_PROMPT_COMMUNITY`, `CLAUDE_PROMPT_RULES` (optional): Split system prompts
|
||||
- `CLAUDE_SYSTEM_PROMPT` (optional): Single system prompt fallback
|
||||
|
||||
Set these in [Convex Dashboard](https://dashboard.convex.dev) > Settings > Environment Variables.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Per-page chat history stored in Convex
|
||||
- Page content can be provided as context for AI responses
|
||||
- Markdown rendering for AI responses
|
||||
- User-friendly error messages when API key is not configured
|
||||
- Anonymous session authentication using localStorage
|
||||
|
||||
## Source
|
||||
|
||||
Fork this project: [github.com/waynesutton/markdown-site](https://github.com/waynesutton/markdown-site)
|
||||
|
||||
44
TASK.md
44
TASK.md
@@ -8,10 +8,52 @@
|
||||
|
||||
## Current Status
|
||||
|
||||
v1.31.1 ready. Footer component now supports images with size control via HTML attributes.
|
||||
v1.33.0 ready. AI Chat Write Agent (Agent) integration complete with Anthropic Claude API support. Available on Write page and optionally in RightSidebar on posts/pages.
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] AI Chat Write Agent (Agent) integration
|
||||
- [x] AIChatView component created with Anthropic Claude API integration
|
||||
- [x] Write page AI Agent mode toggle (replaces textarea when active)
|
||||
- [x] RightSidebar AI chat support via frontmatter aiChat: true field
|
||||
- [x] Per-session, per-context chat history stored in Convex (aiChats table)
|
||||
- [x] Page content context support for AI responses
|
||||
- [x] Markdown rendering for AI responses with copy functionality
|
||||
- [x] Error handling for missing API keys with user-friendly messages
|
||||
- [x] System prompt configurable via Convex environment variables
|
||||
- [x] Anonymous session authentication using localStorage session ID
|
||||
- [x] Chat history limited to last 20 messages for context efficiency
|
||||
- [x] Title changes to "Agent" when in AI chat mode on Write page
|
||||
- [x] Toggle button text changes between "Agent" and "Text Editor"
|
||||
- [x] SiteConfig.aiChat configuration with enabledOnWritePage and enabledOnContent flags
|
||||
- [x] Schema updated with aiChats table and aiChat fields on posts/pages tables
|
||||
- [x] sync-posts.ts updated to handle aiChat frontmatter field
|
||||
- [x] Documentation updated across all files
|
||||
|
||||
- [x] Fixed AI chat scroll prevention in Write page
|
||||
- [x] Added viewport height constraints (100vh) to write-layout to prevent page-level scrolling
|
||||
- [x] Updated write-main with max-height: 100vh and overflow: hidden when AI chat is active
|
||||
- [x] Added min-height: 0 to flex children (write-ai-chat-container, ai-chat-view, ai-chat-messages) for proper flex behavior
|
||||
- [x] Input container fixed at bottom with flex-shrink: 0
|
||||
- [x] Sidebars (left and right) scroll internally with overflow-y: auto
|
||||
- [x] Delayed focus in AIChatView (100ms setTimeout) to prevent scroll jump on mount
|
||||
- [x] Added preventScroll: true to all focus() calls in AIChatView
|
||||
- [x] Toggle button preserves scroll position using requestAnimationFrame
|
||||
- [x] useEffect scrolls to top when switching to AI chat mode
|
||||
- [x] Messages area scrolls internally while input stays fixed at bottom (ChatGPT-style behavior)
|
||||
|
||||
- [x] Custom homepage configuration feature
|
||||
- [x] Added HomepageConfig interface to siteConfig.ts
|
||||
- [x] Updated App.tsx to conditionally render homepage based on config
|
||||
- [x] Updated Post.tsx to accept optional props for homepage mode (slug, isHomepage, homepageType)
|
||||
- [x] Back button hidden when Post component is used as homepage
|
||||
- [x] Original homepage route accessible at /home when custom homepage is set
|
||||
- [x] SEO metadata uses page/post frontmatter when used as homepage
|
||||
- [x] Updated configure-fork.ts to support homepage configuration
|
||||
- [x] Updated FORK_CONFIG.md with homepage documentation
|
||||
- [x] Updated fork-config.json.example with homepage option
|
||||
- [x] All existing features (sidebar, footer, right sidebar) work correctly with custom homepage
|
||||
|
||||
- [x] Image support in footer component with size control
|
||||
- [x] Footer sanitize schema updated to allow width, height, style, class attributes on images
|
||||
- [x] Footer image component handler updated to pass through size attributes
|
||||
|
||||
76
changelog.md
76
changelog.md
@@ -4,6 +4,82 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [1.33.0] - 2025-12-26
|
||||
|
||||
### Added
|
||||
|
||||
- AI Chat Write Agent integration with Anthropic Claude
|
||||
- New `AIChatView` component (`src/components/AIChatView.tsx`) for AI-powered chat interface
|
||||
- AI chat can be toggled on Write page via siteConfig.aiChat.enabledOnWritePage
|
||||
- AI chat can appear in RightSidebar on posts/pages via frontmatter `aiChat: true` field
|
||||
- Per-session, per-context chat history stored in Convex (aiChats table)
|
||||
- Supports page content as context for AI responses
|
||||
- Markdown rendering for AI responses with copy functionality
|
||||
- Theme-aware styling that matches the site's design system
|
||||
- Uses Phosphor Icons for all UI elements
|
||||
|
||||
- Convex backend for AI chat
|
||||
- New `convex/aiChats.ts` with queries and mutations for chat history
|
||||
- New `convex/aiChatActions.ts` with Claude API integration (requires ANTHROPIC_API_KEY environment variable)
|
||||
- System prompt configurable via Convex environment variables:
|
||||
- `CLAUDE_PROMPT_STYLE`, `CLAUDE_PROMPT_COMMUNITY`, `CLAUDE_PROMPT_RULES` (split prompts, joined with separators)
|
||||
- `CLAUDE_SYSTEM_PROMPT` (single prompt, fallback if split prompts not set)
|
||||
- Chat history limited to last 20 messages for context efficiency
|
||||
- Error handling: displays "API key is not set" message when ANTHROPIC_API_KEY is missing in Convex environment variables
|
||||
|
||||
- New configuration options
|
||||
- `siteConfig.aiChat` interface with `enabledOnWritePage` and `enabledOnContent` boolean flags
|
||||
- Both flags default to false (opt-in feature)
|
||||
- New `aiChat` frontmatter field for posts and pages (requires rightSidebar: true)
|
||||
|
||||
### Changed
|
||||
|
||||
- Write page now supports AI Agent mode toggle (replaces textarea when active)
|
||||
- Title changes from "Blog Post" or "Page" to "Agent" when in AI chat mode
|
||||
- Toggle button text changes between "Agent" and "Text Editor"
|
||||
- Page scroll prevention when switching modes (no page jump)
|
||||
- RightSidebar component updated to conditionally render AIChatView
|
||||
- Post.tsx passes pageContent and slug to RightSidebar for AI context
|
||||
- Schema updated with aiChats table and aiChat fields on posts/pages tables
|
||||
- sync-posts.ts updated to handle aiChat frontmatter field
|
||||
- AIChatView displays user-friendly error messages when API key is not configured
|
||||
|
||||
### Technical
|
||||
|
||||
- Added `@anthropic-ai/sdk` dependency for Claude API integration
|
||||
- Anonymous session authentication using localStorage session ID
|
||||
- AI chat CSS styles in global.css with theme variable support
|
||||
- New convex schema: aiChats table with indexes (by_sessionId_contextId, by_contextId)
|
||||
|
||||
## [1.32.0] - 2025-12-25
|
||||
|
||||
### Added
|
||||
|
||||
- Custom homepage configuration
|
||||
- Set any page or blog post to serve as the homepage instead of the default Home component
|
||||
- Configure via `siteConfig.homepage` with `type` ("default", "page", or "post"), `slug` (required for page/post), and `originalHomeRoute` (default: "/home")
|
||||
- Custom homepage retains all Post component features (sidebar, copy dropdown, author info, footer) but without the featured section
|
||||
- Original homepage remains accessible at `/home` route (or configured `originalHomeRoute`) when custom homepage is set
|
||||
- SEO metadata uses the page/post's frontmatter when used as homepage
|
||||
- Back button hidden when Post component is used as homepage
|
||||
- Fork configuration support for homepage
|
||||
- Added `homepage` field to `fork-config.json.example`
|
||||
- Updated `configure-fork.ts` to handle homepage configuration
|
||||
- Documentation added to `FORK_CONFIG.md` with usage examples
|
||||
|
||||
### Changed
|
||||
|
||||
- `src/App.tsx`: Conditionally renders Home or Post component based on `siteConfig.homepage` configuration
|
||||
- `src/pages/Post.tsx`: Added optional `slug`, `isHomepage`, and `homepageType` props to support homepage mode
|
||||
- `src/config/siteConfig.ts`: Added `HomepageConfig` interface and default homepage configuration
|
||||
|
||||
### Technical
|
||||
|
||||
- New interface: `HomepageConfig` in `src/config/siteConfig.ts`
|
||||
- Conditional routing in `App.tsx` checks `homepage.type` and `homepage.slug` to determine homepage component
|
||||
- Post component accepts optional props for homepage mode (hides back button when `isHomepage` is true)
|
||||
- Original homepage route dynamically added when custom homepage is active
|
||||
|
||||
## [1.31.1] - 2025-12-25
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1046,6 +1046,8 @@ Pages appear automatically in the navigation when published.
|
||||
|
||||
**Right sidebar:** When enabled in `siteConfig.rightSidebar.enabled`, posts and pages can display a right sidebar containing the CopyPageDropdown at 1135px+ viewport width. Add `rightSidebar: true` to frontmatter to enable. Without this field, pages render normally with CopyPageDropdown in the nav bar. When enabled, CopyPageDropdown moves from the navigation bar to the right sidebar on wide screens. The right sidebar is hidden below 1135px, and CopyPageDropdown returns to the nav bar automatically.
|
||||
|
||||
**AI Agent chat:** The site includes an AI writing assistant (Agent) powered by Anthropic Claude API. Enable Agent on the Write page via `siteConfig.aiChat.enabledOnWritePage` or in the right sidebar on posts/pages using `aiChat: true` frontmatter (requires `rightSidebar: true`). Requires `ANTHROPIC_API_KEY` environment variable in Convex. See the [AI Agent chat section](#ai-agent-chat) below for setup instructions.
|
||||
|
||||
### Update SEO Meta Tags
|
||||
|
||||
Edit `index.html` to update:
|
||||
@@ -1293,6 +1295,72 @@ A markdown writing page is available at `/write` (not linked in navigation). Use
|
||||
|
||||
Content is stored in localStorage only and not synced to the database. Refreshing the page preserves your content, but clearing browser data will lose it.
|
||||
|
||||
**AI Agent mode:** When `siteConfig.aiChat.enabledOnWritePage` is enabled, a toggle button appears in the Actions section. Clicking it replaces the textarea with the AI Agent chat interface. The page title changes to "Agent" when in chat mode. Requires `ANTHROPIC_API_KEY` environment variable in Convex. See the [AI Agent chat section](#ai-agent-chat) below for setup instructions.
|
||||
|
||||
## AI Agent chat
|
||||
|
||||
The site includes an AI writing assistant (Agent) powered by Anthropic Claude API. Agent can be enabled in two places:
|
||||
|
||||
**1. Write page (`/write`)**
|
||||
|
||||
Enable Agent mode on the Write page via `siteConfig.aiChat.enabledOnWritePage`. When enabled, a toggle button appears in the Actions section. Clicking it replaces the textarea with the Agent chat interface. The page title changes to "Agent" when in chat mode.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```typescript
|
||||
// src/config/siteConfig.ts
|
||||
aiChat: {
|
||||
enabledOnWritePage: true, // Enable Agent toggle on /write page
|
||||
enabledOnContent: true, // Allow Agent on posts/pages via frontmatter
|
||||
},
|
||||
```
|
||||
|
||||
**2. Right sidebar on posts/pages**
|
||||
|
||||
Enable Agent in the right sidebar on individual posts or pages using the `aiChat` frontmatter field. Requires both `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`.
|
||||
|
||||
**Frontmatter example:**
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "My Post"
|
||||
rightSidebar: true
|
||||
aiChat: true # Enable Agent in right sidebar
|
||||
---
|
||||
```
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
Agent requires the following Convex environment variables:
|
||||
|
||||
- `ANTHROPIC_API_KEY` (required): Your Anthropic API key for Claude API access
|
||||
- `CLAUDE_PROMPT_STYLE` (optional): First part of system prompt
|
||||
- `CLAUDE_PROMPT_COMMUNITY` (optional): Second part of system prompt
|
||||
- `CLAUDE_PROMPT_RULES` (optional): Third part of system prompt
|
||||
- `CLAUDE_SYSTEM_PROMPT` (optional): Single system prompt (fallback if split prompts not set)
|
||||
|
||||
**Setting environment variables:**
|
||||
|
||||
1. Go to [Convex Dashboard](https://dashboard.convex.dev)
|
||||
2. Select your project
|
||||
3. Navigate to Settings > Environment Variables
|
||||
4. Add `ANTHROPIC_API_KEY` with your API key value
|
||||
5. Optionally add system prompt variables (`CLAUDE_PROMPT_STYLE`, etc.)
|
||||
6. Deploy changes
|
||||
|
||||
**How it works:**
|
||||
|
||||
- Agent uses anonymous session IDs stored in localStorage for chat history
|
||||
- Each post/page has its own chat context (identified by slug)
|
||||
- Chat history is stored per-session, per-context in Convex (aiChats table)
|
||||
- Page content can be provided as context for AI responses
|
||||
- Chat history limited to last 20 messages for efficiency
|
||||
- If API key is not set, Agent displays "API key is not set" error message
|
||||
|
||||
**Error handling:**
|
||||
|
||||
If `ANTHROPIC_API_KEY` is not configured in Convex environment variables, Agent displays a user-friendly error message: "API key is not set". This helps identify when the API key is missing in production deployments.
|
||||
|
||||
## Next Steps
|
||||
|
||||
After deploying:
|
||||
|
||||
@@ -9,6 +9,64 @@ layout: "sidebar"
|
||||
All notable changes to this project.
|
||||

|
||||
|
||||
## v1.33.0
|
||||
|
||||
Released December 26, 2025
|
||||
|
||||
**AI Chat Write Agent (Agent) integration**
|
||||
|
||||
- AI Agent chat interface powered by Anthropic Claude API
|
||||
- New `AIChatView` component for AI-powered chat interface
|
||||
- Available on Write page (replaces textarea when enabled) and optionally in RightSidebar on posts/pages
|
||||
- Per-session, per-context chat history stored in Convex (aiChats table)
|
||||
- Supports page content as context for AI responses
|
||||
- Markdown rendering for AI responses with copy functionality
|
||||
- Theme-aware styling matching the site's design system
|
||||
- Uses Phosphor Icons for all UI elements
|
||||
- Convex backend for AI chat
|
||||
- New `convex/aiChats.ts` with queries and mutations for chat history
|
||||
- New `convex/aiChatActions.ts` with Claude API integration (requires ANTHROPIC_API_KEY environment variable)
|
||||
- System prompt configurable via Convex environment variables:
|
||||
- `CLAUDE_PROMPT_STYLE`, `CLAUDE_PROMPT_COMMUNITY`, `CLAUDE_PROMPT_RULES` (split prompts, joined with separators)
|
||||
- `CLAUDE_SYSTEM_PROMPT` (single prompt, fallback if split prompts not set)
|
||||
- Chat history limited to last 20 messages for context efficiency
|
||||
- Error handling: displays "API key is not set" message when ANTHROPIC_API_KEY is missing in Convex environment variables
|
||||
- Configuration options
|
||||
- `siteConfig.aiChat` interface with `enabledOnWritePage` and `enabledOnContent` boolean flags
|
||||
- Both flags default to false (opt-in feature)
|
||||
- New `aiChat` frontmatter field for posts and pages (requires rightSidebar: true)
|
||||
- Write page AI Agent mode
|
||||
- Title changes from "Blog Post" or "Page" to "Agent" when in AI chat mode
|
||||
- Toggle button text changes between "Agent" and "Text Editor"
|
||||
- Page scroll prevention when switching modes (no page jump)
|
||||
- RightSidebar AI chat support
|
||||
- Conditionally renders AIChatView when enabled via frontmatter `aiChat: true` field
|
||||
- Requires both `siteConfig.aiChat.enabledOnContent` and frontmatter `aiChat: true`
|
||||
- Passes page content as context for AI responses
|
||||
|
||||
Updated files: `src/components/AIChatView.tsx`, `src/components/RightSidebar.tsx`, `src/pages/Write.tsx`, `src/pages/Post.tsx`, `src/config/siteConfig.ts`, `convex/schema.ts`, `convex/aiChats.ts`, `convex/aiChatActions.ts`, `convex/posts.ts`, `convex/pages.ts`, `scripts/sync-posts.ts`, `src/styles/global.css`, `package.json`
|
||||
|
||||
Documentation updated: `files.md`, `changelog.md`, `README.md`, `content/blog/setup-guide.md`, `public/raw/docs.md`
|
||||
|
||||
## v1.32.0
|
||||
|
||||
Released December 25, 2025
|
||||
|
||||
**Custom homepage configuration**
|
||||
|
||||
- Set any page or blog post to serve as the homepage instead of the default Home component
|
||||
- Configure via `siteConfig.homepage` with `type` ("default", "page", or "post"), `slug` (required for page/post), and `originalHomeRoute` (default: "/home")
|
||||
- Custom homepage retains all Post component features (sidebar, copy dropdown, author info, footer) but without the featured section
|
||||
- Original homepage remains accessible at `/home` route (or configured `originalHomeRoute`) when custom homepage is set
|
||||
- SEO metadata uses the page/post's frontmatter when used as homepage
|
||||
- Back button hidden when Post component is used as homepage
|
||||
- Fork configuration support for homepage
|
||||
- Added `homepage` field to `fork-config.json.example`
|
||||
- Updated `configure-fork.ts` to handle homepage configuration
|
||||
- Documentation added to `FORK_CONFIG.md` with usage examples
|
||||
|
||||
Updated files: `src/App.tsx`, `src/pages/Post.tsx`, `src/config/siteConfig.ts`, `scripts/configure-fork.ts`, `fork-config.json.example`, `FORK_CONFIG.md`
|
||||
|
||||
## v1.31.1
|
||||
|
||||
Released December 25, 2025
|
||||
|
||||
@@ -5,9 +5,12 @@ published: true
|
||||
order: 0
|
||||
layout: "sidebar"
|
||||
rightSidebar: true
|
||||
aiChat: true
|
||||
footer: true
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
Reference documentation for setting up, customizing, and deploying this markdown framework.
|
||||
|
||||
**How publishing works:** Write posts in markdown, run `npm run sync` for development or `npm run sync:prod` for production, and they appear on your live site immediately. No rebuild or redeploy needed. Convex handles real-time data sync, so connected browsers update automatically.
|
||||
|
||||
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -8,6 +8,8 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as aiChatActions from "../aiChatActions.js";
|
||||
import type * as aiChats from "../aiChats.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as pages from "../pages.js";
|
||||
@@ -23,6 +25,8 @@ import type {
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
aiChatActions: typeof aiChatActions;
|
||||
aiChats: typeof aiChats;
|
||||
crons: typeof crons;
|
||||
http: typeof http;
|
||||
pages: typeof pages;
|
||||
|
||||
307
convex/aiChatActions.ts
Normal file
307
convex/aiChatActions.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
"use node";
|
||||
|
||||
import { v } from "convex/values";
|
||||
import { action } from "./_generated/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import type {
|
||||
ContentBlockParam,
|
||||
TextBlockParam,
|
||||
ImageBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/messages/messages";
|
||||
import FirecrawlApp from "@mendable/firecrawl-js";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
|
||||
// Default system prompt for writing assistant
|
||||
const DEFAULT_SYSTEM_PROMPT = `You are a helpful writing assistant. Help users write clearly and concisely.
|
||||
|
||||
Always apply the rule of one:
|
||||
Focus on one person.
|
||||
Address one specific problem they are facing.
|
||||
Identify the single root cause of that problem.
|
||||
Explain the one thing the solution does differently.
|
||||
End by asking for one clear action.
|
||||
|
||||
Follow these guidelines:
|
||||
Write in a clear and direct style.
|
||||
Avoid jargon and unnecessary complexity.
|
||||
Use short sentences and short paragraphs.
|
||||
Be concise but thorough.
|
||||
Do not use em dashes.
|
||||
Format responses in markdown when appropriate.`;
|
||||
|
||||
/**
|
||||
* Build system prompt from environment variables
|
||||
* Supports split prompts (CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES)
|
||||
* or single prompt (CLAUDE_SYSTEM_PROMPT)
|
||||
*/
|
||||
function buildSystemPrompt(): string {
|
||||
// Try split prompts first
|
||||
const part1 = process.env.CLAUDE_PROMPT_STYLE || "";
|
||||
const part2 = process.env.CLAUDE_PROMPT_COMMUNITY || "";
|
||||
const part3 = process.env.CLAUDE_PROMPT_RULES || "";
|
||||
|
||||
const parts = [part1, part2, part3].filter((p) => p.trim());
|
||||
|
||||
if (parts.length > 0) {
|
||||
return parts.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
// Fall back to single prompt
|
||||
return process.env.CLAUDE_SYSTEM_PROMPT || DEFAULT_SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrape URL content using Firecrawl (optional)
|
||||
*/
|
||||
async function scrapeUrl(url: string): Promise<{
|
||||
content: string;
|
||||
title?: string;
|
||||
} | null> {
|
||||
const apiKey = process.env.FIRECRAWL_API_KEY;
|
||||
if (!apiKey) {
|
||||
return null; // Firecrawl not configured
|
||||
}
|
||||
|
||||
try {
|
||||
const firecrawl = new FirecrawlApp({ apiKey });
|
||||
const result = await firecrawl.scrapeUrl(url, {
|
||||
formats: ["markdown"],
|
||||
});
|
||||
|
||||
if (!result.success || !result.markdown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
content: result.markdown,
|
||||
title: result.metadata?.title,
|
||||
};
|
||||
} catch {
|
||||
return null; // Silently fail if scraping fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI response for a chat
|
||||
* Calls Claude API and saves the response
|
||||
*/
|
||||
export const generateResponse = action({
|
||||
args: {
|
||||
chatId: v.id("aiChats"),
|
||||
userMessage: v.string(),
|
||||
pageContext: v.optional(v.string()),
|
||||
attachments: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
type: v.union(v.literal("image"), v.literal("link")),
|
||||
storageId: v.optional(v.id("_storage")),
|
||||
url: v.optional(v.string()),
|
||||
scrapedContent: v.optional(v.string()),
|
||||
title: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
},
|
||||
returns: v.string(),
|
||||
handler: async (ctx, args) => {
|
||||
// Get API key
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("API key is not set");
|
||||
}
|
||||
|
||||
// Get chat history
|
||||
const chat = await ctx.runQuery(internal.aiChats.getAIChatInternal, {
|
||||
chatId: args.chatId,
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
throw new Error("Chat not found");
|
||||
}
|
||||
|
||||
// Build system prompt with optional page context
|
||||
let systemPrompt = buildSystemPrompt();
|
||||
|
||||
// Add page context if provided
|
||||
const pageContent = args.pageContext || chat.pageContext;
|
||||
if (pageContent) {
|
||||
systemPrompt += `\n\n---\n\nThe user is viewing a page with the following content. Use this as context for your responses:\n\n${pageContent}`;
|
||||
}
|
||||
|
||||
// Process attachments if provided
|
||||
let processedAttachments = args.attachments;
|
||||
if (processedAttachments && processedAttachments.length > 0) {
|
||||
// Scrape link attachments
|
||||
const processed = await Promise.all(
|
||||
processedAttachments.map(async (attachment) => {
|
||||
if (
|
||||
attachment.type === "link" &&
|
||||
attachment.url &&
|
||||
!attachment.scrapedContent
|
||||
) {
|
||||
const scraped = await scrapeUrl(attachment.url);
|
||||
if (scraped) {
|
||||
return {
|
||||
...attachment,
|
||||
scrapedContent: scraped.content,
|
||||
title: scraped.title || attachment.title,
|
||||
};
|
||||
}
|
||||
}
|
||||
return attachment;
|
||||
}),
|
||||
);
|
||||
processedAttachments = processed;
|
||||
}
|
||||
|
||||
// Build messages array from chat history (last 20 messages)
|
||||
const recentMessages = chat.messages.slice(-20);
|
||||
const claudeMessages: Array<{
|
||||
role: "user" | "assistant";
|
||||
content: string | Array<ContentBlockParam>;
|
||||
}> = [];
|
||||
|
||||
// Convert chat messages to Claude format
|
||||
for (const msg of recentMessages) {
|
||||
if (msg.role === "assistant") {
|
||||
claudeMessages.push({
|
||||
role: "assistant",
|
||||
content: msg.content,
|
||||
});
|
||||
} else {
|
||||
// User message with potential attachments
|
||||
const contentParts: Array<TextBlockParam | ImageBlockParam> = [];
|
||||
|
||||
// Add text content
|
||||
if (msg.content) {
|
||||
contentParts.push({
|
||||
type: "text",
|
||||
text: msg.content,
|
||||
});
|
||||
}
|
||||
|
||||
// Add attachments
|
||||
if (msg.attachments) {
|
||||
for (const attachment of msg.attachments) {
|
||||
if (attachment.type === "image" && attachment.storageId) {
|
||||
// Get image URL from storage
|
||||
const imageUrl = await ctx.runQuery(
|
||||
internal.aiChats.getStorageUrlInternal,
|
||||
{ storageId: attachment.storageId },
|
||||
);
|
||||
if (imageUrl) {
|
||||
contentParts.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "url",
|
||||
url: imageUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (attachment.type === "link") {
|
||||
// Add link context as text block
|
||||
let linkText = attachment.url || "";
|
||||
if (attachment.scrapedContent) {
|
||||
linkText += `\n\nContent from ${attachment.url}:\n${attachment.scrapedContent}`;
|
||||
}
|
||||
if (linkText) {
|
||||
contentParts.push({
|
||||
type: "text",
|
||||
text: linkText,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
claudeMessages.push({
|
||||
role: "user",
|
||||
content:
|
||||
contentParts.length === 1 && contentParts[0].type === "text"
|
||||
? contentParts[0].text
|
||||
: contentParts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new user message with attachments
|
||||
const newMessageContent: Array<TextBlockParam | ImageBlockParam> = [];
|
||||
|
||||
if (args.userMessage) {
|
||||
newMessageContent.push({
|
||||
type: "text",
|
||||
text: args.userMessage,
|
||||
});
|
||||
}
|
||||
|
||||
// Process new message attachments
|
||||
if (processedAttachments && processedAttachments.length > 0) {
|
||||
for (const attachment of processedAttachments) {
|
||||
if (attachment.type === "image" && attachment.storageId) {
|
||||
const imageUrl = await ctx.runQuery(
|
||||
internal.aiChats.getStorageUrlInternal,
|
||||
{ storageId: attachment.storageId },
|
||||
);
|
||||
if (imageUrl) {
|
||||
newMessageContent.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "url",
|
||||
url: imageUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (attachment.type === "link") {
|
||||
let linkText = attachment.url || "";
|
||||
if (attachment.scrapedContent) {
|
||||
linkText += `\n\nContent from ${attachment.url}:\n${attachment.scrapedContent}`;
|
||||
}
|
||||
if (linkText) {
|
||||
newMessageContent.push({
|
||||
type: "text",
|
||||
text: linkText,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
claudeMessages.push({
|
||||
role: "user",
|
||||
content:
|
||||
newMessageContent.length === 1 && newMessageContent[0].type === "text"
|
||||
? newMessageContent[0].text
|
||||
: newMessageContent,
|
||||
});
|
||||
|
||||
// Initialize Anthropic client
|
||||
const anthropic = new Anthropic({
|
||||
apiKey,
|
||||
});
|
||||
|
||||
// Call Claude API
|
||||
const response = await anthropic.messages.create({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 2048,
|
||||
system: systemPrompt,
|
||||
messages: claudeMessages,
|
||||
});
|
||||
|
||||
// Extract text content from response
|
||||
const textContent = response.content.find((block) => block.type === "text");
|
||||
if (!textContent || textContent.type !== "text") {
|
||||
throw new Error("No text content in Claude response");
|
||||
}
|
||||
|
||||
const assistantMessage = textContent.text;
|
||||
|
||||
// Save the assistant message to the chat
|
||||
await ctx.runMutation(internal.aiChats.addAssistantMessage, {
|
||||
chatId: args.chatId,
|
||||
content: assistantMessage,
|
||||
});
|
||||
|
||||
return assistantMessage;
|
||||
},
|
||||
});
|
||||
356
convex/aiChats.ts
Normal file
356
convex/aiChats.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import { v } from "convex/values";
|
||||
import {
|
||||
query,
|
||||
mutation,
|
||||
internalQuery,
|
||||
internalMutation,
|
||||
} from "./_generated/server";
|
||||
|
||||
// Message validator for reuse
|
||||
const messageValidator = v.object({
|
||||
role: v.union(v.literal("user"), v.literal("assistant")),
|
||||
content: v.string(),
|
||||
timestamp: v.number(),
|
||||
attachments: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
type: v.union(v.literal("image"), v.literal("link")),
|
||||
storageId: v.optional(v.id("_storage")),
|
||||
url: v.optional(v.string()),
|
||||
scrapedContent: v.optional(v.string()),
|
||||
title: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get storage URL for an image attachment
|
||||
*/
|
||||
export const getStorageUrl = query({
|
||||
args: {
|
||||
storageId: v.id("_storage"),
|
||||
},
|
||||
returns: v.union(v.string(), v.null()),
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.storage.getUrl(args.storageId);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get AI chat by session and context
|
||||
* Returns null if no chat exists
|
||||
*/
|
||||
export const getAIChatByContext = query({
|
||||
args: {
|
||||
sessionId: v.string(),
|
||||
contextId: v.string(),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({
|
||||
_id: v.id("aiChats"),
|
||||
_creationTime: v.number(),
|
||||
sessionId: v.string(),
|
||||
contextId: v.string(),
|
||||
messages: v.array(messageValidator),
|
||||
pageContext: v.optional(v.string()),
|
||||
lastMessageAt: v.optional(v.number()),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const chat = await ctx.db
|
||||
.query("aiChats")
|
||||
.withIndex("by_session_and_context", (q) =>
|
||||
q.eq("sessionId", args.sessionId).eq("contextId", args.contextId),
|
||||
)
|
||||
.first();
|
||||
|
||||
return chat;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Internal query for use in actions
|
||||
*/
|
||||
export const getAIChatInternal = internalQuery({
|
||||
args: {
|
||||
chatId: v.id("aiChats"),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({
|
||||
_id: v.id("aiChats"),
|
||||
_creationTime: v.number(),
|
||||
sessionId: v.string(),
|
||||
contextId: v.string(),
|
||||
messages: v.array(messageValidator),
|
||||
pageContext: v.optional(v.string()),
|
||||
lastMessageAt: v.optional(v.number()),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.get(args.chatId);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get storage URL for an image attachment (internal)
|
||||
*/
|
||||
export const getStorageUrlInternal = internalQuery({
|
||||
args: {
|
||||
storageId: v.id("_storage"),
|
||||
},
|
||||
returns: v.union(v.string(), v.null()),
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.storage.getUrl(args.storageId);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get or create AI chat for session and context
|
||||
* Returns the chat ID (creates new chat if needed)
|
||||
*/
|
||||
export const getOrCreateAIChat = mutation({
|
||||
args: {
|
||||
sessionId: v.string(),
|
||||
contextId: v.string(),
|
||||
},
|
||||
returns: v.id("aiChats"),
|
||||
handler: async (ctx, args) => {
|
||||
// Check for existing chat
|
||||
const existing = await ctx.db
|
||||
.query("aiChats")
|
||||
.withIndex("by_session_and_context", (q) =>
|
||||
q.eq("sessionId", args.sessionId).eq("contextId", args.contextId),
|
||||
)
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
return existing._id;
|
||||
}
|
||||
|
||||
// Create new chat
|
||||
const chatId = await ctx.db.insert("aiChats", {
|
||||
sessionId: args.sessionId,
|
||||
contextId: args.contextId,
|
||||
messages: [],
|
||||
lastMessageAt: Date.now(),
|
||||
});
|
||||
|
||||
return chatId;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Add user message to chat
|
||||
* Returns the updated chat
|
||||
*/
|
||||
export const addUserMessage = mutation({
|
||||
args: {
|
||||
chatId: v.id("aiChats"),
|
||||
content: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const chat = await ctx.db.get(args.chatId);
|
||||
if (!chat) {
|
||||
throw new Error("Chat not found");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const newMessage = {
|
||||
role: "user" as const,
|
||||
content: args.content,
|
||||
timestamp: now,
|
||||
};
|
||||
|
||||
await ctx.db.patch(args.chatId, {
|
||||
messages: [...chat.messages, newMessage],
|
||||
lastMessageAt: now,
|
||||
});
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Add user message with attachments
|
||||
* Used when sending images or links
|
||||
*/
|
||||
export const addUserMessageWithAttachments = mutation({
|
||||
args: {
|
||||
chatId: v.id("aiChats"),
|
||||
content: v.string(),
|
||||
attachments: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
type: v.union(v.literal("image"), v.literal("link")),
|
||||
storageId: v.optional(v.id("_storage")),
|
||||
url: v.optional(v.string()),
|
||||
scrapedContent: v.optional(v.string()),
|
||||
title: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const chat = await ctx.db.get(args.chatId);
|
||||
if (!chat) {
|
||||
throw new Error("Chat not found");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const newMessage = {
|
||||
role: "user" as const,
|
||||
content: args.content,
|
||||
timestamp: now,
|
||||
attachments: args.attachments,
|
||||
};
|
||||
|
||||
await ctx.db.patch(args.chatId, {
|
||||
messages: [...chat.messages, newMessage],
|
||||
lastMessageAt: now,
|
||||
});
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate upload URL for image attachments
|
||||
*/
|
||||
export const generateUploadUrl = mutation({
|
||||
args: {},
|
||||
returns: v.string(),
|
||||
handler: async (ctx) => {
|
||||
return await ctx.storage.generateUploadUrl();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Add assistant message to chat (internal - called from action)
|
||||
*/
|
||||
export const addAssistantMessage = internalMutation({
|
||||
args: {
|
||||
chatId: v.id("aiChats"),
|
||||
content: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const chat = await ctx.db.get(args.chatId);
|
||||
if (!chat) {
|
||||
throw new Error("Chat not found");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const newMessage = {
|
||||
role: "assistant" as const,
|
||||
content: args.content,
|
||||
timestamp: now,
|
||||
};
|
||||
|
||||
await ctx.db.patch(args.chatId, {
|
||||
messages: [...chat.messages, newMessage],
|
||||
lastMessageAt: now,
|
||||
});
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Clear all messages from a chat
|
||||
*/
|
||||
export const clearChat = mutation({
|
||||
args: {
|
||||
chatId: v.id("aiChats"),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const chat = await ctx.db.get(args.chatId);
|
||||
if (!chat) {
|
||||
return null; // Idempotent - no error if chat doesn't exist
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.chatId, {
|
||||
messages: [],
|
||||
pageContext: undefined,
|
||||
lastMessageAt: Date.now(),
|
||||
});
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Set page context for a chat (loads page markdown for AI context)
|
||||
*/
|
||||
export const setPageContext = mutation({
|
||||
args: {
|
||||
chatId: v.id("aiChats"),
|
||||
pageContext: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const chat = await ctx.db.get(args.chatId);
|
||||
if (!chat) {
|
||||
throw new Error("Chat not found");
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.chatId, {
|
||||
pageContext: args.pageContext,
|
||||
});
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete entire chat session
|
||||
*/
|
||||
export const deleteChat = mutation({
|
||||
args: {
|
||||
chatId: v.id("aiChats"),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const chat = await ctx.db.get(args.chatId);
|
||||
if (!chat) {
|
||||
return null; // Idempotent
|
||||
}
|
||||
|
||||
await ctx.db.delete(args.chatId);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all chats for a session (for potential future chat history feature)
|
||||
*/
|
||||
export const getChatsBySession = query({
|
||||
args: {
|
||||
sessionId: v.string(),
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id("aiChats"),
|
||||
_creationTime: v.number(),
|
||||
sessionId: v.string(),
|
||||
contextId: v.string(),
|
||||
messages: v.array(messageValidator),
|
||||
pageContext: v.optional(v.string()),
|
||||
lastMessageAt: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const chats = await ctx.db
|
||||
.query("aiChats")
|
||||
.withIndex("by_session", (q) => q.eq("sessionId", args.sessionId))
|
||||
.collect();
|
||||
|
||||
return chats;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -127,6 +127,7 @@ export const getPageBySlug = query({
|
||||
rightSidebar: v.optional(v.boolean()),
|
||||
showFooter: v.optional(v.boolean()),
|
||||
footer: v.optional(v.string()),
|
||||
aiChat: v.optional(v.boolean()),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
@@ -158,6 +159,7 @@ export const getPageBySlug = query({
|
||||
rightSidebar: page.rightSidebar,
|
||||
showFooter: page.showFooter,
|
||||
footer: page.footer,
|
||||
aiChat: page.aiChat,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -183,6 +185,7 @@ export const syncPagesPublic = mutation({
|
||||
rightSidebar: v.optional(v.boolean()),
|
||||
showFooter: v.optional(v.boolean()),
|
||||
footer: v.optional(v.string()),
|
||||
aiChat: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -225,6 +228,7 @@ export const syncPagesPublic = mutation({
|
||||
rightSidebar: page.rightSidebar,
|
||||
showFooter: page.showFooter,
|
||||
footer: page.footer,
|
||||
aiChat: page.aiChat,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
updated++;
|
||||
|
||||
@@ -130,6 +130,7 @@ export const getPostBySlug = query({
|
||||
rightSidebar: v.optional(v.boolean()),
|
||||
showFooter: v.optional(v.boolean()),
|
||||
footer: v.optional(v.string()),
|
||||
aiChat: v.optional(v.boolean()),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
@@ -164,6 +165,7 @@ export const getPostBySlug = query({
|
||||
rightSidebar: post.rightSidebar,
|
||||
showFooter: post.showFooter,
|
||||
footer: post.footer,
|
||||
aiChat: post.aiChat,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -191,6 +193,7 @@ export const syncPosts = internalMutation({
|
||||
rightSidebar: v.optional(v.boolean()),
|
||||
showFooter: v.optional(v.boolean()),
|
||||
footer: v.optional(v.string()),
|
||||
aiChat: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -235,6 +238,7 @@ export const syncPosts = internalMutation({
|
||||
rightSidebar: post.rightSidebar,
|
||||
showFooter: post.showFooter,
|
||||
footer: post.footer,
|
||||
aiChat: post.aiChat,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
updated++;
|
||||
@@ -283,6 +287,7 @@ export const syncPostsPublic = mutation({
|
||||
rightSidebar: v.optional(v.boolean()),
|
||||
showFooter: v.optional(v.boolean()),
|
||||
footer: v.optional(v.string()),
|
||||
aiChat: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -327,6 +332,7 @@ export const syncPostsPublic = mutation({
|
||||
rightSidebar: post.rightSidebar,
|
||||
showFooter: post.showFooter,
|
||||
footer: post.footer,
|
||||
aiChat: post.aiChat,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
updated++;
|
||||
|
||||
@@ -22,6 +22,7 @@ export default defineSchema({
|
||||
rightSidebar: v.optional(v.boolean()), // Enable right sidebar with CopyPageDropdown
|
||||
showFooter: v.optional(v.boolean()), // Show footer on this post (overrides siteConfig default)
|
||||
footer: v.optional(v.string()), // Footer markdown content (overrides siteConfig defaultContent)
|
||||
aiChat: v.optional(v.boolean()), // Enable AI chat in right sidebar
|
||||
lastSyncedAt: v.number(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
@@ -55,6 +56,7 @@ export default defineSchema({
|
||||
rightSidebar: v.optional(v.boolean()), // Enable right sidebar with CopyPageDropdown
|
||||
showFooter: v.optional(v.boolean()), // Show footer on this page (overrides siteConfig default)
|
||||
footer: v.optional(v.string()), // Footer markdown content (overrides siteConfig defaultContent)
|
||||
aiChat: v.optional(v.boolean()), // Enable AI chat in right sidebar
|
||||
lastSyncedAt: v.number(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
@@ -105,4 +107,32 @@ export default defineSchema({
|
||||
})
|
||||
.index("by_sessionId", ["sessionId"])
|
||||
.index("by_lastSeen", ["lastSeen"]),
|
||||
|
||||
// AI chat conversations for writing assistant
|
||||
aiChats: defineTable({
|
||||
sessionId: v.string(), // Anonymous session ID from localStorage
|
||||
contextId: v.string(), // Slug or "write-page" identifier
|
||||
messages: v.array(
|
||||
v.object({
|
||||
role: v.union(v.literal("user"), v.literal("assistant")),
|
||||
content: v.string(),
|
||||
timestamp: v.number(),
|
||||
attachments: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
type: v.union(v.literal("image"), v.literal("link")),
|
||||
storageId: v.optional(v.id("_storage")),
|
||||
url: v.optional(v.string()),
|
||||
scrapedContent: v.optional(v.string()),
|
||||
title: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
pageContext: v.optional(v.string()), // Loaded page markdown content
|
||||
lastMessageAt: v.optional(v.number()),
|
||||
})
|
||||
.index("by_session_and_context", ["sessionId", "contextId"])
|
||||
.index("by_session", ["sessionId"]),
|
||||
});
|
||||
|
||||
320
files.md
320
files.md
@@ -4,188 +4,196 @@ A brief description of each file in the codebase.
|
||||
|
||||
## Root Files
|
||||
|
||||
| File | Description |
|
||||
| -------------------------- | ---------------------------------------------------- |
|
||||
| `package.json` | Dependencies and scripts for the blog |
|
||||
| `tsconfig.json` | TypeScript configuration |
|
||||
| `vite.config.ts` | Vite bundler configuration |
|
||||
| `index.html` | Main HTML entry with SEO meta tags and JSON-LD |
|
||||
| `netlify.toml` | Netlify deployment and Convex HTTP redirects |
|
||||
| `README.md` | Project documentation |
|
||||
| `AGENTS.md` | AI coding agent instructions (agents.md spec) |
|
||||
| `files.md` | This file - codebase structure |
|
||||
| `changelog.md` | Version history and changes |
|
||||
| `TASK.md` | Task tracking and project status |
|
||||
| `FORK_CONFIG.md` | Fork configuration guide (manual + automated options)|
|
||||
| `fork-config.json.example` | Template JSON config for automated fork setup |
|
||||
| File | Description |
|
||||
| -------------------------- | ----------------------------------------------------- |
|
||||
| `package.json` | Dependencies and scripts for the blog |
|
||||
| `tsconfig.json` | TypeScript configuration |
|
||||
| `vite.config.ts` | Vite bundler configuration |
|
||||
| `index.html` | Main HTML entry with SEO meta tags and JSON-LD |
|
||||
| `netlify.toml` | Netlify deployment and Convex HTTP redirects |
|
||||
| `README.md` | Project documentation |
|
||||
| `AGENTS.md` | AI coding agent instructions (agents.md spec) |
|
||||
| `files.md` | This file - codebase structure |
|
||||
| `changelog.md` | Version history and changes |
|
||||
| `TASK.md` | Task tracking and project status |
|
||||
| `FORK_CONFIG.md` | Fork configuration guide (manual + automated options) |
|
||||
| `fork-config.json.example` | Template JSON config for automated fork setup |
|
||||
|
||||
## Source Files (`src/`)
|
||||
|
||||
### Entry Points
|
||||
|
||||
| File | Description |
|
||||
| --------------- | ------------------------------------------ |
|
||||
| `main.tsx` | React app entry point with Convex provider |
|
||||
| `App.tsx` | Main app component with routing |
|
||||
| `vite-env.d.ts` | Vite environment type definitions |
|
||||
| File | Description |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `main.tsx` | React app entry point with Convex provider |
|
||||
| `App.tsx` | Main app component with routing (supports custom homepage configuration via siteConfig.homepage) |
|
||||
| `vite-env.d.ts` | Vite environment type definitions |
|
||||
|
||||
### Config (`src/config/`)
|
||||
|
||||
| File | Description |
|
||||
| --------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration) |
|
||||
| File | Description |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration, homepage configuration, AI chat configuration) |
|
||||
|
||||
### Pages (`src/pages/`)
|
||||
|
||||
| File | Description |
|
||||
| ----------- | ----------------------------------------------------------------- |
|
||||
| `Home.tsx` | Landing page with featured content and optional post list. Supports configurable post limit (homePostsLimit) and optional "read more" link (homePostsReadMore) via siteConfig.postsDisplay |
|
||||
| `Blog.tsx` | Dedicated blog page with post list or card grid view (configurable via siteConfig.blogPage, supports view toggle). Includes back button in navigation |
|
||||
| `Post.tsx` | Individual blog post or page view with optional left sidebar (TOC) and right sidebar (CopyPageDropdown). Includes back button, tag links, and related posts section in footer for blog posts. Supports 3-column layout at 1135px+ (update SITE_URL/SITE_NAME when forking) |
|
||||
| `Stats.tsx` | Real-time analytics dashboard with visitor stats and GitHub stars |
|
||||
| `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence |
|
||||
| `Write.tsx` | Three-column markdown writing page with Cursor docs-style UI, frontmatter reference with copy buttons, theme toggle, font switcher (serif/sans/monospace), and localStorage persistence (not linked in nav) |
|
||||
| File | Description |
|
||||
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Home.tsx` | Landing page with featured content and optional post list. Supports configurable post limit (homePostsLimit) and optional "read more" link (homePostsReadMore) via siteConfig.postsDisplay |
|
||||
| `Blog.tsx` | Dedicated blog page with post list or card grid view (configurable via siteConfig.blogPage, supports view toggle). Includes back button in navigation |
|
||||
| `Post.tsx` | Individual blog post or page view with optional left sidebar (TOC) and right sidebar (CopyPageDropdown). Includes back button (hidden when used as homepage), tag links, and related posts section in footer for blog posts. Supports 3-column layout at 1135px+. Can be used as custom homepage via siteConfig.homepage (update SITE_URL/SITE_NAME when forking) |
|
||||
| `Stats.tsx` | Real-time analytics dashboard with visitor stats and GitHub stars |
|
||||
| `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence |
|
||||
| `Write.tsx` | Three-column markdown writing page with Cursor docs-style UI, frontmatter reference with copy buttons, theme toggle, font switcher (serif/sans/monospace), localStorage persistence, and optional AI Agent mode (toggleable via siteConfig.aiChat.enabledOnWritePage). When enabled, Agent replaces the textarea with AIChatView component. Includes scroll prevention when switching to Agent mode to prevent page jump. Title changes to "Agent" when in AI chat mode. |
|
||||
|
||||
### Components (`src/components/`)
|
||||
|
||||
| File | Description |
|
||||
| ------------------------- | ---------------------------------------------------------- |
|
||||
| `Layout.tsx` | Page wrapper with logo in header (top-left), search button, theme toggle, mobile menu (left-aligned on mobile), and scroll-to-top. Combines Blog link, hardcoded nav items, and markdown pages for navigation. Logo reads from siteConfig.innerPageLogo |
|
||||
| `ThemeToggle.tsx` | Theme switcher (dark/light/tan/cloud) |
|
||||
| `PostList.tsx` | Year-grouped blog post list or card grid (supports list/cards view modes) |
|
||||
| `BlogPost.tsx` | Markdown renderer with syntax highlighting, collapsible sections (details/summary), and text wrapping for plain text code blocks |
|
||||
| `CopyPageDropdown.tsx` | Share dropdown with Copy page (markdown to clipboard), View as Markdown (opens raw .md file), Download as SKILL.md (Anthropic Agent Skills format), and Open in AI links (ChatGPT, Claude, Perplexity) using GitHub raw URLs with universal prompt |
|
||||
| File | Description |
|
||||
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Layout.tsx` | Page wrapper with logo in header (top-left), search button, theme toggle, mobile menu (left-aligned on mobile), and scroll-to-top. Combines Blog link, hardcoded nav items, and markdown pages for navigation. Logo reads from siteConfig.innerPageLogo |
|
||||
| `ThemeToggle.tsx` | Theme switcher (dark/light/tan/cloud) |
|
||||
| `PostList.tsx` | Year-grouped blog post list or card grid (supports list/cards view modes) |
|
||||
| `BlogPost.tsx` | Markdown renderer with syntax highlighting, collapsible sections (details/summary), and text wrapping for plain text code blocks |
|
||||
| `CopyPageDropdown.tsx` | Share dropdown with Copy page (markdown to clipboard), View as Markdown (opens raw .md file), Download as SKILL.md (Anthropic Agent Skills format), and Open in AI links (ChatGPT, Claude, Perplexity) using GitHub raw URLs with universal prompt |
|
||||
| `Footer.tsx` | Footer component that renders markdown content from frontmatter footer field or siteConfig.defaultContent. Can be enabled/disabled globally and per-page via frontmatter showFooter field. Renders inside article at bottom for posts/pages, and in current position on homepage. Supports images with size control via HTML attributes (width, height, style, class) |
|
||||
| `SearchModal.tsx` | Full text search modal with keyboard navigation |
|
||||
| `FeaturedCards.tsx` | Card grid for featured posts/pages with excerpts |
|
||||
| `LogoMarquee.tsx` | Scrolling logo gallery with clickable links |
|
||||
| `MobileMenu.tsx` | Slide-out drawer menu for mobile navigation with hamburger button, includes sidebar table of contents when page has sidebar layout |
|
||||
| `ScrollToTop.tsx` | Configurable scroll-to-top button with Phosphor ArrowUp icon |
|
||||
| `GitHubContributions.tsx` | GitHub activity graph with theme-aware colors and year navigation |
|
||||
| `VisitorMap.tsx` | Real-time visitor location map with dotted world display and theme-aware colors |
|
||||
| `PageSidebar.tsx` | Collapsible table of contents sidebar for pages/posts with sidebar layout, extracts headings (H1-H6), active heading highlighting, smooth scroll navigation, localStorage persistence for expanded/collapsed state |
|
||||
| `RightSidebar.tsx` | Right sidebar component that displays CopyPageDropdown on posts/pages at 1135px+ viewport width, controlled by siteConfig.rightSidebar.enabled and frontmatter rightSidebar field |
|
||||
| `SearchModal.tsx` | Full text search modal with keyboard navigation |
|
||||
| `FeaturedCards.tsx` | Card grid for featured posts/pages with excerpts |
|
||||
| `LogoMarquee.tsx` | Scrolling logo gallery with clickable links |
|
||||
| `MobileMenu.tsx` | Slide-out drawer menu for mobile navigation with hamburger button, includes sidebar table of contents when page has sidebar layout |
|
||||
| `ScrollToTop.tsx` | Configurable scroll-to-top button with Phosphor ArrowUp icon |
|
||||
| `GitHubContributions.tsx` | GitHub activity graph with theme-aware colors and year navigation |
|
||||
| `VisitorMap.tsx` | Real-time visitor location map with dotted world display and theme-aware colors |
|
||||
| `PageSidebar.tsx` | Collapsible table of contents sidebar for pages/posts with sidebar layout, extracts headings (H1-H6), active heading highlighting, smooth scroll navigation, localStorage persistence for expanded/collapsed state |
|
||||
| `RightSidebar.tsx` | Right sidebar component that displays CopyPageDropdown or AI chat on posts/pages at 1135px+ viewport width, controlled by siteConfig.rightSidebar.enabled and frontmatter rightSidebar/aiChat fields |
|
||||
| `AIChatView.tsx` | AI chat interface component (Agent) using Anthropic Claude API. Supports per-page chat history, page content context, markdown rendering, and copy functionality. Used in Write page (replaces textarea when enabled) and optionally in RightSidebar. Requires ANTHROPIC_API_KEY environment variable in Convex. System prompt configurable via CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES, or CLAUDE_SYSTEM_PROMPT environment variables. Includes error handling for missing API keys. |
|
||||
|
||||
### Context (`src/context/`)
|
||||
|
||||
| File | Description |
|
||||
| ------------------ | ---------------------------------------------------- |
|
||||
| `ThemeContext.tsx` | Theme state management with localStorage persistence |
|
||||
| `FontContext.tsx` | Font family state management (serif/sans/monospace) with localStorage persistence and siteConfig integration |
|
||||
| `SidebarContext.tsx` | Shares sidebar headings and active ID between Post and Layout components for mobile menu integration |
|
||||
| File | Description |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `ThemeContext.tsx` | Theme state management with localStorage persistence |
|
||||
| `FontContext.tsx` | Font family state management (serif/sans/monospace) with localStorage persistence and siteConfig integration |
|
||||
| `SidebarContext.tsx` | Shares sidebar headings and active ID between Post and Layout components for mobile menu integration |
|
||||
|
||||
### Utils (`src/utils/`)
|
||||
|
||||
| File | Description |
|
||||
| --------------------- | -------------------------------------------------------------------- |
|
||||
| `extractHeadings.ts` | Parses markdown content to extract headings (H1-H6), generates slugs, filters out headings inside code blocks |
|
||||
| File | Description |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `extractHeadings.ts` | Parses markdown content to extract headings (H1-H6), generates slugs, filters out headings inside code blocks |
|
||||
|
||||
### Hooks (`src/hooks/`)
|
||||
|
||||
| File | Description |
|
||||
| -------------------- | --------------------------------------------- |
|
||||
| File | Description |
|
||||
| -------------------- | ------------------------------------------------ |
|
||||
| `usePageTracking.ts` | Page view recording and active session heartbeat |
|
||||
|
||||
### Styles (`src/styles/`)
|
||||
|
||||
| File | Description |
|
||||
| ------------ | ------------------------------------------------------------------------------------ |
|
||||
| `global.css` | Global CSS with theme variables, centralized font-size CSS variables for all themes, sidebar styling with alternate background colors, hidden scrollbar, and consistent borders using box-shadow for docs-style layout. Left sidebar (`.post-sidebar-wrapper`) and right sidebar (`.post-sidebar-right`) have separate, independent styles. Footer image styles (`.site-footer-image-wrapper`, `.site-footer-image`, `.site-footer-image-caption`) for responsive image display |
|
||||
| File | Description |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `global.css` | Global CSS with theme variables, centralized font-size CSS variables for all themes, sidebar styling with alternate background colors, hidden scrollbar, and consistent borders using box-shadow for docs-style layout. Left sidebar (`.post-sidebar-wrapper`) and right sidebar (`.post-sidebar-right`) have separate, independent styles. Footer image styles (`.site-footer-image-wrapper`, `.site-footer-image`, `.site-footer-image-caption`) for responsive image display. Write page layout uses viewport height constraints (100vh) with overflow hidden to prevent page scroll, and AI chat uses flexbox with min-height: 0 for proper scrollable message area |
|
||||
|
||||
## Convex Backend (`convex/`)
|
||||
|
||||
| File | Description |
|
||||
| ------------------ | -------------------------------------------------------------------- |
|
||||
| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions) with by_tags index for tag queries |
|
||||
| `posts.ts` | Queries and mutations for blog posts, view counts, getAllTags, getPostsByTag, and getRelatedPosts |
|
||||
| `pages.ts` | Queries and mutations for static pages |
|
||||
| `search.ts` | Full text search queries across posts and pages |
|
||||
| `stats.ts` | Real-time stats with aggregate component for O(log n) counts, page view recording, session heartbeat |
|
||||
| `crons.ts` | Cron job for stale session cleanup |
|
||||
| `http.ts` | HTTP endpoints: sitemap, API (update SITE_URL/SITE_NAME when forking, uses www.markdown.fast) |
|
||||
| `rss.ts` | RSS feed generation (update SITE_URL/SITE_TITLE when forking, uses www.markdown.fast) |
|
||||
| `convex.config.ts` | Convex app configuration with aggregate component registrations (pageViewsByPath, totalPageViews, uniqueVisitors) |
|
||||
| `tsconfig.json` | Convex TypeScript configuration |
|
||||
| File | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats) with indexes for tag and AI queries |
|
||||
| `posts.ts` | Queries and mutations for blog posts, view counts, getAllTags, getPostsByTag, and getRelatedPosts |
|
||||
| `pages.ts` | Queries and mutations for static pages |
|
||||
| `search.ts` | Full text search queries across posts and pages |
|
||||
| `stats.ts` | Real-time stats with aggregate component for O(log n) counts, page view recording, session heartbeat |
|
||||
| `crons.ts` | Cron job for stale session cleanup |
|
||||
| `http.ts` | HTTP endpoints: sitemap, API (update SITE_URL/SITE_NAME when forking, uses www.markdown.fast) |
|
||||
| `rss.ts` | RSS feed generation (update SITE_URL/SITE_TITLE when forking, uses www.markdown.fast) |
|
||||
| `aiChats.ts` | Queries and mutations for AI chat history (per-session, per-context storage). Handles anonymous session IDs, per-page chat contexts, and message history management. Supports page content as context for AI responses. |
|
||||
| `aiChatActions.ts` | Anthropic Claude API integration action for AI chat responses. Requires ANTHROPIC_API_KEY environment variable in Convex. Uses claude-sonnet-3-5-20240620 model. System prompt configurable via environment variables (CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES, or CLAUDE_SYSTEM_PROMPT). Includes error handling for missing API keys with user-friendly error messages. Supports page content context and chat history (last 20 messages). |
|
||||
| `convex.config.ts` | Convex app configuration with aggregate component registrations (pageViewsByPath, totalPageViews, uniqueVisitors) |
|
||||
| `tsconfig.json` | Convex TypeScript configuration |
|
||||
|
||||
### HTTP Endpoints (defined in `http.ts`)
|
||||
|
||||
| Route | Description |
|
||||
| -------------------------- | -------------------------------------- |
|
||||
| `/stats` | Real-time site analytics page |
|
||||
| `/rss.xml` | RSS feed with descriptions |
|
||||
| `/rss-full.xml` | RSS feed with full content for LLMs |
|
||||
| `/sitemap.xml` | Dynamic XML sitemap for search engines (includes posts, pages, and tag pages) |
|
||||
| `/api/posts` | JSON list of all posts |
|
||||
| `/api/post` | Single post as JSON or markdown |
|
||||
| `/api/export` | Batch export all posts with content |
|
||||
| `/meta/post` | Open Graph HTML for social crawlers |
|
||||
| `/.well-known/ai-plugin.json` | AI plugin manifest |
|
||||
| `/openapi.yaml` | OpenAPI 3.0 specification |
|
||||
| `/llms.txt` | AI agent discovery |
|
||||
| Route | Description |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `/stats` | Real-time site analytics page |
|
||||
| `/rss.xml` | RSS feed with descriptions |
|
||||
| `/rss-full.xml` | RSS feed with full content for LLMs |
|
||||
| `/sitemap.xml` | Dynamic XML sitemap for search engines (includes posts, pages, and tag pages) |
|
||||
| `/api/posts` | JSON list of all posts |
|
||||
| `/api/post` | Single post as JSON or markdown |
|
||||
| `/api/export` | Batch export all posts with content |
|
||||
| `/meta/post` | Open Graph HTML for social crawlers |
|
||||
| `/.well-known/ai-plugin.json` | AI plugin manifest |
|
||||
| `/openapi.yaml` | OpenAPI 3.0 specification |
|
||||
| `/llms.txt` | AI agent discovery |
|
||||
|
||||
## Content (`content/blog/`)
|
||||
|
||||
Markdown files with frontmatter for blog posts. Each file becomes a blog post.
|
||||
|
||||
| Field | Description |
|
||||
| --------------- | ------------------------------------------- |
|
||||
| `title` | Post title |
|
||||
| `description` | Short description for SEO |
|
||||
| `date` | Publication date (YYYY-MM-DD) |
|
||||
| `slug` | URL path for the post |
|
||||
| `published` | Whether post is public |
|
||||
| `tags` | Array of topic tags |
|
||||
| `readTime` | Estimated reading time |
|
||||
| `image` | Header/Open Graph image URL (optional) |
|
||||
| `excerpt` | Short excerpt for card view (optional) |
|
||||
| `featured` | Show in featured section (optional) |
|
||||
| `featuredOrder` | Order in featured section (optional) |
|
||||
| `authorName` | Author display name (optional) |
|
||||
| `authorImage` | Round author avatar image URL (optional) |
|
||||
| `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) |
|
||||
| `showFooter` | Show footer on this post (optional, overrides siteConfig default) |
|
||||
| Field | Description |
|
||||
| --------------- | ----------------------------------------------------------------------- |
|
||||
| `title` | Post title |
|
||||
| `description` | Short description for SEO |
|
||||
| `date` | Publication date (YYYY-MM-DD) |
|
||||
| `slug` | URL path for the post |
|
||||
| `published` | Whether post is public |
|
||||
| `tags` | Array of topic tags |
|
||||
| `readTime` | Estimated reading time |
|
||||
| `image` | Header/Open Graph image URL (optional) |
|
||||
| `excerpt` | Short excerpt for card view (optional) |
|
||||
| `featured` | Show in featured section (optional) |
|
||||
| `featuredOrder` | Order in featured section (optional) |
|
||||
| `authorName` | Author display name (optional) |
|
||||
| `authorImage` | Round author avatar image URL (optional) |
|
||||
| `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) |
|
||||
| `showFooter` | Show footer on this post (optional, overrides siteConfig default) |
|
||||
| `footer` | Footer markdown content (optional, overrides siteConfig.defaultContent) |
|
||||
| `aiChat` | Enable AI Agent chat in right sidebar (optional, requires rightSidebar: true and siteConfig.aiChat.enabledOnContent: true) |
|
||||
|
||||
## Static Pages (`content/pages/`)
|
||||
|
||||
Markdown files for static pages like About, Projects, Contact, Changelog.
|
||||
|
||||
| Field | Description |
|
||||
| --------------- | ----------------------------------------- |
|
||||
| `title` | Page title |
|
||||
| `slug` | URL path for the page |
|
||||
| `published` | Whether page is public |
|
||||
| `order` | Display order in navigation (lower first) |
|
||||
| `showInNav` | Show in navigation menu (default: true) |
|
||||
| `excerpt` | Short excerpt for card view (optional) |
|
||||
| `featured` | Show in featured section (optional) |
|
||||
| `featuredOrder` | Order in featured section (optional) |
|
||||
| `authorName` | Author display name (optional) |
|
||||
| `authorImage` | Round author avatar image URL (optional) |
|
||||
| `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) |
|
||||
| `showFooter` | Show footer on this page (optional, overrides siteConfig default) |
|
||||
| Field | Description |
|
||||
| --------------- | ----------------------------------------------------------------------- |
|
||||
| `title` | Page title |
|
||||
| `slug` | URL path for the page |
|
||||
| `published` | Whether page is public |
|
||||
| `order` | Display order in navigation (lower first) |
|
||||
| `showInNav` | Show in navigation menu (default: true) |
|
||||
| `excerpt` | Short excerpt for card view (optional) |
|
||||
| `featured` | Show in featured section (optional) |
|
||||
| `featuredOrder` | Order in featured section (optional) |
|
||||
| `authorName` | Author display name (optional) |
|
||||
| `authorImage` | Round author avatar image URL (optional) |
|
||||
| `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) |
|
||||
| `showFooter` | Show footer on this page (optional, overrides siteConfig default) |
|
||||
| `footer` | Footer markdown content (optional, overrides siteConfig.defaultContent) |
|
||||
| `aiChat` | Enable AI Agent chat in right sidebar (optional, requires rightSidebar: true and siteConfig.aiChat.enabledOnContent: true) |
|
||||
|
||||
## Scripts (`scripts/`)
|
||||
|
||||
| File | Description |
|
||||
| -------------------------- | ---------------------------------------------------------- |
|
||||
| `sync-posts.ts` | Syncs markdown files to Convex at build time |
|
||||
| `sync-discovery-files.ts` | Updates AGENTS.md and llms.txt with current app data |
|
||||
| `import-url.ts` | Imports external URLs as markdown posts (Firecrawl) |
|
||||
| `configure-fork.ts` | Automated fork configuration (reads fork-config.json) |
|
||||
| File | Description |
|
||||
| ------------------------- | ----------------------------------------------------- |
|
||||
| `sync-posts.ts` | Syncs markdown files to Convex at build time |
|
||||
| `sync-discovery-files.ts` | Updates AGENTS.md and llms.txt with current app data |
|
||||
| `import-url.ts` | Imports external URLs as markdown posts (Firecrawl) |
|
||||
| `configure-fork.ts` | Automated fork configuration (reads fork-config.json) |
|
||||
|
||||
### Sync Commands
|
||||
|
||||
**Development:**
|
||||
|
||||
- `npm run sync` - Sync markdown content to development Convex
|
||||
- `npm run sync:discovery` - Update discovery files (AGENTS.md, llms.txt) with development data
|
||||
|
||||
**Production:**
|
||||
|
||||
- `npm run sync:prod` - Sync markdown content to production Convex
|
||||
- `npm run sync:discovery:prod` - Update discovery files with production data
|
||||
|
||||
**Sync everything together:**
|
||||
|
||||
- `npm run sync:all` - Run both content sync and discovery sync (development)
|
||||
- `npm run sync:all:prod` - Run both content sync and discovery sync (production)
|
||||
|
||||
@@ -206,31 +214,31 @@ Frontmatter is the YAML metadata at the top of each markdown file. Here is how i
|
||||
|
||||
## Netlify (`netlify/edge-functions/`)
|
||||
|
||||
| File | Description |
|
||||
| ------------ | ------------------------------------------------------------ |
|
||||
| File | Description |
|
||||
| ------------ | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `botMeta.ts` | Edge function for social media crawler detection, excludes `/raw/*` paths and AI crawlers from OG interception |
|
||||
| `rss.ts` | Proxies `/rss.xml` and `/rss-full.xml` to Convex HTTP |
|
||||
| `sitemap.ts` | Proxies `/sitemap.xml` to Convex HTTP |
|
||||
| `api.ts` | Proxies `/api/posts`, `/api/post`, `/api/export` to Convex |
|
||||
| `geo.ts` | Returns user geo location from Netlify's automatic geo headers for visitor map |
|
||||
| `rss.ts` | Proxies `/rss.xml` and `/rss-full.xml` to Convex HTTP |
|
||||
| `sitemap.ts` | Proxies `/sitemap.xml` to Convex HTTP |
|
||||
| `api.ts` | Proxies `/api/posts`, `/api/post`, `/api/export` to Convex |
|
||||
| `geo.ts` | Returns user geo location from Netlify's automatic geo headers for visitor map |
|
||||
|
||||
## Public Assets (`public/`)
|
||||
|
||||
| File | Description |
|
||||
| -------------- | ---------------------------------------------- |
|
||||
| `favicon.svg` | Site favicon |
|
||||
| `_redirects` | SPA redirect rules for static files |
|
||||
| File | Description |
|
||||
| -------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `favicon.svg` | Site favicon |
|
||||
| `_redirects` | SPA redirect rules for static files |
|
||||
| `robots.txt` | Crawler rules for search engines and AI bots (update sitemap URL when forking, uses www.markdown.fast) |
|
||||
| `llms.txt` | AI agent discovery file (update site name/URL when forking, uses www.markdown.fast) |
|
||||
| `openapi.yaml` | OpenAPI 3.0 specification (update API title when forking, uses www.markdown.fast) |
|
||||
| `llms.txt` | AI agent discovery file (update site name/URL when forking, uses www.markdown.fast) |
|
||||
| `openapi.yaml` | OpenAPI 3.0 specification (update API title when forking, uses www.markdown.fast) |
|
||||
|
||||
### Raw Markdown Files (`public/raw/`)
|
||||
|
||||
Static markdown files generated during `npm run sync` or `npm run sync:prod`. Each published post and page gets a corresponding `.md` file for direct access by users, search engines, and AI agents.
|
||||
|
||||
| File Pattern | Description |
|
||||
| -------------- | ---------------------------------------------- |
|
||||
| `{slug}.md` | Static markdown file for each post/page |
|
||||
| File Pattern | Description |
|
||||
| ------------ | --------------------------------------- |
|
||||
| `{slug}.md` | Static markdown file for each post/page |
|
||||
|
||||
Access via `/raw/{slug}.md` (e.g., `/raw/setup-guide.md`).
|
||||
|
||||
@@ -238,9 +246,9 @@ Files include a metadata header with type (post/page), date, reading time, and t
|
||||
|
||||
### AI Plugin (`public/.well-known/`)
|
||||
|
||||
| File | Description |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| `ai-plugin.json` | AI plugin manifest (update name/description when forking) |
|
||||
| File | Description |
|
||||
| ---------------- | --------------------------------------------------------- |
|
||||
| `ai-plugin.json` | AI plugin manifest (update name/description when forking) |
|
||||
|
||||
### Images (`public/images/`)
|
||||
|
||||
@@ -252,23 +260,23 @@ Files include a metadata header with type (post/page), date, reading time, and t
|
||||
|
||||
### Logo Gallery (`public/images/logos/`)
|
||||
|
||||
| File | Description |
|
||||
| -------------------- | ---------------------------------------- |
|
||||
| `sample-logo-1.svg` | Sample logo (replace with your own) |
|
||||
| `sample-logo-2.svg` | Sample logo (replace with your own) |
|
||||
| `sample-logo-3.svg` | Sample logo (replace with your own) |
|
||||
| `sample-logo-4.svg` | Sample logo (replace with your own) |
|
||||
| `sample-logo-5.svg` | Sample logo (replace with your own) |
|
||||
| File | Description |
|
||||
| ------------------- | ----------------------------------- |
|
||||
| `sample-logo-1.svg` | Sample logo (replace with your own) |
|
||||
| `sample-logo-2.svg` | Sample logo (replace with your own) |
|
||||
| `sample-logo-3.svg` | Sample logo (replace with your own) |
|
||||
| `sample-logo-4.svg` | Sample logo (replace with your own) |
|
||||
| `sample-logo-5.svg` | Sample logo (replace with your own) |
|
||||
|
||||
## Cursor Rules (`.cursor/rules/`)
|
||||
|
||||
| File | Description |
|
||||
| -------------------------- | ------------------------------------------------ |
|
||||
| `convex-write-conflicts.mdc` | Write conflict prevention patterns for Convex |
|
||||
| `convex2.mdc` | Convex function syntax and examples |
|
||||
| `dev2.mdc` | Development guidelines and best practices |
|
||||
| `help.mdc` | Core development guidelines |
|
||||
| `rulesforconvex.mdc` | Convex schema and function best practices |
|
||||
| `sec-check.mdc` | Security guidelines and audit checklist |
|
||||
| `task.mdc` | Task list management guidelines |
|
||||
| `write.mdc` | Writing style guide (activate with @write) |
|
||||
| File | Description |
|
||||
| ---------------------------- | --------------------------------------------- |
|
||||
| `convex-write-conflicts.mdc` | Write conflict prevention patterns for Convex |
|
||||
| `convex2.mdc` | Convex function syntax and examples |
|
||||
| `dev2.mdc` | Development guidelines and best practices |
|
||||
| `help.mdc` | Core development guidelines |
|
||||
| `rulesforconvex.mdc` | Convex schema and function best practices |
|
||||
| `sec-check.mdc` | Security guidelines and audit checklist |
|
||||
| `task.mdc` | Task list management guidelines |
|
||||
| `write.mdc` | Writing style guide (activate with @write) |
|
||||
|
||||
@@ -50,6 +50,11 @@
|
||||
"featuredViewMode": "cards",
|
||||
"showViewToggle": true,
|
||||
"theme": "tan",
|
||||
"fontFamily": "serif"
|
||||
"fontFamily": "serif",
|
||||
"homepage": {
|
||||
"type": "default",
|
||||
"slug": null,
|
||||
"originalHomeRoute": "/home"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
40
package-lock.json
generated
40
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "markdown-site",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.71.2",
|
||||
"@convex-dev/aggregate": "^0.2.0",
|
||||
"@mendable/firecrawl-js": "^1.21.1",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
@@ -43,6 +44,26 @@
|
||||
"vite": "^5.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz",
|
||||
"integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
@@ -3608,6 +3629,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -5669,6 +5703,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"deploy:prod": "npx convex deploy && npm run sync:prod"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.71.2",
|
||||
"@convex-dev/aggregate": "^0.2.0",
|
||||
"@mendable/firecrawl-js": "^1.21.1",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# llms.txt - Information for AI assistants and LLMs
|
||||
# Learn more: https://llmstxt.org/
|
||||
# Last updated: 2025-12-25T20:18:52.317Z
|
||||
# Last updated: 2025-12-26T20:30:35.292Z
|
||||
|
||||
> Your content is instantly available to browsers, LLMs, and AI agents.
|
||||
|
||||
|
||||
@@ -8,6 +8,64 @@ Date: 2025-12-26
|
||||
All notable changes to this project.
|
||||

|
||||
|
||||
## v1.33.0
|
||||
|
||||
Released December 26, 2025
|
||||
|
||||
**AI Chat Write Agent (Agent) integration**
|
||||
|
||||
- AI Agent chat interface powered by Anthropic Claude API
|
||||
- New `AIChatView` component for AI-powered chat interface
|
||||
- Available on Write page (replaces textarea when enabled) and optionally in RightSidebar on posts/pages
|
||||
- Per-session, per-context chat history stored in Convex (aiChats table)
|
||||
- Supports page content as context for AI responses
|
||||
- Markdown rendering for AI responses with copy functionality
|
||||
- Theme-aware styling matching the site's design system
|
||||
- Uses Phosphor Icons for all UI elements
|
||||
- Convex backend for AI chat
|
||||
- New `convex/aiChats.ts` with queries and mutations for chat history
|
||||
- New `convex/aiChatActions.ts` with Claude API integration (requires ANTHROPIC_API_KEY environment variable)
|
||||
- System prompt configurable via Convex environment variables:
|
||||
- `CLAUDE_PROMPT_STYLE`, `CLAUDE_PROMPT_COMMUNITY`, `CLAUDE_PROMPT_RULES` (split prompts, joined with separators)
|
||||
- `CLAUDE_SYSTEM_PROMPT` (single prompt, fallback if split prompts not set)
|
||||
- Chat history limited to last 20 messages for context efficiency
|
||||
- Error handling: displays "API key is not set" message when ANTHROPIC_API_KEY is missing in Convex environment variables
|
||||
- Configuration options
|
||||
- `siteConfig.aiChat` interface with `enabledOnWritePage` and `enabledOnContent` boolean flags
|
||||
- Both flags default to false (opt-in feature)
|
||||
- New `aiChat` frontmatter field for posts and pages (requires rightSidebar: true)
|
||||
- Write page AI Agent mode
|
||||
- Title changes from "Blog Post" or "Page" to "Agent" when in AI chat mode
|
||||
- Toggle button text changes between "Agent" and "Text Editor"
|
||||
- Page scroll prevention when switching modes (no page jump)
|
||||
- RightSidebar AI chat support
|
||||
- Conditionally renders AIChatView when enabled via frontmatter `aiChat: true` field
|
||||
- Requires both `siteConfig.aiChat.enabledOnContent` and frontmatter `aiChat: true`
|
||||
- Passes page content as context for AI responses
|
||||
|
||||
Updated files: `src/components/AIChatView.tsx`, `src/components/RightSidebar.tsx`, `src/pages/Write.tsx`, `src/pages/Post.tsx`, `src/config/siteConfig.ts`, `convex/schema.ts`, `convex/aiChats.ts`, `convex/aiChatActions.ts`, `convex/posts.ts`, `convex/pages.ts`, `scripts/sync-posts.ts`, `src/styles/global.css`, `package.json`
|
||||
|
||||
Documentation updated: `files.md`, `changelog.md`, `README.md`, `content/blog/setup-guide.md`, `public/raw/docs.md`
|
||||
|
||||
## v1.32.0
|
||||
|
||||
Released December 25, 2025
|
||||
|
||||
**Custom homepage configuration**
|
||||
|
||||
- Set any page or blog post to serve as the homepage instead of the default Home component
|
||||
- Configure via `siteConfig.homepage` with `type` ("default", "page", or "post"), `slug` (required for page/post), and `originalHomeRoute` (default: "/home")
|
||||
- Custom homepage retains all Post component features (sidebar, copy dropdown, author info, footer) but without the featured section
|
||||
- Original homepage remains accessible at `/home` route (or configured `originalHomeRoute`) when custom homepage is set
|
||||
- SEO metadata uses the page/post's frontmatter when used as homepage
|
||||
- Back button hidden when Post component is used as homepage
|
||||
- Fork configuration support for homepage
|
||||
- Added `homepage` field to `fork-config.json.example`
|
||||
- Updated `configure-fork.ts` to handle homepage configuration
|
||||
- Documentation added to `FORK_CONFIG.md` with usage examples
|
||||
|
||||
Updated files: `src/App.tsx`, `src/pages/Post.tsx`, `src/config/siteConfig.ts`, `scripts/configure-fork.ts`, `fork-config.json.example`, `FORK_CONFIG.md`
|
||||
|
||||
## v1.31.1
|
||||
|
||||
Released December 25, 2025
|
||||
|
||||
@@ -5,6 +5,8 @@ Type: page
|
||||
Date: 2025-12-26
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
Reference documentation for setting up, customizing, and deploying this markdown framework.
|
||||
|
||||
**How publishing works:** Write posts in markdown, run `npm run sync` for development or `npm run sync:prod` for production, and they appear on your live site immediately. No rebuild or redeploy needed. Convex handles real-time data sync, so connected browsers update automatically.
|
||||
|
||||
@@ -1040,6 +1040,8 @@ Pages appear automatically in the navigation when published.
|
||||
|
||||
**Right sidebar:** When enabled in `siteConfig.rightSidebar.enabled`, posts and pages can display a right sidebar containing the CopyPageDropdown at 1135px+ viewport width. Add `rightSidebar: true` to frontmatter to enable. Without this field, pages render normally with CopyPageDropdown in the nav bar. When enabled, CopyPageDropdown moves from the navigation bar to the right sidebar on wide screens. The right sidebar is hidden below 1135px, and CopyPageDropdown returns to the nav bar automatically.
|
||||
|
||||
**AI Agent chat:** The site includes an AI writing assistant (Agent) powered by Anthropic Claude API. Enable Agent on the Write page via `siteConfig.aiChat.enabledOnWritePage` or in the right sidebar on posts/pages using `aiChat: true` frontmatter (requires `rightSidebar: true`). Requires `ANTHROPIC_API_KEY` environment variable in Convex. See the [AI Agent chat section](#ai-agent-chat) below for setup instructions.
|
||||
|
||||
### Update SEO Meta Tags
|
||||
|
||||
Edit `index.html` to update:
|
||||
@@ -1287,6 +1289,72 @@ A markdown writing page is available at `/write` (not linked in navigation). Use
|
||||
|
||||
Content is stored in localStorage only and not synced to the database. Refreshing the page preserves your content, but clearing browser data will lose it.
|
||||
|
||||
**AI Agent mode:** When `siteConfig.aiChat.enabledOnWritePage` is enabled, a toggle button appears in the Actions section. Clicking it replaces the textarea with the AI Agent chat interface. The page title changes to "Agent" when in chat mode. Requires `ANTHROPIC_API_KEY` environment variable in Convex. See the [AI Agent chat section](#ai-agent-chat) below for setup instructions.
|
||||
|
||||
## AI Agent chat
|
||||
|
||||
The site includes an AI writing assistant (Agent) powered by Anthropic Claude API. Agent can be enabled in two places:
|
||||
|
||||
**1. Write page (`/write`)**
|
||||
|
||||
Enable Agent mode on the Write page via `siteConfig.aiChat.enabledOnWritePage`. When enabled, a toggle button appears in the Actions section. Clicking it replaces the textarea with the Agent chat interface. The page title changes to "Agent" when in chat mode.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```typescript
|
||||
// src/config/siteConfig.ts
|
||||
aiChat: {
|
||||
enabledOnWritePage: true, // Enable Agent toggle on /write page
|
||||
enabledOnContent: true, // Allow Agent on posts/pages via frontmatter
|
||||
},
|
||||
```
|
||||
|
||||
**2. Right sidebar on posts/pages**
|
||||
|
||||
Enable Agent in the right sidebar on individual posts or pages using the `aiChat` frontmatter field. Requires both `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`.
|
||||
|
||||
**Frontmatter example:**
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "My Post"
|
||||
rightSidebar: true
|
||||
aiChat: true # Enable Agent in right sidebar
|
||||
---
|
||||
```
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
Agent requires the following Convex environment variables:
|
||||
|
||||
- `ANTHROPIC_API_KEY` (required): Your Anthropic API key for Claude API access
|
||||
- `CLAUDE_PROMPT_STYLE` (optional): First part of system prompt
|
||||
- `CLAUDE_PROMPT_COMMUNITY` (optional): Second part of system prompt
|
||||
- `CLAUDE_PROMPT_RULES` (optional): Third part of system prompt
|
||||
- `CLAUDE_SYSTEM_PROMPT` (optional): Single system prompt (fallback if split prompts not set)
|
||||
|
||||
**Setting environment variables:**
|
||||
|
||||
1. Go to [Convex Dashboard](https://dashboard.convex.dev)
|
||||
2. Select your project
|
||||
3. Navigate to Settings > Environment Variables
|
||||
4. Add `ANTHROPIC_API_KEY` with your API key value
|
||||
5. Optionally add system prompt variables (`CLAUDE_PROMPT_STYLE`, etc.)
|
||||
6. Deploy changes
|
||||
|
||||
**How it works:**
|
||||
|
||||
- Agent uses anonymous session IDs stored in localStorage for chat history
|
||||
- Each post/page has its own chat context (identified by slug)
|
||||
- Chat history is stored per-session, per-context in Convex (aiChats table)
|
||||
- Page content can be provided as context for AI responses
|
||||
- Chat history limited to last 20 messages for efficiency
|
||||
- If API key is not set, Agent displays "API key is not set" error message
|
||||
|
||||
**Error handling:**
|
||||
|
||||
If `ANTHROPIC_API_KEY` is not configured in Convex environment variables, Agent displays a user-friendly error message: "API key is not set". This helps identify when the API key is missing in production deployments.
|
||||
|
||||
## Next Steps
|
||||
|
||||
After deploying:
|
||||
|
||||
@@ -73,6 +73,11 @@ interface ForkConfig {
|
||||
showViewToggle?: boolean;
|
||||
theme?: "dark" | "light" | "tan" | "cloud";
|
||||
fontFamily?: "serif" | "sans" | "monospace";
|
||||
homepage?: {
|
||||
type: "default" | "page" | "post";
|
||||
slug?: string;
|
||||
originalHomeRoute?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Get project root directory
|
||||
@@ -261,6 +266,26 @@ function updateSiteConfig(config: ForkConfig): void {
|
||||
);
|
||||
}
|
||||
|
||||
// Update homepage configuration if specified
|
||||
if (config.homepage) {
|
||||
content = content.replace(
|
||||
/type: ['"](?:default|page|post)['"],\s*\/\/ Options: "default" \(standard Home component\), "page" \(use a static page\), or "post" \(use a blog post\)/,
|
||||
`type: "${config.homepage.type}", // Options: "default" (standard Home component), "page" (use a static page), or "post" (use a blog post)`,
|
||||
);
|
||||
if (config.homepage.slug !== undefined) {
|
||||
content = content.replace(
|
||||
/slug: (?:undefined|['"].*?['"]),\s*\/\/ Required if type is "page" or "post" - the slug of the page\/post to use/,
|
||||
`slug: ${config.homepage.slug ? `"${config.homepage.slug}"` : "undefined"}, // Required if type is "page" or "post" - the slug of the page/post to use`,
|
||||
);
|
||||
}
|
||||
if (config.homepage.originalHomeRoute !== undefined) {
|
||||
content = content.replace(
|
||||
/originalHomeRoute: ['"].*?['"],\s*\/\/ Route to access the original homepage when custom homepage is set/,
|
||||
`originalHomeRoute: "${config.homepage.originalHomeRoute}", // Route to access the original homepage when custom homepage is set`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update gitHubRepo config (for AI service raw URLs)
|
||||
// Support both new gitHubRepoConfig and legacy githubUsername/githubRepo fields
|
||||
const gitHubRepoOwner =
|
||||
|
||||
@@ -38,6 +38,7 @@ interface PostFrontmatter {
|
||||
authorImage?: string; // Author avatar image URL (round)
|
||||
layout?: string; // Layout type: "sidebar" for docs-style layout
|
||||
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
|
||||
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
|
||||
}
|
||||
|
||||
interface ParsedPost {
|
||||
@@ -59,6 +60,7 @@ interface ParsedPost {
|
||||
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
|
||||
showFooter?: boolean; // Show footer on this post (overrides siteConfig default)
|
||||
footer?: string; // Footer markdown content (overrides siteConfig defaultContent)
|
||||
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
|
||||
}
|
||||
|
||||
// Page frontmatter (for static pages like About, Projects, Contact)
|
||||
@@ -77,6 +79,7 @@ interface PageFrontmatter {
|
||||
layout?: string; // Layout type: "sidebar" for docs-style layout
|
||||
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
|
||||
showFooter?: boolean; // Show footer on this page (overrides siteConfig default)
|
||||
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
|
||||
}
|
||||
|
||||
interface ParsedPage {
|
||||
@@ -95,6 +98,7 @@ interface ParsedPage {
|
||||
layout?: string; // Layout type: "sidebar" for docs-style layout
|
||||
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
|
||||
showFooter?: boolean; // Show footer on this page (overrides siteConfig default)
|
||||
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
|
||||
}
|
||||
|
||||
// Calculate reading time based on word count
|
||||
@@ -138,6 +142,7 @@ function parseMarkdownFile(filePath: string): ParsedPost | null {
|
||||
rightSidebar: frontmatter.rightSidebar, // Enable right sidebar with CopyPageDropdown
|
||||
showFooter: frontmatter.showFooter, // Show footer on this post
|
||||
footer: frontmatter.footer, // Footer markdown content
|
||||
aiChat: frontmatter.aiChat, // Enable AI chat in right sidebar
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error parsing ${filePath}:`, error);
|
||||
@@ -191,6 +196,7 @@ function parsePageFile(filePath: string): ParsedPage | null {
|
||||
layout: frontmatter.layout, // Layout type: "sidebar" for docs-style layout
|
||||
rightSidebar: frontmatter.rightSidebar, // Enable right sidebar with CopyPageDropdown
|
||||
showFooter: frontmatter.showFooter, // Show footer on this page
|
||||
aiChat: frontmatter.aiChat, // Enable AI chat in right sidebar
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error parsing page ${filePath}:`, error);
|
||||
|
||||
31
src/App.tsx
31
src/App.tsx
@@ -20,11 +20,40 @@ function App() {
|
||||
return <Write />;
|
||||
}
|
||||
|
||||
// Determine if we should use a custom homepage
|
||||
const useCustomHomepage =
|
||||
siteConfig.homepage.type !== "default" && siteConfig.homepage.slug;
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
{/* Homepage route - either default Home or custom page/post */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
useCustomHomepage ? (
|
||||
<Post
|
||||
slug={siteConfig.homepage.slug!}
|
||||
isHomepage={true}
|
||||
homepageType={
|
||||
siteConfig.homepage.type === "default"
|
||||
? undefined
|
||||
: siteConfig.homepage.type
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Home />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* Original homepage route (when custom homepage is set) */}
|
||||
{useCustomHomepage && (
|
||||
<Route
|
||||
path={siteConfig.homepage.originalHomeRoute || "/home"}
|
||||
element={<Home />}
|
||||
/>
|
||||
)}
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
{/* Blog page route - only enabled when blogPage.enabled is true */}
|
||||
{siteConfig.blogPage.enabled && (
|
||||
|
||||
719
src/components/AIChatView.tsx
Normal file
719
src/components/AIChatView.tsx
Normal file
@@ -0,0 +1,719 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useQuery, useMutation, useAction } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
PaperPlaneTilt,
|
||||
Copy,
|
||||
Check,
|
||||
Stop,
|
||||
Trash,
|
||||
FileText,
|
||||
SpinnerGap,
|
||||
Image,
|
||||
Link,
|
||||
X,
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
// Generate a unique session ID for anonymous users
|
||||
function getSessionId(): string {
|
||||
const key = "ai_chat_session_id";
|
||||
let sessionId = localStorage.getItem(key);
|
||||
if (!sessionId) {
|
||||
sessionId = crypto.randomUUID();
|
||||
localStorage.setItem(key, sessionId);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
interface AIChatViewProps {
|
||||
contextId: string; // Slug or "write-page"
|
||||
pageContent?: string; // Optional page content for context
|
||||
onClose?: () => void; // Optional close handler
|
||||
hideAttachments?: boolean; // Hide image/link attachment buttons (for right sidebar)
|
||||
}
|
||||
|
||||
export default function AIChatView({
|
||||
contextId,
|
||||
pageContent,
|
||||
onClose,
|
||||
hideAttachments = false,
|
||||
}: AIChatViewProps) {
|
||||
// State
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isStopped, setIsStopped] = useState(false);
|
||||
const [copiedMessageIndex, setCopiedMessageIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [chatId, setChatId] = useState<Id<"aiChats"> | null>(null);
|
||||
const [hasLoadedContext, setHasLoadedContext] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attachments, setAttachments] = useState<
|
||||
Array<{
|
||||
type: "image" | "link";
|
||||
storageId?: Id<"_storage">;
|
||||
url?: string;
|
||||
file?: File;
|
||||
preview?: string;
|
||||
scrapedContent?: string;
|
||||
title?: string;
|
||||
}>
|
||||
>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [linkInputValue, setLinkInputValue] = useState("");
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
|
||||
// Refs
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Session ID
|
||||
const sessionId = getSessionId();
|
||||
|
||||
// Convex hooks
|
||||
const chat = useQuery(
|
||||
api.aiChats.getAIChatByContext,
|
||||
chatId ? { sessionId, contextId } : "skip",
|
||||
);
|
||||
|
||||
const getOrCreateChat = useMutation(api.aiChats.getOrCreateAIChat);
|
||||
const addUserMessage = useMutation(api.aiChats.addUserMessage);
|
||||
const addUserMessageWithAttachments = useMutation(
|
||||
api.aiChats.addUserMessageWithAttachments,
|
||||
);
|
||||
const generateUploadUrl = useMutation(api.aiChats.generateUploadUrl);
|
||||
const clearChatMutation = useMutation(api.aiChats.clearChat);
|
||||
const setPageContext = useMutation(api.aiChats.setPageContext);
|
||||
const generateResponse = useAction(api.aiChatActions.generateResponse);
|
||||
|
||||
// Initialize chat
|
||||
useEffect(() => {
|
||||
const initChat = async () => {
|
||||
const id = await getOrCreateChat({ sessionId, contextId });
|
||||
setChatId(id);
|
||||
};
|
||||
initChat();
|
||||
}, [sessionId, contextId, getOrCreateChat]);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [chat?.messages]);
|
||||
|
||||
// Prevent page scroll when clicking input container
|
||||
const handleInputContainerClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Only prevent if clicking the container itself, not the textarea
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Focus input after mount with delay to prevent scroll jump
|
||||
useEffect(() => {
|
||||
// Use setTimeout to delay focus until after layout settles
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
// Auto-resize textarea
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight();
|
||||
}, [inputValue, adjustTextareaHeight]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// "/" to focus input (when not in input/textarea)
|
||||
if (
|
||||
e.key === "/" &&
|
||||
document.activeElement?.tagName !== "INPUT" &&
|
||||
document.activeElement?.tagName !== "TEXTAREA"
|
||||
) {
|
||||
e.preventDefault();
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Handle image upload
|
||||
const handleImageUpload = async (file: File) => {
|
||||
if (!chatId) return;
|
||||
|
||||
// Check attachment limit (max 3 images)
|
||||
const currentImageCount = attachments.filter(
|
||||
(a) => a.type === "image",
|
||||
).length;
|
||||
if (currentImageCount >= 3) {
|
||||
alert("Maximum 3 images per message");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert("Please upload a PNG, JPEG, GIF, or WebP image");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (3MB max)
|
||||
const maxSize = 3 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
alert("Image must be smaller than 3MB");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
// Get upload URL
|
||||
const uploadUrl = await generateUploadUrl();
|
||||
|
||||
// Upload file
|
||||
const result = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": file.type },
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error("Upload failed");
|
||||
}
|
||||
|
||||
const response = await result.json();
|
||||
const storageId = response.storageId;
|
||||
|
||||
// Add to attachments
|
||||
const preview = URL.createObjectURL(file);
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "image",
|
||||
storageId: storageId as Id<"_storage">,
|
||||
file,
|
||||
preview,
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error uploading image:", error);
|
||||
alert("Failed to upload image");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle link attachment
|
||||
const handleAddLink = () => {
|
||||
if (!linkInputValue.trim()) return;
|
||||
|
||||
// Check attachment limit (max 3 links)
|
||||
const currentLinkCount = attachments.filter(
|
||||
(a) => a.type === "link",
|
||||
).length;
|
||||
if (currentLinkCount >= 3) {
|
||||
alert("Maximum 3 links per message");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = linkInputValue.trim();
|
||||
try {
|
||||
new URL(url); // Validate URL
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "link",
|
||||
url,
|
||||
},
|
||||
]);
|
||||
setLinkInputValue("");
|
||||
setShowLinkModal(false);
|
||||
} catch {
|
||||
alert("Please enter a valid URL");
|
||||
}
|
||||
};
|
||||
|
||||
// Remove attachment
|
||||
const handleRemoveAttachment = (index: number) => {
|
||||
setAttachments((prev) => {
|
||||
const newAttachments = [...prev];
|
||||
const removed = newAttachments[index];
|
||||
if (removed.preview) {
|
||||
URL.revokeObjectURL(removed.preview);
|
||||
}
|
||||
newAttachments.splice(index, 1);
|
||||
return newAttachments;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle send message
|
||||
const handleSend = async () => {
|
||||
if (
|
||||
(!inputValue.trim() && attachments.length === 0) ||
|
||||
!chatId ||
|
||||
isLoading
|
||||
)
|
||||
return;
|
||||
|
||||
const message = inputValue.trim();
|
||||
setInputValue("");
|
||||
setIsStopped(false);
|
||||
|
||||
// Handle clear command
|
||||
if (message.toLowerCase() === "clear") {
|
||||
await clearChatMutation({ chatId });
|
||||
setHasLoadedContext(false);
|
||||
setAttachments([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare attachments for sending
|
||||
const attachmentsToSend = attachments.map((att) => ({
|
||||
type: att.type as "image" | "link",
|
||||
storageId: att.storageId,
|
||||
url: att.url,
|
||||
scrapedContent: att.scrapedContent,
|
||||
title: att.title,
|
||||
}));
|
||||
|
||||
// Add user message with attachments
|
||||
if (attachmentsToSend.length > 0) {
|
||||
await addUserMessageWithAttachments({
|
||||
chatId,
|
||||
content: message || "",
|
||||
attachments: attachmentsToSend,
|
||||
});
|
||||
} else {
|
||||
await addUserMessage({ chatId, content: message });
|
||||
}
|
||||
|
||||
// Clear attachments
|
||||
attachments.forEach((att) => {
|
||||
if (att.preview) {
|
||||
URL.revokeObjectURL(att.preview);
|
||||
}
|
||||
});
|
||||
setAttachments([]);
|
||||
|
||||
// Generate AI response
|
||||
setIsLoading(true);
|
||||
setIsStopped(false);
|
||||
setError(null);
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
await generateResponse({
|
||||
chatId,
|
||||
userMessage: message || "",
|
||||
pageContext: hasLoadedContext ? undefined : pageContent,
|
||||
attachments:
|
||||
attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
const errorMessage =
|
||||
(error as Error).message || "Failed to generate response";
|
||||
setError(errorMessage);
|
||||
console.error("Error generating response:", error);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle stop generation
|
||||
const handleStop = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
setIsStopped(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle key press in textarea
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle copy message
|
||||
const handleCopy = async (content: string, index: number) => {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopiedMessageIndex(index);
|
||||
setTimeout(() => setCopiedMessageIndex(null), 2000);
|
||||
};
|
||||
|
||||
// Handle load page context
|
||||
const handleLoadContext = async () => {
|
||||
if (!chatId || !pageContent) return;
|
||||
|
||||
await setPageContext({ chatId, pageContext: pageContent });
|
||||
setHasLoadedContext(true);
|
||||
};
|
||||
|
||||
// Handle clear chat
|
||||
const handleClear = async () => {
|
||||
if (!chatId) return;
|
||||
await clearChatMutation({ chatId });
|
||||
setHasLoadedContext(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const messages = chat?.messages || [];
|
||||
|
||||
// Component to render image attachment with URL fetching
|
||||
const ImageAttachment = ({ storageId }: { storageId: Id<"_storage"> }) => {
|
||||
const imageUrl = useQuery(api.aiChats.getStorageUrl, { storageId });
|
||||
if (!imageUrl) {
|
||||
return <div className="ai-chat-attachment-loading">Loading image...</div>;
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Attachment"
|
||||
className="ai-chat-attachment-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-chat-view">
|
||||
{/* Header with actions */}
|
||||
<div className="ai-chat-header">
|
||||
<span className="ai-chat-title">Agent</span>
|
||||
<div className="ai-chat-header-actions">
|
||||
{pageContent && !hasLoadedContext && (
|
||||
<button
|
||||
className="ai-chat-load-context-button"
|
||||
onClick={handleLoadContext}
|
||||
title="Load page content as context"
|
||||
>
|
||||
<FileText size={16} weight="bold" />
|
||||
<span>Load Page</span>
|
||||
</button>
|
||||
)}
|
||||
{pageContent && hasLoadedContext && (
|
||||
<span className="ai-chat-context-loaded">
|
||||
<Check size={14} weight="bold" />
|
||||
Context loaded
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="ai-chat-clear-button"
|
||||
onClick={handleClear}
|
||||
title="Clear chat (or type 'clear')"
|
||||
disabled={messages.length === 0}
|
||||
>
|
||||
<Trash size={16} weight="bold" />
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
className="ai-chat-close-button"
|
||||
onClick={onClose}
|
||||
title="Close chat"
|
||||
>
|
||||
Back to Editor
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="ai-chat-messages">
|
||||
{messages.length === 0 && !isLoading && (
|
||||
<div className="ai-chat-empty">
|
||||
<p>Ask a question.</p>
|
||||
<p className="ai-chat-empty-hint">
|
||||
Press Enter to send, Shift+Enter for new line, or type
|
||||
"clear" to reset.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={`${message.timestamp}-${index}`}
|
||||
className={`ai-chat-message ai-chat-message-${message.role}`}
|
||||
>
|
||||
<div className="ai-chat-message-content">
|
||||
{message.role === "assistant" ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<>
|
||||
{message.content && <p>{message.content}</p>}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="ai-chat-attachments">
|
||||
{message.attachments.map((att, attIndex) => (
|
||||
<div key={attIndex} className="ai-chat-attachment">
|
||||
{att.type === "image" && att.storageId && (
|
||||
<ImageAttachment storageId={att.storageId} />
|
||||
)}
|
||||
{att.type === "link" && att.url && (
|
||||
<a
|
||||
href={att.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ai-chat-attachment-link"
|
||||
>
|
||||
<Link size={16} />
|
||||
<span>{att.title || att.url}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{message.role === "assistant" && (
|
||||
<button
|
||||
className="ai-chat-copy-button"
|
||||
onClick={() => handleCopy(message.content, index)}
|
||||
title="Copy message"
|
||||
>
|
||||
{copiedMessageIndex === index ? (
|
||||
<Check size={14} weight="bold" />
|
||||
) : (
|
||||
<Copy size={14} weight="bold" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="ai-chat-message ai-chat-message-assistant ai-chat-loading">
|
||||
<div className="ai-chat-loading-content">
|
||||
<SpinnerGap size={18} weight="bold" className="ai-chat-spinner" />
|
||||
<span>Thinking...</span>
|
||||
</div>
|
||||
<button
|
||||
className="ai-chat-stop-button"
|
||||
onClick={handleStop}
|
||||
title="Stop generating"
|
||||
>
|
||||
<Stop size={16} weight="bold" />
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stopped state */}
|
||||
{isStopped && !isLoading && (
|
||||
<div className="ai-chat-stopped">Generation stopped</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="ai-chat-message ai-chat-message-assistant ai-chat-error">
|
||||
<div className="ai-chat-message-content">
|
||||
<p style={{ margin: 0 }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Attachments preview */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="ai-chat-attachments-preview">
|
||||
{attachments.map((att, index) => (
|
||||
<div key={index} className="ai-chat-attachment-preview">
|
||||
{att.type === "image" && att.preview && (
|
||||
<>
|
||||
<img
|
||||
src={att.preview}
|
||||
alt="Preview"
|
||||
className="ai-chat-attachment-preview-image"
|
||||
/>
|
||||
<button
|
||||
className="ai-chat-attachment-remove"
|
||||
onClick={() => handleRemoveAttachment(index)}
|
||||
title="Remove attachment"
|
||||
>
|
||||
<X size={14} weight="bold" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{att.type === "link" && (
|
||||
<>
|
||||
<Link size={16} />
|
||||
<span className="ai-chat-attachment-preview-url">
|
||||
{att.url}
|
||||
</span>
|
||||
<button
|
||||
className="ai-chat-attachment-remove"
|
||||
onClick={() => handleRemoveAttachment(index)}
|
||||
title="Remove attachment"
|
||||
>
|
||||
<X size={14} weight="bold" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div
|
||||
className="ai-chat-input-container"
|
||||
onClick={handleInputContainerClick}
|
||||
>
|
||||
<div className="ai-chat-input-wrapper">
|
||||
{!hideAttachments && (
|
||||
<div className="ai-chat-input-actions">
|
||||
<label
|
||||
className="ai-chat-attach-button"
|
||||
title="Upload image (max 3)"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleImageUpload(file);
|
||||
}
|
||||
e.target.value = ""; // Reset input
|
||||
}}
|
||||
disabled={
|
||||
isLoading ||
|
||||
isUploading ||
|
||||
attachments.filter((a) => a.type === "image").length >= 3
|
||||
}
|
||||
/>
|
||||
{isUploading ? (
|
||||
<SpinnerGap
|
||||
size={18}
|
||||
weight="bold"
|
||||
className="ai-chat-spinner"
|
||||
/>
|
||||
) : (
|
||||
<Image size={18} weight="regular" />
|
||||
)}
|
||||
</label>
|
||||
<button
|
||||
className="ai-chat-attach-button"
|
||||
onClick={() => setShowLinkModal(true)}
|
||||
disabled={
|
||||
isLoading ||
|
||||
isUploading ||
|
||||
attachments.filter((a) => a.type === "link").length >= 3
|
||||
}
|
||||
title="Add link (max 3)"
|
||||
>
|
||||
<Link size={18} weight="regular" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="ai-chat-input"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder="Type your message..."
|
||||
rows={1}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
className="ai-chat-send-button"
|
||||
onClick={handleSend}
|
||||
disabled={
|
||||
(!inputValue.trim() && attachments.length === 0) || isLoading
|
||||
}
|
||||
title="Send message (Enter)"
|
||||
>
|
||||
<PaperPlaneTilt size={18} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link input modal */}
|
||||
{showLinkModal && (
|
||||
<div
|
||||
className="ai-chat-link-modal"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowLinkModal(false);
|
||||
setLinkInputValue("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="ai-chat-link-modal-content">
|
||||
<h3>Add Link</h3>
|
||||
<input
|
||||
type="url"
|
||||
value={linkInputValue}
|
||||
onChange={(e) => setLinkInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleAddLink();
|
||||
} else if (e.key === "Escape") {
|
||||
setShowLinkModal(false);
|
||||
setLinkInputValue("");
|
||||
}
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
className="ai-chat-link-input"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="ai-chat-link-modal-actions">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLinkModal(false);
|
||||
setLinkInputValue("");
|
||||
}}
|
||||
className="ai-chat-link-modal-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddLink}
|
||||
className="ai-chat-link-modal-add"
|
||||
disabled={!linkInputValue.trim()}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,19 +37,23 @@ export default function LogoMarquee({ config }: LogoMarqueeProps) {
|
||||
|
||||
// Normalize images
|
||||
const normalizedImages = config.images.map(normalizeImage);
|
||||
|
||||
|
||||
// Check if scrolling mode (default true for backwards compatibility)
|
||||
const isScrolling = config.scrolling !== false;
|
||||
|
||||
// home logos scrolling settings
|
||||
const isScrolling = config.scrolling !== true;
|
||||
|
||||
// For static mode, limit to maxItems (default 4)
|
||||
const maxItems = config.maxItems ?? 4;
|
||||
const displayImages = isScrolling
|
||||
const displayImages = isScrolling
|
||||
? [...normalizedImages, ...normalizedImages] // Duplicate for seamless scroll
|
||||
: normalizedImages.slice(0, maxItems); // Limit for static grid
|
||||
|
||||
// Render logo item (shared between modes)
|
||||
const renderLogo = (logo: LogoItem, index: number) => (
|
||||
<div key={`${logo.src}-${index}`} className={isScrolling ? "logo-marquee-item" : "logo-static-item"}>
|
||||
<div
|
||||
key={`${logo.src}-${index}`}
|
||||
className={isScrolling ? "logo-marquee-item" : "logo-static-item"}
|
||||
>
|
||||
{logo.href ? (
|
||||
<a
|
||||
href={logo.href}
|
||||
@@ -77,9 +81,7 @@ export default function LogoMarquee({ config }: LogoMarqueeProps) {
|
||||
|
||||
return (
|
||||
<div className="logo-marquee-container">
|
||||
{config.title && (
|
||||
<p className="logo-marquee-title">{config.title}</p>
|
||||
)}
|
||||
{config.title && <p className="logo-marquee-title">{config.title}</p>}
|
||||
{isScrolling ? (
|
||||
// Scrolling marquee mode
|
||||
<div
|
||||
@@ -96,9 +98,7 @@ export default function LogoMarquee({ config }: LogoMarqueeProps) {
|
||||
</div>
|
||||
) : (
|
||||
// Static grid mode
|
||||
<div className="logo-static-grid">
|
||||
{displayImages.map(renderLogo)}
|
||||
</div>
|
||||
<div className="logo-static-grid">{displayImages.map(renderLogo)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,44 @@
|
||||
// Right sidebar component - maintains layout spacing when sidebars are enabled
|
||||
// CopyPageDropdown is now rendered in the main content area instead
|
||||
export default function RightSidebar() {
|
||||
// Right sidebar component
|
||||
// Conditionally renders AI chat when enabled via frontmatter and siteConfig
|
||||
import AIChatView from "./AIChatView";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
interface RightSidebarProps {
|
||||
aiChatEnabled?: boolean; // From frontmatter aiChat: true
|
||||
pageContent?: string; // Page markdown content for AI context
|
||||
slug?: string; // Page/post slug for chat context ID
|
||||
}
|
||||
|
||||
export default function RightSidebar({
|
||||
aiChatEnabled = false,
|
||||
pageContent,
|
||||
slug,
|
||||
}: RightSidebarProps) {
|
||||
// Check if AI chat should be shown
|
||||
// Requires both siteConfig.aiChat.enabledOnContent AND frontmatter aiChat: true
|
||||
const showAIChat =
|
||||
siteConfig.aiChat.enabledOnContent && aiChatEnabled && slug;
|
||||
|
||||
if (showAIChat) {
|
||||
return (
|
||||
<aside className="post-sidebar-right">
|
||||
<div className="right-sidebar-ai-chat">
|
||||
<AIChatView
|
||||
contextId={slug}
|
||||
pageContent={pageContent}
|
||||
hideAttachments={true}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// Default empty sidebar for layout spacing
|
||||
return (
|
||||
<aside className="post-sidebar-right">
|
||||
<div className="right-sidebar-content">
|
||||
{/* Empty - CopyPageDropdown moved to main content area */}
|
||||
{/* Empty - maintains layout spacing */}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,21 @@ export interface FooterConfig {
|
||||
defaultContent?: string; // Default markdown content if no frontmatter footer field provided
|
||||
}
|
||||
|
||||
// Homepage configuration
|
||||
// Allows setting any page or blog post to serve as the homepage
|
||||
export interface HomepageConfig {
|
||||
type: "default" | "page" | "post"; // Type of homepage: default (standard Home component), page (static page), or post (blog post)
|
||||
slug?: string; // Required if type is "page" or "post" - the slug of the page/post to use as homepage
|
||||
originalHomeRoute?: string; // Route to access the original homepage when custom homepage is set (default: "/home")
|
||||
}
|
||||
|
||||
// AI Chat configuration
|
||||
// Controls the AI writing assistant feature on Write page and content pages
|
||||
export interface AIChatConfig {
|
||||
enabledOnWritePage: boolean; // Show AI chat toggle on /write page
|
||||
enabledOnContent: boolean; // Allow AI chat on posts/pages via frontmatter aiChat: true
|
||||
}
|
||||
|
||||
// Site configuration interface
|
||||
export interface SiteConfig {
|
||||
// Basic site info
|
||||
@@ -150,6 +165,12 @@ export interface SiteConfig {
|
||||
|
||||
// Footer configuration
|
||||
footer: FooterConfig;
|
||||
|
||||
// Homepage configuration
|
||||
homepage: HomepageConfig;
|
||||
|
||||
// AI Chat configuration
|
||||
aiChat: AIChatConfig;
|
||||
}
|
||||
|
||||
// Default site configuration
|
||||
@@ -186,7 +207,7 @@ export const siteConfig: SiteConfig = {
|
||||
},
|
||||
{
|
||||
src: "/images/logos/netlify.svg",
|
||||
href: "https://www.netlify.com/",
|
||||
href: "https://www.netlify.com/utm_source=markdownfast",
|
||||
},
|
||||
{
|
||||
src: "/images/logos/firecrawl.svg",
|
||||
@@ -201,8 +222,8 @@ export const siteConfig: SiteConfig = {
|
||||
href: "https://markdown.fast/setup-guide",
|
||||
},
|
||||
{
|
||||
src: "/images/logos/sample-logo-5.svg",
|
||||
href: "https://markdown.fast/setup-guide",
|
||||
src: "/images/logos/agentmail.svg",
|
||||
href: "https://agentmail.to/utm_source=markdownfast",
|
||||
},
|
||||
],
|
||||
position: "above-footer",
|
||||
@@ -321,6 +342,24 @@ export const siteConfig: SiteConfig = {
|
||||
|
||||
Created by [Wayne](https://x.com/waynesutton) with Convex, Cursor, and Claude Opus 4.5. Follow on [Twitter/X](https://x.com/waynesutton), [LinkedIn](https://www.linkedin.com/in/waynesutton/), and [GitHub](https://github.com/waynesutton). This project is licensed under the MIT [License](https://github.com/waynesutton/markdown-site?tab=MIT-1-ov-file).`,
|
||||
},
|
||||
|
||||
// Homepage configuration
|
||||
// Set any page or blog post to serve as the homepage
|
||||
// Custom homepage uses the page/post's full content and features (sidebar, copy dropdown, etc.)
|
||||
// Featured section is NOT shown on custom homepage (only on default Home component)
|
||||
homepage: {
|
||||
type: "default", // Options: "default" (standard Home component), "page" (use a static page), or "post" (use a blog post)
|
||||
slug: "undefined", // Required if type is "page" or "post" - the slug of the page/post to use default is undefined
|
||||
originalHomeRoute: "/home", // Route to access the original homepage when custom homepage is set
|
||||
},
|
||||
|
||||
// AI Chat configuration
|
||||
// Controls the AI writing assistant powered by Claude
|
||||
// Requires ANTHROPIC_API_KEY environment variable in Convex dashboard
|
||||
aiChat: {
|
||||
enabledOnWritePage: true, // Show AI chat toggle on /write page
|
||||
enabledOnContent: true, // Allow AI chat on posts/pages via frontmatter aiChat: true
|
||||
},
|
||||
};
|
||||
|
||||
// Export the config as default for easy importing
|
||||
|
||||
@@ -18,11 +18,25 @@ const SITE_URL = "https://markdown.fast";
|
||||
const SITE_NAME = "markdown sync framework";
|
||||
const DEFAULT_OG_IMAGE = "/images/og-default.svg";
|
||||
|
||||
export default function Post() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
interface PostProps {
|
||||
slug?: string; // Optional slug prop when used as homepage
|
||||
isHomepage?: boolean; // Flag to indicate this is the homepage
|
||||
homepageType?: "page" | "post"; // Type of homepage content
|
||||
}
|
||||
|
||||
export default function Post({
|
||||
slug: propSlug,
|
||||
isHomepage = false,
|
||||
homepageType,
|
||||
}: PostProps = {}) {
|
||||
const { slug: routeSlug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { setHeadings, setActiveId } = useSidebar();
|
||||
|
||||
// Use prop slug if provided (for homepage), otherwise use route slug
|
||||
const slug = propSlug || routeSlug;
|
||||
|
||||
// Check for page first, then post
|
||||
const page = useQuery(api.pages.getPageBySlug, slug ? { slug } : "skip");
|
||||
const post = useQuery(api.posts.getPostBySlug, slug ? { slug } : "skip");
|
||||
@@ -196,8 +210,8 @@ export default function Post() {
|
||||
return (
|
||||
<div className={`post-page ${hasAnySidebar ? "post-page-with-sidebar" : ""}`}>
|
||||
<nav className={`post-nav ${hasAnySidebar ? "post-nav-with-sidebar" : ""}`}>
|
||||
{/* Hide back-button when sidebars are enabled */}
|
||||
{!hasAnySidebar && (
|
||||
{/* Hide back-button when sidebars are enabled or when used as homepage */}
|
||||
{!hasAnySidebar && !isHomepage && (
|
||||
<button onClick={() => navigate("/")} className="back-button">
|
||||
<ArrowLeft size={16} />
|
||||
<span>Back</span>
|
||||
@@ -269,8 +283,14 @@ export default function Post() {
|
||||
)}
|
||||
</article>
|
||||
|
||||
{/* Right sidebar - empty when sidebars are enabled, CopyPageDropdown moved to main content */}
|
||||
{hasRightSidebar && <RightSidebar />}
|
||||
{/* Right sidebar - with optional AI chat support */}
|
||||
{hasRightSidebar && (
|
||||
<RightSidebar
|
||||
aiChatEnabled={page.aiChat}
|
||||
pageContent={page.content}
|
||||
slug={page.slug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -319,8 +339,8 @@ export default function Post() {
|
||||
return (
|
||||
<div className={`post-page ${hasAnySidebar ? "post-page-with-sidebar" : ""}`}>
|
||||
<nav className={`post-nav ${hasAnySidebar ? "post-nav-with-sidebar" : ""}`}>
|
||||
{/* Hide back-button when sidebars are enabled */}
|
||||
{!hasAnySidebar && (
|
||||
{/* Hide back-button when sidebars are enabled or when used as homepage */}
|
||||
{!hasAnySidebar && !isHomepage && (
|
||||
<button onClick={() => navigate("/")} className="back-button">
|
||||
<ArrowLeft size={16} />
|
||||
<span>Back</span>
|
||||
@@ -475,8 +495,14 @@ export default function Post() {
|
||||
)}
|
||||
</article>
|
||||
|
||||
{/* Right sidebar - empty when sidebars are enabled, CopyPageDropdown moved to main content */}
|
||||
{hasRightSidebar && <RightSidebar />}
|
||||
{/* Right sidebar - with optional AI chat support */}
|
||||
{hasRightSidebar && (
|
||||
<RightSidebar
|
||||
aiChatEnabled={post.aiChat}
|
||||
pageContent={post.content}
|
||||
slug={post.slug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,11 +9,14 @@ import {
|
||||
File,
|
||||
Warning,
|
||||
TextAa,
|
||||
ChatCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Moon, Sun, Cloud } from "lucide-react";
|
||||
import { Half2Icon } from "@radix-ui/react-icons";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { useFont } from "../context/FontContext";
|
||||
import AIChatView from "../components/AIChatView";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
// Frontmatter field definitions for blog posts
|
||||
const POST_FIELDS = [
|
||||
@@ -37,11 +40,20 @@ const POST_FIELDS = [
|
||||
{ name: "featured", required: false, example: "true" },
|
||||
{ name: "featuredOrder", required: false, example: "1" },
|
||||
{ name: "authorName", required: false, example: '"Jane Doe"' },
|
||||
{ name: "authorImage", required: false, example: '"/images/authors/jane.png"' },
|
||||
{
|
||||
name: "authorImage",
|
||||
required: false,
|
||||
example: '"/images/authors/jane.png"',
|
||||
},
|
||||
{ name: "layout", required: false, example: '"sidebar"' },
|
||||
{ name: "rightSidebar", required: false, example: "true" },
|
||||
{ name: "showFooter", required: false, example: "true" },
|
||||
{ name: "footer", required: false, example: '"Built with [Convex](https://convex.dev)."' },
|
||||
{
|
||||
name: "footer",
|
||||
required: false,
|
||||
example: '"Built with [Convex](https://convex.dev)."',
|
||||
},
|
||||
{ name: "aiChat", required: false, example: "true" },
|
||||
];
|
||||
|
||||
// Frontmatter field definitions for pages
|
||||
@@ -56,11 +68,20 @@ const PAGE_FIELDS = [
|
||||
{ name: "featured", required: false, example: "true" },
|
||||
{ name: "featuredOrder", required: false, example: "1" },
|
||||
{ name: "authorName", required: false, example: '"Jane Doe"' },
|
||||
{ name: "authorImage", required: false, example: '"/images/authors/jane.png"' },
|
||||
{
|
||||
name: "authorImage",
|
||||
required: false,
|
||||
example: '"/images/authors/jane.png"',
|
||||
},
|
||||
{ name: "layout", required: false, example: '"sidebar"' },
|
||||
{ name: "rightSidebar", required: false, example: "true" },
|
||||
{ name: "showFooter", required: false, example: "true" },
|
||||
{ name: "footer", required: false, example: '"Built with [Convex](https://convex.dev)."' },
|
||||
{
|
||||
name: "footer",
|
||||
required: false,
|
||||
example: '"Built with [Convex](https://convex.dev)."',
|
||||
},
|
||||
{ name: "aiChat", required: false, example: "true" },
|
||||
];
|
||||
|
||||
// Generate frontmatter template based on content type
|
||||
@@ -160,8 +181,12 @@ export default function Write() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
const [font, setFont] = useState<"serif" | "sans" | "monospace">("serif");
|
||||
const [isAIChatMode, setIsAIChatMode] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Check if AI chat is enabled for write page
|
||||
const aiChatEnabled = siteConfig.aiChat.enabledOnWritePage;
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedContent = localStorage.getItem(STORAGE_KEY_CONTENT);
|
||||
@@ -188,7 +213,9 @@ export default function Write() {
|
||||
// Use saved font preference, or fall back to global font, or default to serif
|
||||
if (
|
||||
savedFont &&
|
||||
(savedFont === "serif" || savedFont === "sans" || savedFont === "monospace")
|
||||
(savedFont === "serif" ||
|
||||
savedFont === "sans" ||
|
||||
savedFont === "monospace")
|
||||
) {
|
||||
setFont(savedFont);
|
||||
} else {
|
||||
@@ -212,6 +239,12 @@ export default function Write() {
|
||||
localStorage.setItem(STORAGE_KEY_FONT, font);
|
||||
}, [font]);
|
||||
|
||||
// Prevent scroll when switching to AI chat mode
|
||||
useEffect(() => {
|
||||
// Lock scroll position to prevent jump when AI chat mounts
|
||||
window.scrollTo(0, 0);
|
||||
}, [isAIChatMode]);
|
||||
|
||||
// Toggle font between serif, sans-serif, and monospace
|
||||
const toggleFont = useCallback(() => {
|
||||
setFont((prev) => {
|
||||
@@ -325,6 +358,34 @@ export default function Write() {
|
||||
|
||||
<div className="write-nav-section">
|
||||
<span className="write-nav-label">Actions</span>
|
||||
{/* AI Chat toggle - only show if enabled in siteConfig */}
|
||||
{aiChatEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Prevent any scroll behavior during mode switch
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
setIsAIChatMode(!isAIChatMode);
|
||||
// Restore scroll position immediately after state change
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(scrollX, scrollY);
|
||||
});
|
||||
}}
|
||||
className={`write-nav-item ${isAIChatMode ? "active" : ""}`}
|
||||
title={
|
||||
isAIChatMode ? "Switch to text editor" : "Switch to AI Chat"
|
||||
}
|
||||
>
|
||||
<ChatCircle
|
||||
size={18}
|
||||
weight={isAIChatMode ? "fill" : "regular"}
|
||||
/>
|
||||
<span>{isAIChatMode ? "Text Editor" : "Agent"}</span>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleClear} className="write-nav-item">
|
||||
<Trash size={18} />
|
||||
<span>Clear</span>
|
||||
@@ -357,54 +418,68 @@ export default function Write() {
|
||||
<main className="write-main">
|
||||
<div className="write-main-header">
|
||||
<h1 className="write-main-title">
|
||||
{contentType === "post" ? "Blog Post" : "Page"}
|
||||
{isAIChatMode
|
||||
? "Agent"
|
||||
: contentType === "post"
|
||||
? "Blog Post"
|
||||
: "Page"}
|
||||
</h1>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={`write-copy-btn ${copied ? "copied" : ""}`}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={16} weight="bold" />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopySimple size={16} />
|
||||
<span>Copy All</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!isAIChatMode && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={`write-copy-btn ${copied ? "copied" : ""}`}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={16} weight="bold" />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopySimple size={16} />
|
||||
<span>Copy All</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="write-textarea"
|
||||
placeholder="Start writing your markdown..."
|
||||
spellCheck={true}
|
||||
autoComplete="off"
|
||||
autoCapitalize="sentences"
|
||||
autoFocus
|
||||
style={{ fontFamily: FONTS[font] }}
|
||||
/>
|
||||
{/* Conditionally render textarea or AI chat */}
|
||||
{isAIChatMode ? (
|
||||
<div className="write-ai-chat-container">
|
||||
<AIChatView contextId="write-page" />
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="write-textarea"
|
||||
placeholder="Start writing your markdown..."
|
||||
spellCheck={true}
|
||||
autoComplete="off"
|
||||
autoCapitalize="sentences"
|
||||
style={{ fontFamily: FONTS[font] }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer with stats */}
|
||||
<div className="write-main-footer">
|
||||
<div className="write-stats">
|
||||
<span>{words} words</span>
|
||||
<span className="write-stats-divider" />
|
||||
<span>{lines} lines</span>
|
||||
<span className="write-stats-divider" />
|
||||
<span>{characters} chars</span>
|
||||
{/* Footer with stats - only show in text editor mode */}
|
||||
{!isAIChatMode && (
|
||||
<div className="write-main-footer">
|
||||
<div className="write-stats">
|
||||
<span>{words} words</span>
|
||||
<span className="write-stats-divider" />
|
||||
<span>{lines} lines</span>
|
||||
<span className="write-stats-divider" />
|
||||
<span>{characters} chars</span>
|
||||
</div>
|
||||
<div className="write-save-hint">
|
||||
Save to{" "}
|
||||
<code>content/{contentType === "post" ? "blog" : "pages"}/</code>{" "}
|
||||
then <code>npm run sync</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="write-save-hint">
|
||||
Save to{" "}
|
||||
<code>content/{contentType === "post" ? "blog" : "pages"}/</code>{" "}
|
||||
then <code>npm run sync</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Right Sidebar: Frontmatter fields */}
|
||||
|
||||
@@ -318,6 +318,12 @@ body {
|
||||
/* padding: 40px 24px; */
|
||||
}
|
||||
|
||||
/* Expand main-content to full width when sidebar layout is used */
|
||||
.main-content:has(.post-page-with-sidebar) {
|
||||
max-width: 100%;
|
||||
padding: 0 0px;
|
||||
}
|
||||
|
||||
/* Wide content layout for pages that need more space (stats, etc.) */
|
||||
.main-content-wide {
|
||||
flex: 1;
|
||||
@@ -341,6 +347,8 @@ body {
|
||||
background-color: var(--bg-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
/* nav bar border to match sidebar if sidebar is enabled
|
||||
border-bottom: 1px solid var(--border-sidebar); */
|
||||
}
|
||||
|
||||
/* Logo in top navigation */
|
||||
@@ -655,13 +663,11 @@ body {
|
||||
/* Full-width sidebar layout - breaks out of .main-content constraints */
|
||||
.post-page-with-sidebar {
|
||||
padding-top: 0px;
|
||||
/* Break out of the 680px max-width container */
|
||||
width: calc(100vw - 48px);
|
||||
max-width: none;
|
||||
margin-left: calc(-1 * (min(100vw - 48px, 1400px) - 680px) / 2);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
position: relative;
|
||||
/* Add left padding to align content with sidebar edge
|
||||
padding-left: 24px;*/
|
||||
}
|
||||
|
||||
.post-nav {
|
||||
@@ -892,6 +898,7 @@ body {
|
||||
@media (min-width: 1135px) {
|
||||
.post-content-with-sidebar:has(.post-sidebar-right) {
|
||||
grid-template-columns: 240px 1fr 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Adjust main content padding when right sidebar exists */
|
||||
@@ -947,7 +954,7 @@ body {
|
||||
-ms-overflow-style: none; /* IE */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
background-color: var(--bg-sidebar);
|
||||
margin-right: -24px;
|
||||
margin-right: 0;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-top: 24px;
|
||||
@@ -1104,6 +1111,7 @@ body {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.post-content-with-sidebar {
|
||||
@@ -4465,7 +4473,9 @@ body {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr 280px;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden; /* Prevent any page-level scroll */
|
||||
}
|
||||
|
||||
/* Left Sidebar */
|
||||
@@ -4474,6 +4484,8 @@ body {
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
overflow-y: auto;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.write-sidebar-header {
|
||||
@@ -4577,7 +4589,14 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
max-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* When AI chat is active, ensure proper height constraints */
|
||||
.write-main:has(.write-ai-chat-container) {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.write-main-header {
|
||||
@@ -4699,6 +4718,8 @@ body {
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
overflow-y: auto;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.write-sidebar-right .write-sidebar-header {
|
||||
@@ -4862,6 +4883,15 @@ body {
|
||||
|
||||
.write-main {
|
||||
min-height: auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* On mobile, AI chat gets fixed height */
|
||||
.write-main:has(.write-ai-chat-container) {
|
||||
height: calc(100vh - 120px); /* Account for mobile nav */
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.write-main-header {
|
||||
@@ -4932,3 +4962,830 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* AI Chat Styles */
|
||||
/* Write page AI chat container */
|
||||
.write-ai-chat-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; /* Allow shrinking in flex container */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.write-ai-chat-container .ai-chat-view {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0; /* Allow shrinking in flex container */
|
||||
}
|
||||
|
||||
.ai-chat-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0; /* Allow shrinking in flex container */
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ai-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-chat-title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-load-context-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-load-context-button:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-context-loaded {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.ai-chat-clear-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-clear-button:hover:not(:disabled) {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-clear-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ai-chat-close-button {
|
||||
padding: 6px 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-close-button:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-messages {
|
||||
flex: 1;
|
||||
min-height: 0; /* Critical: allows flex item to shrink below content size */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.ai-chat-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.ai-chat-empty p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.ai-chat-empty-hint {
|
||||
font-size: var(--font-size-xs);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ai-chat-message {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-width: 85%;
|
||||
animation: aiChatFadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes aiChatFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-chat-message-user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.ai-chat-message-assistant {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.ai-chat-message-content {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.ai-chat-message-user .ai-chat-message-content {
|
||||
background-color: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-message-assistant .ai-chat-message-content {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-message-content p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.ai-chat-message-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ai-chat-message-content h1,
|
||||
.ai-chat-message-content h2,
|
||||
.ai-chat-message-content h3,
|
||||
.ai-chat-message-content h4,
|
||||
.ai-chat-message-content h5,
|
||||
.ai-chat-message-content h6 {
|
||||
margin: 12px 0 8px;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-chat-message-content h1:first-child,
|
||||
.ai-chat-message-content h2:first-child,
|
||||
.ai-chat-message-content h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.ai-chat-message-content ul,
|
||||
.ai-chat-message-content ol {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.ai-chat-message-content li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-message-content code {
|
||||
font-family:
|
||||
"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 2px 6px;
|
||||
background-color: var(--bg-hover);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-message-content pre {
|
||||
margin: 8px 0;
|
||||
padding: 12px;
|
||||
background-color: var(--bg-hover);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ai-chat-message-content pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.ai-chat-message-content a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ai-chat-message-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ai-chat-message-content blockquote {
|
||||
margin: 8px 0;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ai-chat-copy-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-message:hover .ai-chat-copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ai-chat-copy-button:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ai-chat-loading-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
border-bottom-left-radius: 4px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ai-chat-spinner {
|
||||
animation: aiChatSpin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes aiChatSpin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-chat-stop-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
color: #ef4444;
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-stop-button:hover {
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.ai-chat-stopped {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-error {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.ai-chat-error .ai-chat-message-content {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.ai-chat-input-container {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.ai-chat-input-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-input-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-chat-attach-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-attach-button:hover:not(:disabled) {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-attach-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ai-chat-input-wrapper:focus-within {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ai-chat-input {
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
max-height: 200px;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
resize: none;
|
||||
outline: none;
|
||||
/* Prevent scroll jump when focusing */
|
||||
scroll-margin: 0;
|
||||
}
|
||||
|
||||
.ai-chat-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ai-chat-send-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
color: var(--bg-primary);
|
||||
background-color: var(--text-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-chat-send-button:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.ai-chat-send-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Attachments preview */
|
||||
.ai-chat-attachments-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-preview {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-preview-image {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-preview-url {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
background-color: var(--bg-hover);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-remove:hover {
|
||||
background-color: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Link input modal */
|
||||
.ai-chat-link-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: backdropFadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-content {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-content h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-link-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-link-input:focus {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-cancel,
|
||||
.ai-chat-link-modal-add {
|
||||
padding: 6px 12px;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-cancel {
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-cancel:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-add {
|
||||
color: var(--bg-primary);
|
||||
background-color: var(--text-primary);
|
||||
border-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-add:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-add:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Attachments in messages */
|
||||
.ai-chat-attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-attachment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-image {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-loading {
|
||||
padding: 8px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
padding: 6px 10px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
font-size: var(--font-size-xs);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-link:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* AI Chat in Write Page - overrides for Write context */
|
||||
.write-ai-chat-container .ai-chat-view {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* AI Chat toggle button in Write nav */
|
||||
.write-nav-item.ai-chat-active {
|
||||
background-color: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.write-nav-item.ai-chat-active:hover {
|
||||
background-color: var(--text-primary);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* AI Chat in Right Sidebar */
|
||||
.right-sidebar-ai-chat {
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-view {
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-header {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-title {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-messages {
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-message {
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-message-content {
|
||||
padding: 8px 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-input-container {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-input {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-send-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* Theme-specific AI Chat styles */
|
||||
:root[data-theme="dark"] .ai-chat-message-user .ai-chat-message-content {
|
||||
background-color: #f5f5f5;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .ai-chat-message-content code {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .ai-chat-message-content pre {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:root[data-theme="tan"] .ai-chat-message-user .ai-chat-message-content {
|
||||
background-color: #1a1a1a;
|
||||
color: #faf8f5;
|
||||
}
|
||||
|
||||
:root[data-theme="cloud"] .ai-chat-message-user .ai-chat-message-content {
|
||||
background-color: #1e293b;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
/* Mobile responsive AI Chat */
|
||||
@media (max-width: 768px) {
|
||||
.ai-chat-header {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.ai-chat-title {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.ai-chat-messages {
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ai-chat-message {
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.ai-chat-message-content {
|
||||
padding: 8px 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.ai-chat-input-container {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.ai-chat-load-context-button span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-chat-close-button {
|
||||
padding: 6px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ai-chat-copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ai-chat-attachments-preview {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-preview-image {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-preview-url {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-content {
|
||||
padding: 16px;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-image {
|
||||
max-width: 150px;
|
||||
max-height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ai-chat-header-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-empty {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.ai-chat-empty p {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user