feat: implement session management for image generation skills and add session handling functions

This commit is contained in:
Jim Liu 宝玉
2026-01-16 20:20:07 -06:00
parent 259baff413
commit 8e88cf4a8b
7 changed files with 211 additions and 31 deletions
+53 -16
View File
@@ -10,6 +10,7 @@ import {
writeGeminiCookieMapToDisk,
} from './cookie-store.js';
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js';
import { readSession, writeSession, listSessions } from './session-store.js';
function printUsage(exitCode = 0): never {
const cookiePath = resolveGeminiWebCookiePath();
@@ -21,6 +22,10 @@ function printUsage(exitCode = 0): never {
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --prompt "A cute cat" --image generated.png
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --promptfiles system.md content.md --image out.png
Multi-turn conversation (agent generates unique sessionId):
npx -y bun skills/baoyu-gemini-web/scripts/main.ts "Remember 42" --sessionId abc123
npx -y bun skills/baoyu-gemini-web/scripts/main.ts "What number?" --sessionId abc123
Options:
-p, --prompt <text> Prompt text
--promptfiles <files...> Read prompt from one or more files (concatenated in order)
@@ -28,6 +33,8 @@ Options:
--json Output JSON
--image [path] Generate an image and save it (default: ./generated.png)
--reference <files...> Reference images for vision input
--sessionId <id> Session ID for multi-turn conversation (agent should generate unique ID)
--list-sessions List saved sessions (max 100, sorted by update time)
--login Only refresh cookies, then exit
--cookie-path <path> Cookie file path (default: ${cookiePath})
--profile-dir <path> Chrome profile dir (default: ${profileDir})
@@ -77,6 +84,8 @@ function parseArgs(argv: string[]): {
cookiePath?: string;
profileDir?: string;
referenceImages?: string[];
sessionId?: string;
listSessions?: boolean;
} {
const out: ReturnType<typeof parseArgs> = {};
const positional: string[] = [];
@@ -172,6 +181,19 @@ function parseArgs(argv: string[]): {
}
continue;
}
if (arg === '--sessionId' || arg === '--session-id') {
out.sessionId = argv[i + 1] ?? '';
i += 1;
continue;
}
if (arg.startsWith('--sessionId=') || arg.startsWith('--session-id=')) {
out.sessionId = arg.split('=')[1] ?? '';
continue;
}
if (arg === '--list-sessions') {
out.listSessions = true;
continue;
}
if (arg.startsWith('-')) {
throw new Error(`Unknown option: ${arg}`);
@@ -194,6 +216,8 @@ function parseArgs(argv: string[]): {
if (out.profileDir === '') delete out.profileDir;
if (out.promptFiles?.length === 0) delete out.promptFiles;
if (out.referenceImages?.length === 0) delete out.referenceImages;
if (out.sessionId != null) out.sessionId = out.sessionId.trim();
if (out.sessionId === '') delete out.sessionId;
return out;
}
@@ -267,6 +291,18 @@ async function main(): Promise<void> {
const cookiePath = args.cookiePath ?? resolveGeminiWebCookiePath();
const profileDir = args.profileDir ?? resolveGeminiWebChromeProfileDir();
if (args.listSessions) {
const sessions = await listSessions();
if (sessions.length === 0) {
console.log('No saved sessions.');
} else {
for (const { id, updatedAt } of sessions) {
console.log(`${id}\t${updatedAt}`);
}
}
return;
}
if (args.loginOnly) {
await ensureGeminiCookieMap({ cookiePath, profileDir });
return;
@@ -277,6 +313,9 @@ async function main(): Promise<void> {
const prompt = promptFromArgs || (await readPromptFromStdin());
if (!prompt) printUsage(1);
const sessionData = args.sessionId ? await readSession(args.sessionId) : null;
const chatMetadata = sessionData?.metadata ?? null;
let cookieMap = await ensureGeminiCookieMap({ cookiePath, profileDir });
const desiredModel = resolveModel(args.model || 'gemini-3-pro');
@@ -297,10 +336,14 @@ async function main(): Promise<void> {
files: referenceImages,
model: desiredModel,
cookieMap,
chatMetadata: null,
chatMetadata,
signal: controller.signal,
});
if (args.sessionId && out.metadata) {
await writeSession(args.sessionId, out.metadata, prompt, out.text ?? '', out.errorMessage);
}
let imageSaved = false;
let imageCount = 0;
if (imagePath) {
@@ -313,13 +356,8 @@ async function main(): Promise<void> {
}
if (args.json) {
process.stdout.write(
`${JSON.stringify(
imagePath ? { ...out, imageSaved, imageCount, imagePath } : out,
null,
2,
)}\n`,
);
const jsonOut = { ...out, ...(imagePath && { imageSaved, imageCount, imagePath }), ...(args.sessionId && { sessionId: args.sessionId }) };
process.stdout.write(`${JSON.stringify(jsonOut, null, 2)}\n`);
if (out.errorMessage) process.exit(1);
return;
}
@@ -356,10 +394,14 @@ async function main(): Promise<void> {
files: referenceImages,
model: desiredModel,
cookieMap,
chatMetadata: null,
chatMetadata,
signal: controller.signal,
});
if (args.sessionId && out.metadata) {
await writeSession(args.sessionId, out.metadata, prompt, out.text ?? '', out.errorMessage);
}
let imageSaved = false;
let imageCount = 0;
if (imagePath) {
@@ -372,13 +414,8 @@ async function main(): Promise<void> {
}
if (args.json) {
process.stdout.write(
`${JSON.stringify(
imagePath ? { ...out, imageSaved, imageCount, imagePath } : out,
null,
2,
)}\n`,
);
const jsonOut = { ...out, ...(imagePath && { imageSaved, imageCount, imagePath }), ...(args.sessionId && { sessionId: args.sessionId }) };
process.stdout.write(`${JSON.stringify(jsonOut, null, 2)}\n`);
if (out.errorMessage) process.exit(1);
return;
}
+9
View File
@@ -34,3 +34,12 @@ export function resolveGeminiWebChromeProfileDir(): string {
if (override) return path.resolve(override);
return path.join(resolveGeminiWebDataDir(), PROFILE_DIR_NAME);
}
export function resolveGeminiWebSessionsDir(): string {
return path.join(resolveGeminiWebDataDir(), 'sessions');
}
export function resolveGeminiWebSessionPath(name: string): string {
const sanitized = name.replace(/[^a-zA-Z0-9_-]/g, '_');
return path.join(resolveGeminiWebSessionsDir(), `${sanitized}.json`);
}
@@ -0,0 +1,90 @@
import { mkdir, readFile, writeFile, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { resolveGeminiWebSessionsDir, resolveGeminiWebSessionPath } from './paths.js';
export interface SessionMessage {
role: 'user' | 'assistant';
content: string;
timestamp: string;
error?: string;
}
export interface SessionData {
id: string;
metadata: unknown;
messages: SessionMessage[];
createdAt: string;
updatedAt: string;
}
export interface SessionListItem {
id: string;
updatedAt: string;
}
export async function readSession(id: string): Promise<SessionData | null> {
const sessionPath = resolveGeminiWebSessionPath(id);
try {
const content = await readFile(sessionPath, 'utf8');
return JSON.parse(content) as SessionData;
} catch {
return null;
}
}
export async function writeSession(
id: string,
metadata: unknown,
userMessage: string,
assistantMessage: string,
error?: string,
): Promise<void> {
const sessionPath = resolveGeminiWebSessionPath(id);
const sessionsDir = resolveGeminiWebSessionsDir();
await mkdir(sessionsDir, { recursive: true });
const existing = await readSession(id);
const now = new Date().toISOString();
const newMessages: SessionMessage[] = [
{ role: 'user', content: userMessage, timestamp: now },
{ role: 'assistant', content: assistantMessage, timestamp: now, ...(error && { error }) },
];
const data: SessionData = {
id,
metadata,
messages: [...(existing?.messages ?? []), ...newMessages],
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
await writeFile(sessionPath, JSON.stringify(data, null, 2));
}
export async function listSessions(limit = 100): Promise<SessionListItem[]> {
const sessionsDir = resolveGeminiWebSessionsDir();
try {
const files = await readdir(sessionsDir);
const jsonFiles = files.filter((f) => f.endsWith('.json'));
const items: { id: string; updatedAt: string; mtime: number }[] = [];
for (const file of jsonFiles) {
const filePath = path.join(sessionsDir, file);
try {
const stats = await stat(filePath);
items.push({
id: file.slice(0, -5),
updatedAt: stats.mtime.toISOString(),
mtime: stats.mtime.getTime(),
});
} catch {
continue;
}
}
items.sort((a, b) => b.mtime - a.mtime);
return items.slice(0, limit).map(({ id, updatedAt }) => ({ id, updatedAt }));
} catch {
return [];
}
}