feat: Add user likes tab and local timeline, update branding assets

- Add likes tab to user profiles displaying user's liked posts
- Implement new `/api/users/[handle]/likes` endpoint to fetch user's liked posts
- Add local timeline feed type to show only local node posts without fediverse content
- Update favicon and add new logotext branding asset
- Enhance search results with isLiked and isReposted status for authenticated users
- Populate like and repost information in search posts endpoint
- Update profile page tabs to conditionally show likes tab for non-bot users
- Add loading states and empty state messaging for likes tab
- Remove deprecated guide page
- Improve bot settings page and content generation logic
This commit is contained in:
AskIt
2026-01-25 19:45:34 +01:00
parent 465bd8b60c
commit d36c1c93e5
14 changed files with 509 additions and 289 deletions
+61 -9
View File
@@ -108,6 +108,11 @@ export const MAX_SOURCE_CONTENT_LENGTH = 4000;
*/
export const MAX_CONVERSATION_CONTEXT_LENGTH = 2000;
/**
* Maximum character length for previous posts context.
*/
export const MAX_PREVIOUS_POSTS_CONTEXT_LENGTH = 8000;
/**
* Truncation suffix added to truncated content.
*/
@@ -212,18 +217,21 @@ export function isContentTruncated(content: string): boolean {
* Includes personality context and instructions.
*
* @param personality - Bot's personality configuration
* @param hasSourceContent - Whether the post has source content with a URL
* @returns System prompt string
*
* Validates: Requirements 3.2, 11.1
*/
export function buildPostSystemPrompt(personality: PersonalityConfig): string {
export function buildPostSystemPrompt(personality: PersonalityConfig, hasSourceContent: boolean = true): string {
let prompt = personality.systemPrompt;
if (personality.responseStyle) {
prompt += `\n\nResponse Style: ${personality.responseStyle}`;
}
prompt += `\n\nIMPORTANT: Your posts MUST be under 450 characters (not including the URL). This leaves room for the source link. This is a strict limit.
if (hasSourceContent) {
// Instructions for posts with source content (include URL)
prompt += `\n\nIMPORTANT: Your posts MUST be under 450 characters (not including the URL). This leaves room for the source link. This is a strict limit.
Instructions for creating posts:
- Create engaging, original content based on the source material
@@ -233,7 +241,22 @@ Instructions for creating posts:
- Do not simply copy or summarize - add value with your unique voice
- Do NOT use hashtags
- Write like a human, not a marketing bot
- AVOID repeating themes, phrases, or sentence structures from your previous posts
- Format: [Your commentary] [URL]`;
} else {
// Instructions for original posts without source content (no URL needed)
prompt += `\n\nIMPORTANT: Your posts MUST be under 500 characters. This is a strict limit.
Instructions for creating posts:
- Create engaging, original content that fits your personality
- Keep the text concise - aim for 100-400 characters
- Do NOT include any URLs or links
- Do NOT use hashtags
- Write like a human, not a marketing bot
- Be creative and stay in character
- AVOID repeating themes, phrases, or sentence structures from your previous posts
- Each post should feel fresh and different while maintaining your voice`;
}
return prompt;
}
@@ -294,22 +317,48 @@ Respond with a JSON object containing:
*
* @param sourceContent - Source content to post about
* @param context - Optional additional context
* @param previousPosts - Optional array of previous post contents for context
* @returns User message string
*/
export function buildPostUserMessage(
sourceContent?: ContentItem,
context?: string
context?: string,
previousPosts?: string[]
): string {
let message = '';
// Add previous posts context if available
if (previousPosts && previousPosts.length > 0) {
message += 'Your recent posts (for context - avoid repeating similar content):\n';
// Truncate previous posts if too long
let contextLength = 0;
const relevantPosts: string[] = [];
for (const post of previousPosts) {
if (contextLength + post.length > MAX_PREVIOUS_POSTS_CONTEXT_LENGTH) {
break;
}
relevantPosts.push(`- ${post}`);
contextLength += post.length;
}
message += relevantPosts.join('\n');
message += '\n\n---\n\n';
}
if (!sourceContent) {
if (context) {
return `Create a post about the following:\n\n${context}`;
message += `Create a post about the following:\n\n${context}`;
} else {
message += 'Create an engaging post for your followers.';
}
return 'Create an engaging post for your followers.';
return message;
}
const truncatedContent = truncateContent(sourceContent.content || '');
let message = `Create a post about the following content:\n\n`;
message += `Create a post about the following content:\n\n`;
message += `Title: ${sourceContent.title}\n`;
message += `URL: ${sourceContent.url}\n`;
@@ -426,16 +475,19 @@ export class ContentGenerator {
*
* @param sourceContent - Optional source content to post about
* @param context - Optional additional context
* @param previousPosts - Optional array of previous post contents for context
* @returns Generated content with token usage
*
* Validates: Requirements 3.2, 11.1, 11.2, 11.3
*/
async generatePost(
sourceContent?: ContentItem,
context?: string
context?: string,
previousPosts?: string[]
): Promise<GeneratedContent> {
const systemPrompt = buildPostSystemPrompt(this.bot.personalityConfig);
const userMessage = buildPostUserMessage(sourceContent, context);
const hasSourceContent = !!sourceContent;
const systemPrompt = buildPostSystemPrompt(this.bot.personalityConfig, hasSourceContent);
const userMessage = buildPostUserMessage(sourceContent, context, previousPosts);
const messages: LLMMessage[] = [
{ role: 'system', content: systemPrompt },
+69 -20
View File
@@ -261,6 +261,40 @@ async function getNextUnprocessedContentItem(botId: string): Promise<ContentItem
return null;
}
/**
* Get the bot's previous posts for context.
* Returns the content of the most recent posts to help avoid repetition.
*
* @param botId - The bot ID
* @param limit - Maximum number of posts to fetch (default: 40)
* @returns Array of post content strings
*/
async function getBotPreviousPosts(botId: string, limit: number = 40): Promise<string[]> {
// Get bot to find its user ID
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
});
if (!bot) {
return [];
}
// Get the bot's recent posts
const recentPosts = await db.query.posts.findMany({
where: eq(posts.userId, bot.userId),
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
limit,
columns: {
content: true,
},
});
// Return just the content strings
return recentPosts
.map(p => p.content)
.filter((content): content is string => !!content);
}
/**
* Mark a content item as processed.
*
@@ -507,6 +541,7 @@ export async function selectContentForPosting(
* @param bot - The bot (from database)
* @param contentItem - Optional content item to post about
* @param context - Optional additional context
* @param previousPosts - Optional array of previous post contents for context
* @returns Generated post text
*
* Validates: Requirements 11.6
@@ -514,7 +549,8 @@ export async function selectContentForPosting(
export async function generatePostContent(
bot: typeof bots.$inferSelect & { user: { handle: string } },
contentItem?: ContentItem,
context?: string
context?: string,
previousPosts?: string[]
): Promise<string> {
// Check if bot has API key
try {
@@ -539,7 +575,7 @@ export async function generatePostContent(
try {
// Generate post (Requirement 11.6)
const generatedContent = await generator.generatePost(contentItem, context);
const generatedContent = await generator.generatePost(contentItem, context, previousPosts);
// Log the generation
await logActivity(
@@ -550,6 +586,7 @@ export async function generatePostContent(
contentItemId: contentItem?.id,
tokensUsed: generatedContent.tokensUsed,
model: generatedContent.model,
previousPostsCount: previousPosts?.length || 0,
},
true
);
@@ -995,15 +1032,21 @@ export async function triggerPost(
});
}
// Select content (Requirement 5.4)
const contentItem = await selectContentForPosting(botId, sourceContentId);
if (!contentItem) {
return {
success: false,
error: 'No content available for posting',
errorCode: 'NO_CONTENT',
};
// Select content (Requirement 5.4) - optional, bots can post without sources
let contentItem: ContentItem | null = null;
if (sourceContentId) {
contentItem = await selectContentForPosting(botId, sourceContentId);
if (!contentItem) {
return {
success: false,
error: 'Specified content not found',
errorCode: 'CONTENT_NOT_FOUND',
};
}
} else {
// Try to get content, but don't fail if none available
contentItem = await selectContentForPosting(botId);
}
// Get bot from database for generation
@@ -1020,8 +1063,12 @@ export async function triggerPost(
};
}
// Fetch previous posts for context (helps avoid repetition)
const previousPosts = await getBotPreviousPosts(botId, 40);
// Generate post content (Requirement 11.6)
let postContent = await generatePostContent(dbBot, contentItem, context);
// Content item is optional - bot can generate posts based on personality alone
let postContent = await generatePostContent(dbBot, contentItem || undefined, context, previousPosts);
// Sanitize content
postContent = sanitizePostContent(postContent);
@@ -1036,7 +1083,7 @@ export async function triggerPost(
'error',
{
type: 'validation',
contentItemId: contentItem.id,
contentItemId: contentItem?.id || null,
errors: validation.errors,
content: postContent,
},
@@ -1048,21 +1095,23 @@ export async function triggerPost(
success: false,
error: `Post validation failed: ${validation.errors.join(', ')}`,
errorCode: 'VALIDATION_FAILED',
contentItem,
contentItem: contentItem || undefined,
};
}
}
// Create post in database with source URL for link preview
const post = await createPostInDatabase(botId, postContent, contentItem.url);
// Create post in database with source URL for link preview (if content item exists)
const post = await createPostInDatabase(botId, postContent, contentItem?.url);
// Record post for rate limiting
if (!skipRateLimitCheck) {
await recordPost(botId);
}
// Mark content item as processed
await markContentItemProcessed(contentItem.id, post.id);
// Mark content item as processed (only if we used one)
if (contentItem) {
await markContentItemProcessed(contentItem.id, post.id);
}
// Log successful post creation
await logActivity(
@@ -1070,7 +1119,7 @@ export async function triggerPost(
'post_created',
{
postId: post.id,
contentItemId: contentItem.id,
contentItemId: contentItem?.id || null,
contentLength: postContent.length,
},
true
@@ -1082,7 +1131,7 @@ export async function triggerPost(
return {
success: true,
post,
contentItem,
contentItem: contentItem || undefined,
};
} catch (error) {
// Log error