wip: a2ui working great now

This commit is contained in:
2026-02-26 11:20:03 +01:00
parent 00a9d22a36
commit cf57879d1f
4 changed files with 273 additions and 29 deletions

View File

@@ -306,11 +306,12 @@ IMPORTANT: You do NOT have access to the internet, real-time data, or any extern
You can ONLY access information through the tools listed below. Do not claim otherwise.
Available Data Tools:
- search_posts: Search blog posts using full-text search. Supports category/tag filters.
- get_blog_stats: Get comprehensive blog statistics (total posts, date range, posts per year, tag/category counts, media count). ALWAYS call this first when you need to understand the scope of the data.
- search_posts: Search blog posts using full-text search. Supports category/tag filters and pagination (offset/limit).
- read_post: Read the full content and metadata of a specific post by ID.
- list_posts: List posts with optional filtering by status, category, or tags.
- list_posts: List posts with optional filtering by status, category, or tags. Supports pagination (offset/limit). Returns "total" (global count) and "filteredTotal" (matching filter).
- get_media: Get information about a specific media file by ID.
- list_media: List media files with optional MIME type filtering.
- list_media: List media files with optional MIME type filtering. Supports pagination (offset/limit).
- view_image: View an image to analyze its visual content. Use this when you need to describe or analyze what an image looks like.
- update_post_metadata: Update a post's title, excerpt, tags, or categories.
- update_media_metadata: Update a media file's title, alt text, caption, or tags.
@@ -339,7 +340,14 @@ When answering questions:
6. When presenting data, statistics, or comparisons, prefer using render tools (render_chart, render_table, render_metric) to show rich interactive UI instead of plain text.
7. When you need user input for a multi-field operation, use render_form to present a structured form.
8. Use render_card with action buttons when presenting items the user might want to navigate to (e.g., posts, media).
9. When comparing data across multiple dimensions (e.g., statistics per year), use render_tabs with embedded charts or tables in each tab.`;
9. When comparing data across multiple dimensions (e.g., statistics per year), use render_tabs with embedded charts or tables in each tab.
CRITICAL - Pagination and data volume awareness:
10. This blog may contain thousands or tens of thousands of posts spanning many years. NEVER assume the first page of results represents all data.
11. Always check the "total" and "filteredTotal" fields in list_posts and list_media responses. If total > limit, there are more results available via pagination.
12. When asked to analyze ALL posts (e.g., "show me all posts from 2015"), use pagination (offset/limit) to fetch all pages, or use get_blog_stats first to understand the scope.
13. When reporting counts or statistics, always use get_blog_stats or check the total fields rather than counting the items in a single page of results.
14. Never claim there are only N posts when you have only fetched one page. State the total count from the API response.`;
}
/**

View File

@@ -13,7 +13,7 @@ import http from 'http';
import { URL } from 'url';
import { BrowserWindow } from 'electron';
import { ChatEngine } from './ChatEngine';
import { PostEngine } from './PostEngine';
import { PostEngine, type PostData } from './PostEngine';
import { MediaEngine } from './MediaEngine';
import { getPostMediaEngine } from './PostMediaEngine';
import { isRenderTool, generateFromToolCall } from '../a2ui/generator';
@@ -274,7 +274,10 @@ export class OpenCodeManager {
// Get system prompt
const systemMessage = conversation.messages.find(m => m.role === 'system');
const systemPrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt();
const basePrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt();
// Inject live blog stats into system prompt for data volume awareness
const systemPrompt = await this.appendBlogStats(basePrompt);
// Build message history from DB (excluding system messages)
const dbMessages = conversation.messages.filter(m => m.role !== 'system');
@@ -733,7 +736,7 @@ export class OpenCodeManager {
return [
{
name: 'search_posts',
description: 'Search blog posts using full-text search. Can filter by category or tags. Returns matching posts with their metadata.',
description: 'Search blog posts using full-text search. Can filter by category or tags. Returns paginated results with totalMatches count. Use offset to page through results when totalMatches > limit.',
input_schema: {
type: 'object',
properties: {
@@ -741,6 +744,7 @@ export class OpenCodeManager {
category: { type: 'string', description: 'Optional category to filter by (e.g., "article", "picture", "aside", "page")' },
tags: { type: 'array', items: { type: 'string' }, description: 'Optional array of tags to filter by' },
limit: { type: 'number', description: 'Maximum number of results to return (default: 10)' },
offset: { type: 'number', description: 'Offset for pagination (default: 0). Use with limit to page through results.' },
},
required: ['query'],
},
@@ -758,7 +762,7 @@ export class OpenCodeManager {
},
{
name: 'list_posts',
description: 'List blog posts with optional filtering by status, category, or tags.',
description: 'List blog posts with optional filtering by status, category, or tags. Returns paginated results. The response includes "total" (global post count in the blog) and "filteredTotal" (count matching current filters). Use offset/limit to page through all results. Always check total to understand the full data volume.',
input_schema: {
type: 'object',
properties: {
@@ -783,12 +787,13 @@ export class OpenCodeManager {
},
{
name: 'list_media',
description: 'List all media files in the current project with optional filtering.',
description: 'List media files in the current project with optional filtering. Returns paginated results with total count. Use offset/limit to page through all results.',
input_schema: {
type: 'object',
properties: {
mimeTypeFilter: { type: 'string', description: 'Filter by MIME type prefix (e.g., "image/")' },
limit: { type: 'number', description: 'Maximum number of results (default: 20)' },
offset: { type: 'number', description: 'Offset for pagination (default: 0)' },
},
},
},
@@ -838,6 +843,14 @@ export class OpenCodeManager {
properties: {},
},
},
{
name: 'get_blog_stats',
description: 'Get comprehensive blog statistics: total posts, drafts, published, archived counts, date range (oldest to newest post), posts per year breakdown, number of unique tags and categories, and total media count. Use this FIRST to understand the full scope of the blog before making queries. This is essential to understand the data volume.',
input_schema: {
type: 'object',
properties: {},
},
},
{
name: 'view_image',
description: 'View an image to analyze its visual content. Returns the actual image for visual inspection. Use this when you need to see, describe, or analyze what an image looks like. Only works with image files (not PDFs or other media types).',
@@ -1094,12 +1107,18 @@ export class OpenCodeManager {
);
}
const totalMatches = filteredPosts.length;
const offset = (args.offset as number) || 0;
const limit = (args.limit as number) || 10;
filteredPosts = filteredPosts.slice(0, limit);
filteredPosts = filteredPosts.slice(offset, offset + limit);
return {
success: true,
count: filteredPosts.length,
totalMatches,
hasMore: offset + limit < totalMatches,
offset,
limit,
posts: filteredPosts.map(p => ({
id: p!.id, title: p!.title, slug: p!.slug,
excerpt: p!.excerpt, status: p!.status,
@@ -1131,27 +1150,35 @@ export class OpenCodeManager {
if (args.tags) filter.tags = args.tags as string[];
if (args.category) filter.categories = [args.category as string];
let posts;
if (Object.keys(filter).length > 0) {
posts = await this.postEngine.getPostsFiltered(filter);
} else {
const result = await this.postEngine.getAllPosts({
limit: (args.limit as number) || 20,
offset: (args.offset as number) || 0,
});
posts = result.items;
}
const offset = (args.offset as number) || 0;
const limit = (args.limit as number) || 20;
const slicedPosts = posts.slice(offset, offset + limit);
// Always get global total for awareness
const globalStats = await this.postEngine.getDashboardStats();
const globalTotal = globalStats.totalPosts;
let pageItems: PostData[];
let filteredTotal: number;
if (Object.keys(filter).length > 0) {
const allFiltered = await this.postEngine.getPostsFiltered(filter);
filteredTotal = allFiltered.length;
pageItems = allFiltered.slice(offset, offset + limit);
} else {
const result = await this.postEngine.getAllPosts({ limit, offset });
pageItems = result.items;
filteredTotal = result.total;
}
return {
success: true,
count: slicedPosts.length,
total: posts.length,
hasMore: offset + limit < posts.length,
posts: slicedPosts.map(p => ({
count: pageItems.length,
total: globalTotal,
filteredTotal,
hasMore: offset + limit < filteredTotal,
offset,
limit,
posts: pageItems.map(p => ({
id: p.id, title: p.title, slug: p.slug,
status: p.status, categories: p.categories,
tags: p.tags, createdAt: p.createdAt, updatedAt: p.updatedAt,
@@ -1176,15 +1203,23 @@ export class OpenCodeManager {
case 'list_media': {
let mediaList = await this.mediaEngine.getAllMedia();
const totalMedia = mediaList.length;
if (args.mimeTypeFilter) {
mediaList = mediaList.filter(m => m.mimeType.startsWith(args.mimeTypeFilter as string));
}
const filteredTotal = mediaList.length;
const offset = (args.offset as number) || 0;
const limit = (args.limit as number) || 20;
mediaList = mediaList.slice(0, limit);
const pageItems = mediaList.slice(offset, offset + limit);
return {
success: true,
count: mediaList.length,
media: mediaList.map(m => ({
count: pageItems.length,
total: totalMedia,
filteredTotal,
hasMore: offset + limit < filteredTotal,
offset,
limit,
media: pageItems.map(m => ({
id: m.id, filename: m.filename,
originalName: m.originalName, mimeType: m.mimeType,
title: m.title, alt: m.alt, tags: m.tags,
@@ -1360,6 +1395,25 @@ export class OpenCodeManager {
};
}
case 'get_blog_stats': {
const stats = await this.postEngine.getBlogStats();
const mediaList = await this.mediaEngine.getAllMedia();
return {
success: true,
totalPosts: stats.totalPosts,
draftCount: stats.draftCount,
publishedCount: stats.publishedCount,
archivedCount: stats.archivedCount,
dateRange: stats.oldestPostDate && stats.newestPostDate
? { oldest: stats.oldestPostDate, newest: stats.newestPostDate }
: null,
postsPerYear: stats.postsPerYear,
tagCount: stats.tagCount,
categoryCount: stats.categoryCount,
totalMedia: mediaList.length,
};
}
default:
return { success: false, error: `Unknown tool: ${name}` };
}
@@ -1481,6 +1535,45 @@ export class OpenCodeManager {
// ── Helpers ──
/**
* Append live blog statistics to the system prompt so the AI
* knows the true scale of the data before its first tool call.
*/
private async appendBlogStats(basePrompt: string): Promise<string> {
try {
const stats = await this.postEngine.getBlogStats();
const mediaList = await this.mediaEngine.getAllMedia();
if (stats.totalPosts === 0) {
return basePrompt;
}
const dateRange = stats.oldestPostDate && stats.newestPostDate
? `from ${stats.oldestPostDate.toISOString().split('T')[0]} to ${stats.newestPostDate.toISOString().split('T')[0]}`
: 'unknown';
const yearBreakdown = Object.entries(stats.postsPerYear)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([year, count]) => `${year}: ${count}`)
.join(', ');
const statsSummary = `
--- CURRENT BLOG DATA SUMMARY ---
Total posts: ${stats.totalPosts} (${stats.publishedCount} published, ${stats.draftCount} drafts, ${stats.archivedCount} archived)
Date range: ${dateRange}
Posts per year: ${yearBreakdown}
Unique tags: ${stats.tagCount}, Unique categories: ${stats.categoryCount}
Total media files: ${mediaList.length}
NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all data. Default page size is 20.`;
return basePrompt + statsSummary;
} catch (error) {
console.error('[OpenCodeManager] Failed to append blog stats:', error);
return basePrompt;
}
}
private detectProvider(modelId: string): string {
const id = modelId.toLowerCase();
if (id.startsWith('claude')) return 'anthropic';

View File

@@ -958,6 +958,67 @@ export class PostEngine extends EventEmitter {
};
}
async getBlogStats(): Promise<{
totalPosts: number;
draftCount: number;
publishedCount: number;
archivedCount: number;
oldestPostDate: Date | null;
newestPostDate: Date | null;
postsPerYear: Record<number, number>;
tagCount: number;
categoryCount: number;
}> {
const db = getDatabase().getLocal();
const dbPosts = await db
.select({ status: posts.status, createdAt: posts.createdAt, tags: posts.tags, categories: posts.categories })
.from(posts)
.where(eq(posts.projectId, this.currentProjectId))
.all();
let draftCount = 0;
let publishedCount = 0;
let archivedCount = 0;
let oldestPostDate: Date | null = null;
let newestPostDate: Date | null = null;
const postsPerYear: Record<number, number> = {};
const uniqueTags = new Set<string>();
const uniqueCategories = new Set<string>();
for (const row of dbPosts) {
switch (row.status) {
case 'draft': draftCount++; break;
case 'published': publishedCount++; break;
case 'archived': archivedCount++; break;
}
const created = row.createdAt;
if (!oldestPostDate || created < oldestPostDate) oldestPostDate = created;
if (!newestPostDate || created > newestPostDate) newestPostDate = created;
const year = created.getFullYear();
postsPerYear[year] = (postsPerYear[year] || 0) + 1;
const parsedTags: string[] = JSON.parse(row.tags || '[]');
for (const tag of parsedTags) uniqueTags.add(tag);
const parsedCategories: string[] = JSON.parse(row.categories || '[]');
for (const cat of parsedCategories) uniqueCategories.add(cat);
}
return {
totalPosts: dbPosts.length,
draftCount,
publishedCount,
archivedCount,
oldestPostDate,
newestPostDate,
postsPerYear,
tagCount: uniqueTags.size,
categoryCount: uniqueCategories.size,
};
}
async getPostsByYearMonth(): Promise<{ year: number; month: number; count: number }[]> {
const allPosts = await this.getAllPostsUnpaginated();
const counts = new Map<string, { year: number; month: number; count: number }>();

View File

@@ -2619,6 +2619,88 @@ Published snapshot content`);
});
});
describe('getBlogStats', () => {
it('should return comprehensive blog statistics', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
orderBy: vi.fn().mockReturnThis(),
all: vi.fn().mockResolvedValue([
{ status: 'draft', createdAt: new Date('2015-03-10'), tags: '["travel","photo"]', categories: '["article"]' },
{ status: 'published', createdAt: new Date('2016-07-22'), tags: '["tech"]', categories: '["article"]' },
{ status: 'published', createdAt: new Date('2020-01-05'), tags: '["travel"]', categories: '["aside"]' },
{ status: 'published', createdAt: new Date('2024-11-30'), tags: '["tech","ai"]', categories: '["article"]' },
{ status: 'archived', createdAt: new Date('2018-06-15'), tags: '[]', categories: '["page"]' },
]),
});
return chain;
});
const result = await postEngine.getBlogStats();
expect(result.totalPosts).toBe(5);
expect(result.draftCount).toBe(1);
expect(result.publishedCount).toBe(3);
expect(result.archivedCount).toBe(1);
expect(result.oldestPostDate).toEqual(new Date('2015-03-10'));
expect(result.newestPostDate).toEqual(new Date('2024-11-30'));
expect(result.postsPerYear).toEqual({
2015: 1,
2016: 1,
2018: 1,
2020: 1,
2024: 1,
});
expect(result.tagCount).toBe(4);
expect(result.categoryCount).toBe(3);
});
it('should handle empty project', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
orderBy: vi.fn().mockReturnThis(),
all: vi.fn().mockResolvedValue([]),
});
return chain;
});
const result = await postEngine.getBlogStats();
expect(result.totalPosts).toBe(0);
expect(result.draftCount).toBe(0);
expect(result.publishedCount).toBe(0);
expect(result.archivedCount).toBe(0);
expect(result.oldestPostDate).toBeNull();
expect(result.newestPostDate).toBeNull();
expect(result.postsPerYear).toEqual({});
expect(result.tagCount).toBe(0);
expect(result.categoryCount).toBe(0);
});
it('should count unique tags and categories', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
orderBy: vi.fn().mockReturnThis(),
all: vi.fn().mockResolvedValue([
{ status: 'published', createdAt: new Date('2023-01-01'), tags: '["a","b","c"]', categories: '["x"]' },
{ status: 'published', createdAt: new Date('2023-06-01'), tags: '["b","c","d"]', categories: '["x","y"]' },
]),
});
return chain;
});
const result = await postEngine.getBlogStats();
expect(result.tagCount).toBe(4); // a, b, c, d
expect(result.categoryCount).toBe(2); // x, y
});
});
describe('extractInternalLinks', () => {
it('should extract markdown-style internal links', () => {
const content = 'Check out [my post](/posts/my-post) for more info.';