Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2cbd313
feat(afs): implement i18n driver for multilingual support and enhance…
lban2049 Dec 16, 2025
3c575f1
fix(view-processor, metadata): improve error handling and enhance met…
lban2049 Dec 16, 2025
c32812c
feat(i18n-driver): implement initial i18n driver with built-in transl…
lban2049 Dec 16, 2025
85f1b2b
feat(afs): enhance read functionality with AFSReadResult and view sta…
lban2049 Dec 16, 2025
9b8c5a3
feat(i18n-driver): add context support for translation and enhance dr…
lban2049 Dec 16, 2025
4657e50
feat(afs): add test for loading AIAgent with AFS drivers and modules …
lban2049 Dec 17, 2025
942203c
fix: polish code
lban2049 Dec 17, 2025
760c539
feat(afs): introduce storage configuration for AFS options and update…
lban2049 Dec 17, 2025
0c63610
refactor(afs, metadata): update metadata handling to include module i…
lban2049 Dec 17, 2025
bb1e7c7
fix: polish code
lban2049 Dec 25, 2025
15894fe
fix: polish code
lban2049 Dec 25, 2025
3e237d0
fix: polish code
lban2049 Dec 25, 2025
089387b
fix: polish code
lban2049 Dec 25, 2025
0918b73
fix: polish code
lban2049 Dec 25, 2025
cec1e60
fix: polish code
lban2049 Dec 25, 2025
cc64462
feat(afs): implement SlotScanner for parsing image slots and enhance …
lban2049 Dec 26, 2025
5562968
feat(image-driver): add AFS image generation driver with support for …
lban2049 Dec 26, 2025
a5ebb75
fix: polish code
lban2049 Dec 27, 2025
8e109f7
fix: polish default generate image agent
lban2049 Dec 27, 2025
7dfd732
Merge branch 'main' into feat-afs-view-driver
li-yechao Dec 29, 2025
e3e033c
fix: polish code
lban2049 Dec 29, 2025
7d88d78
Merge branch 'feat-afs-view-driver' of https://github.com/AIGNE-io/ai…
lban2049 Dec 29, 2025
cf983f5
fix: add task design
lban2049 Dec 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions afs/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"postbuild": "node ../../scripts/post-build-lib.mjs"
},
"dependencies": {
"@aigne/sqlite": "workspace:^",
"p-limit": "^6.1.0",
"strict-event-emitter": "^0.5.1",
"ufo": "^1.6.1",
"zod": "^3.25.67"
Expand Down
216 changes: 203 additions & 13 deletions afs/core/src/afs.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Emitter } from "strict-event-emitter";
import { joinURL } from "ufo";
import { z } from "zod";
import { SQLiteMetadataStore } from "./metadata/index.js";
import {
type AFSContext,
type AFSContextPreset,
type AFSDeleteOptions,
type AFSDeleteResult,
type AFSDriver,
type AFSEntry,
type AFSExecOptions,
type AFSExecResult,
Expand All @@ -27,7 +29,10 @@ import {
type AFSWriteOptions,
type AFSWriteResult,
afsEntrySchema,
type View,
} from "./type.js";
import { normalizeViewKey } from "./view-key.js";
import { ViewProcessor } from "./view-processor.js";

const DEFAULT_MAX_DEPTH = 1;

Expand All @@ -36,20 +41,48 @@ const MODULES_ROOT_DIR = "/modules";
export interface AFSOptions {
modules?: AFSModule[];
context?: AFSContext;
drivers?: AFSDriver[];
storage?: {
url: string; // Storage path for AFS data, default: ".afs"
};
}

export class AFS extends Emitter<AFSRootEvents> implements AFSRoot {
name: string = "AFSRoot";

private modules = new Map<string, AFSModule>();
private _drivers: AFSDriver[] = [];
private metadataStore?: SQLiteMetadataStore;
private viewProcessor?: ViewProcessor;

constructor(public options: AFSOptions = {}) {
super();

// Mount modules
for (const module of options?.modules ?? []) {
this.mount(module);
}

// Initialize drivers
this._drivers = options?.drivers ?? [];

// Initialize metadata store and view processor if drivers are present
if (this._drivers.length > 0) {
const storageUrl = options?.storage?.url || ".afs";
const metadataPath = `file:${storageUrl}/metadata.db`;
this.metadataStore = new SQLiteMetadataStore({ url: metadataPath });
this.viewProcessor = new ViewProcessor(this.metadataStore, this._drivers);

// Mount drivers
for (const driver of this._drivers) {
driver.onMount?.(this);
}
}
}

private modules = new Map<string, AFSModule>();
get drivers(): AFSDriver[] {
return this._drivers;
}

mount(module: AFSModule): this {
let path = joinURL("/", module.name);
Expand Down Expand Up @@ -136,20 +169,36 @@ export class AFS extends Emitter<AFSRootEvents> implements AFSRoot {
return { data: results };
}

async read(path: string, _options?: AFSReadOptions): Promise<AFSReadResult> {
async read(path: string, options?: AFSReadOptions): Promise<AFSReadResult> {
const modules = this.findModules(path, { exactMatch: true });

for (const { module, modulePath, subpath } of modules) {
const res = await module.read?.(subpath);

if (res?.data) {
return {
...res,
data: {
...res.data,
path: joinURL(modulePath, res.data.path),
},
};
// If view is requested and we have a view processor, use it
if (options?.view && this.viewProcessor) {
const res = await this.viewProcessor.handleRead(module, subpath, options, options.context);

if (res?.data) {
return {
...res,
data: {
...res.data,
path: joinURL(modulePath, res.data.path),
},
};
}
} else {
// No view requested, read normally
const res = await module.read?.(subpath, options);

if (res?.data) {
return {
...res,
data: {
...res.data,
path: joinURL(modulePath, res.data.path),
},
};
}
}
}

Expand All @@ -166,6 +215,11 @@ export class AFS extends Emitter<AFSRootEvents> implements AFSRoot {

const res = await module.module.write(module.subpath, content, options);

// Update metadata if view processor is available
if (this.viewProcessor) {
await this.viewProcessor.handleWrite(module.module, module.subpath, res.data);
}

return {
...res,
data: {
Expand All @@ -179,7 +233,14 @@ export class AFS extends Emitter<AFSRootEvents> implements AFSRoot {
const module = this.findModules(path, { exactMatch: true })[0];
if (!module?.module.delete) throw new Error(`No module found for path: ${path}`);

return await module.module.delete(module.subpath, options);
const result = await module.module.delete(module.subpath, options);

// Clean up metadata if view processor is available
if (this.viewProcessor) {
await this.viewProcessor.handleDelete(module.module, module.subpath);
}

return result;
}

async rename(
Expand Down Expand Up @@ -422,4 +483,133 @@ export class AFS extends Emitter<AFSRootEvents> implements AFSRoot {

return metadataSuffix;
}

/**
* Get slot metadata by owner path and slot ID
* @param ownerPath - Document path that declares the slot
* @param slotId - Slot ID
* @returns Slot metadata or null if not found
*/
async getSlot(ownerPath: string, slotId: string) {
if (!this.metadataStore) {
throw new Error("MetadataStore not initialized. Drivers must be configured to use slots.");
}

const module = this.findModules(ownerPath, { exactMatch: true })[0];
if (!module) {
throw new Error(`No module found for path: ${ownerPath}`);
}

return await this.metadataStore.getSlot(module.module.name, module.subpath, slotId);
}

/**
* Read image by slot (convenience method)
* @param ownerPath - Document path that declares the slot
* @param slotId - Slot ID
* @param options - Read options with view specification
* @returns AFSReadResult with the image
*/
async getImageBySlot(
ownerPath: string,
slotId: string,
options?: AFSReadOptions,
): Promise<AFSReadResult> {
const slot = await this.getSlot(ownerPath, slotId);
if (!slot) {
throw new Error(`Slot "${slotId}" not found in document: ${ownerPath}`);
}

const module = this.findModules(ownerPath, { exactMatch: true })[0];
if (!module) {
throw new Error(`No module found for path: ${ownerPath}`);
}

// Construct full path for the image
const imagePath = joinURL(MODULES_ROOT_DIR, module.module.name, slot.assetPath);

return await this.read(imagePath, options);
}

/**
* Render slots in document content by replacing slot markers with image references
* @param ownerPath - Document path
* @param content - Document content with slot markers
* @param options - Rendering options
* @returns Content with slots replaced by image references
*/
async renderSlots(
ownerPath: string,
content: string,
options?: {
view?: View;
format?: (slot: any, imagePath: string) => string;
},
): Promise<string> {
if (!this.metadataStore) {
throw new Error("MetadataStore not initialized. Drivers must be configured to use slots.");
}

const module = this.findModules(ownerPath, { exactMatch: true })[0];
if (!module) {
throw new Error(`No module found for path: ${ownerPath}`);
}

// Get all slots for this document
const slots = await this.metadataStore.listSlots(module.module.name, module.subpath);

let rendered = content;

// Replace each slot marker with image reference
for (const slot of slots) {
// Construct image path based on view
let imagePath = slot.assetPath;
if (options?.view) {
const viewKey = normalizeViewKey(options.view);
const format = options.view.format || "png";
imagePath = `${slot.assetPath}/${viewKey}/${slot.slug}.${format}`;
}

// Use custom format function or default markdown image syntax
const replacement = options?.format
? options.format(slot, imagePath)
: `![${slot.desc}](${imagePath})`;

// Replace slot marker with image reference
const slotPattern = new RegExp(
`<!--\\s*afs:image\\s+id="${slot.slotId}"(?:\\s+key="[^"]*")?\\s+desc="[^"]+"\\s*-->`,
"g",
);
rendered = rendered.replace(slotPattern, replacement);
}

return rendered;
}

/**
* Prefetch views for batch generation
* @param pathOrGlob - Single path or array of paths (glob support TBD)
* @param options - View options
*/
async prefetch(
pathOrGlob: string | string[],
options: { view: View; concurrency?: number; context?: any },
): Promise<void> {
if (!this.viewProcessor) {
throw new Error("Prefetch requires drivers to be configured");
}

const paths = Array.isArray(pathOrGlob) ? pathOrGlob : [pathOrGlob];

// For each path, find the module and prefetch
for (const path of paths) {
const module = this.findModules(path, { exactMatch: true })[0];
if (module) {
await this.viewProcessor.prefetch(module.module, [module.subpath], options.view, {
concurrency: options.concurrency,
context: options.context,
});
}
}
}
}
4 changes: 4 additions & 0 deletions afs/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
export * from "./afs.js";
export * from "./metadata/index.js";
export * from "./type.js";
export * from "./view-key.js";
export * from "./view-processor.js";
export * from "./view-schema.js";
4 changes: 4 additions & 0 deletions afs/core/src/metadata/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./migrate.js";
export * from "./models/index.js";
export * from "./store.js";
export * from "./type.js";
49 changes: 49 additions & 0 deletions afs/core/src/metadata/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { initDatabase } from "@aigne/sqlite";
import { sql } from "@aigne/sqlite";
import { init } from "./migrations/001-init.js";

const migrations = [init];

/**
* Run database migrations
*/
export async function migrate(db: ReturnType<typeof initDatabase>): Promise<void> {
const dbInstance = await db;

// Create migrations tracking table
await dbInstance
.run(
sql`CREATE TABLE IF NOT EXISTS __metadata_migrations (
hash TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
)`,
)
.execute();

// Run pending migrations
for (const migration of migrations) {
const rows = await dbInstance
.values<[string, number]>(
sql`SELECT hash, applied_at FROM __metadata_migrations WHERE hash = ${sql.param(migration.hash)}`,
)
.execute();

const existing = rows[0];

if (!existing) {
console.log(`Running migration: ${migration.hash}`);

// Execute all SQL statements in the migration
for (const statement of migration.sql()) {
await dbInstance.run(statement).execute();
}

// Record migration as applied
await dbInstance
.run(
sql`INSERT INTO __metadata_migrations (hash, applied_at) VALUES (${sql.param(migration.hash)}, ${sql.param(Date.now())})`,
)
.execute();
}
}
}
Loading
Loading