diff --git a/CLAUDE.md b/CLAUDE.md index 3abbaf0..757f184 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,13 @@ CodePilot — Claude Code 的桌面 GUI 客户端,基于 Electron + Next.js。 +## 本地参考项目路径(上下文共享/存储) + +为便于后续分析与实现对照,本机已拉取以下参考仓库: + +- craft-agents-oss: `/Users/op7418/Documents/code/资料/craft-agents-oss` +- opencode: `/Users/op7418/Documents/code/资料/opencode` + ## Release Checklist **发版流程(CI 自动打包 + 发布):** @@ -49,47 +56,14 @@ CodePilot — Claude Code 的桌面 GUI 客户端,基于 Electron + Next.js。 ## Release Notes 规范 -每次发布 GitHub Release 时,必须包含以下内容: +标题:`CodePilot v{版本号}` -**标题格式**: `CodePilot v{版本号}` - -**正文结构**: - -```markdown -## New Features / Bug Fixes(按实际内容选择标题) - -- **功能/修复标题** — 简要描述改动内容和原因 - -## Downloads - -- **CodePilot-{版本}-arm64.dmg** — macOS Apple Silicon (M1/M2/M3/M4) -- **CodePilot-{版本}-x64.dmg** — macOS Intel - -## Installation - -1. 下载对应芯片架构的 DMG 文件 -2. 打开 DMG,将 CodePilot 拖入 Applications 文件夹 -3. 首次打开时如遇安全提示,前往 **系统设置 → 隐私与安全性** 点击"仍要打开" -4. 在 Settings 页面配置 Anthropic API Key 或环境变量 - -## Requirements - -- macOS 12.0+ -- Anthropic API Key 或已配置 `ANTHROPIC_API_KEY` 环境变量 -- 如需使用代码相关功能,建议安装 Claude Code CLI - -## Changelog (since v{上一版本}) - -| Commit | Description | -|--------|-------------| -| `{hash}` | {commit message} | -``` - -**注意事项**: -- 大版本(功能更新)用 `## New Features` + `## Bug Fixes` 分区 -- 小版本(纯修复)用 `## Bug Fix` 即可 -- Downloads、Installation、Requirements 每次都要写,方便新用户 -- Changelog 表格列出自上一版本以来的所有 commit +正文必须包含: +- **本版本更新内容**(New Features / Bug Fixes,按实际情况分区) +- **Downloads**(各平台安装包说明) +- **Installation**(各平台安装步骤) +- **Requirements**(系统要求、依赖说明) +- **Changelog**(自上一版本以来的 commit 列表) ## Build Notes diff --git a/docs/context-storage-migration-plan.md b/docs/context-storage-migration-plan.md new file mode 100644 index 0000000..f41bea1 --- /dev/null +++ b/docs/context-storage-migration-plan.md @@ -0,0 +1,211 @@ +# 上下文共享与存储迁移设计(CodePilot) + +更新时间:2026-02-26 + +## 1. 目标与范围 + +本设计用于解决当前项目在会话上下文共享与持久化上的核心问题: + +- 存储缺少 `project/workspace` 隔离,跨项目上下文容易串扰 +- `working_directory`、`sdk_session_id` 缺少一致性约束,恢复行为不稳定 +- 运行态强依赖内存 Map,重启后不可恢复 +- 长会话 fallback 依赖固定窗口(最近 50 条),无压缩策略 + +参考实现(本机路径): + +- `/Users/op7418/Documents/code/资料/craft-agents-oss` +- `/Users/op7418/Documents/code/资料/opencode` + +## 2. 现状问题摘要 + +- 会话表 `chat_sessions` 与消息表 `messages` 为单库全局存储,未区分 project。 +- `PATCH /api/chat/sessions/[id]` 更新 `working_directory` 时,不会同步清理或重建 SDK 恢复锚点。 +- `conversation-registry` / `permission-registry` 为内存态;进程重启会丢失处理中状态。 +- fallback 历史上下文取最近 50 条拼接 prompt,长链路任务容易丢关键状态。 + +## 3. 目标架构 + +采用“关系型主存储 + 结构化消息 part + 会话压缩摘要 + 可恢复运行态”。 + +### 3.1 核心原则 + +- `project` 作为一级隔离边界(至少按 `working_directory` 归属) +- `sdk_cwd` 为会话恢复锚点,默认不可变;仅在“无消息 + 无 sdk_session_id”时允许更新 +- 消息内容从“单文本”升级为“message + part(text/tool_use/tool_result/attachment)” +- 运行态状态(处理中、队列、待审批)入库,内存仅做热缓存 + +### 3.2 目标表结构(建议) + +1. `projects` +- `id TEXT PRIMARY KEY` +- `root_path TEXT NOT NULL UNIQUE` +- `name TEXT NOT NULL DEFAULT ''` +- `created_at TEXT NOT NULL` +- `updated_at TEXT NOT NULL` + +2. `chat_sessions`(新增/调整字段) +- `project_id TEXT NOT NULL`(FK -> `projects.id`) +- `sdk_cwd TEXT NOT NULL DEFAULT ''` +- `is_archived INTEGER NOT NULL DEFAULT 0` +- `parent_session_id TEXT`(支持 fork/sub-session) +- `summary TEXT NOT NULL DEFAULT ''`(最近一次压缩摘要) +- `summary_updated_at TEXT` + +3. `messages`(保留主表,内容轻量化) +- `id TEXT PRIMARY KEY` +- `session_id TEXT NOT NULL` +- `role TEXT NOT NULL` +- `created_at TEXT NOT NULL` +- `token_usage TEXT` +- `is_summary INTEGER NOT NULL DEFAULT 0` + +4. `message_parts`(新增) +- `id TEXT PRIMARY KEY` +- `message_id TEXT NOT NULL` +- `session_id TEXT NOT NULL` +- `part_type TEXT NOT NULL`(`text|tool_use|tool_result|attachment|status`) +- `payload TEXT NOT NULL`(JSON) +- `seq INTEGER NOT NULL` +- `created_at TEXT NOT NULL` + +5. `session_runtime_state`(新增) +- `session_id TEXT PRIMARY KEY` +- `is_processing INTEGER NOT NULL DEFAULT 0` +- `processing_generation INTEGER NOT NULL DEFAULT 0` +- `queued_messages TEXT NOT NULL DEFAULT '[]'` +- `pending_permissions TEXT NOT NULL DEFAULT '[]'` +- `updated_at TEXT NOT NULL` + +## 4. 关键状态机设计 + +### 4.1 `working_directory` 与 `sdk_cwd` / `sdk_session_id` + +规则: + +- 会话创建:`sdk_cwd = working_directory`(若无则使用默认路径) +- 若 `messages.count = 0` 且 `sdk_session_id` 为空:允许同时更新 `working_directory` 与 `sdk_cwd` +- 否则仅更新 `working_directory`,`sdk_cwd` 不变 +- 若用户明确“重置上下文”:清空消息 + 清空 `sdk_session_id` + 清零 token usage + +### 4.2 恢复与 fallback + +- 优先 `sdk_session_id` 恢复 +- 恢复失败:清空失效 `sdk_session_id`,转“摘要 + 最近窗口消息”模式 +- 最近窗口不再固定 50,改为按 token 预算动态截断(例如保留最近 N token) + +## 5. 分阶段迁移计划 + +### Phase 0(低风险,1~2 天) + +- 新增 `projects`,为历史 session 回填 `project_id` +- 新增 `sdk_cwd`,历史数据用 `working_directory` 回填 +- 在更新 `working_directory` 路径时引入 `canUpdateSdkCwd` 逻辑 + +交付标准: +- 旧会话可正常读取 +- 新会话具备稳定 `sdk_cwd` + +### Phase 1(中风险,2~4 天) + +- 新增 `message_parts`,写入新消息时同步落 part +- 读取接口优先读 part 组装,兼容旧 `content` +- 附件、tool_use、tool_result 改为结构化 payload + +交付标准: +- UI 展示不回归 +- tool 结果不再依赖文本解析 + +### Phase 2(中风险,2~3 天) + +- 新增 `session_runtime_state`,持久化队列与待审批请求 +- 启动时恢复可恢复状态(queued/pending) +- streaming 保存改为“可 flush 队列 + 原子提交” + +交付标准: +- 进程重启后能恢复排队消息与审批提示 + +### Phase 3(中高风险,3~5 天) + +- 上线 compaction:自动摘要 + 老旧 tool output 裁剪 +- 支持 `archive/fork/parent-child` +- 列表查询支持 active/archived 与 parent 维度 + +交付标准: +- 长会话上下文质量稳定 +- 会话组织能力可用 + +## 6. SQL 迁移草案(示例) + +```sql +-- 1) project +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + root_path TEXT NOT NULL UNIQUE, + name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- 2) chat_sessions 增量字段 +ALTER TABLE chat_sessions ADD COLUMN project_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE chat_sessions ADD COLUMN sdk_cwd TEXT NOT NULL DEFAULT ''; +ALTER TABLE chat_sessions ADD COLUMN is_archived INTEGER NOT NULL DEFAULT 0; +ALTER TABLE chat_sessions ADD COLUMN parent_session_id TEXT; +ALTER TABLE chat_sessions ADD COLUMN summary TEXT NOT NULL DEFAULT ''; +ALTER TABLE chat_sessions ADD COLUMN summary_updated_at TEXT; + +-- 3) message_parts +CREATE TABLE IF NOT EXISTS message_parts ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + part_type TEXT NOT NULL, + payload TEXT NOT NULL, + seq INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, + FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_message_parts_message_seq ON message_parts(message_id, seq); +CREATE INDEX IF NOT EXISTS idx_message_parts_session_created ON message_parts(session_id, created_at); + +-- 4) runtime_state +CREATE TABLE IF NOT EXISTS session_runtime_state ( + session_id TEXT PRIMARY KEY, + is_processing INTEGER NOT NULL DEFAULT 0, + processing_generation INTEGER NOT NULL DEFAULT 0, + queued_messages TEXT NOT NULL DEFAULT '[]', + pending_permissions TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE +); +``` + +## 7. 代码落点建议 + +- `src/lib/db.ts` + - 增加 schema version 与分步 migration 函数(幂等) + - 增加 `project` 相关 CRUD +- `src/app/api/chat/sessions/[id]/route.ts` + - 更新工作目录时执行 `canUpdateSdkCwd` 判定 +- `src/lib/claude-client.ts` + - fallback 改为“summary + token 预算窗口” + - SDK 恢复失败后的状态落库更完整(含 runtime_state) +- `src/lib/conversation-registry.ts` / `src/lib/permission-registry.ts` + - 仅保留热缓存;状态源改为 DB + +## 8. 验收与回归清单 + +- 新建会话、切换目录、发送消息、终止、恢复会话全链路 +- 进程重启后:处理中状态、排队消息、权限请求恢复验证 +- 历史会话兼容读取(旧 content 字段) +- 大会话(>500 消息)下首 token 延迟与恢复成功率 + +## 9. 风险与回滚 + +- 风险:旧消息格式兼容处理不完善导致渲染异常 +- 风险:迁移中断导致部分字段未回填 +- 回滚策略: + - 所有迁移前备份 `codepilot.db` + - 每个 phase 单独开关(feature flag) + - 先双写(old content + message_parts),稳定后再切读路径 diff --git a/package-lock.json b/package-lock.json index 7824d28..137bc2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "codepilot", "version": "0.15.1", "dependencies": { + "@ai-sdk/google": "^3.0.31", "@anthropic-ai/claude-agent-sdk": "^0.2.33", "@google/genai": "^1.43.0", "@hugeicons/core-free-icons": "^3.1.1", @@ -82,6 +83,51 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/google": { + "version": "3.0.31", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.31.tgz", + "integrity": "sha512-RVNz8WFSIRbXbYDBE6JvlE2escWPJimBCs22LzKEYH7DNfl/X7cHNa1LFho4PsY6Ib0JmbzB8s2+i0wHs/wNCg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.15" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz", + "integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/provider": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.7.tgz", diff --git a/package.json b/package.json index cfbc391..872f1ad 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "electron:pack:linux": "npm run electron:build && electron-builder --linux --config electron-builder.yml" }, "dependencies": { + "@ai-sdk/google": "^3.0.31", "@anthropic-ai/claude-agent-sdk": "^0.2.33", "@google/genai": "^1.43.0", "@hugeicons/core-free-icons": "^3.1.1", diff --git a/public/skills/image-generation.md b/public/skills/image-generation.md new file mode 100644 index 0000000..d259b84 --- /dev/null +++ b/public/skills/image-generation.md @@ -0,0 +1,61 @@ +--- +name: image-generation +description: Generate images using Nano Banana Pro (Gemini) +--- + +# Image Generation Assistant + +You are an image generation assistant. The user wants to generate an image. Your job is to extract the prompt and output a structured code block. + +## Core Principle — STRICTLY ENFORCED + +**You MUST use the user's original words VERBATIM as the prompt. NEVER rewrite, rephrase, expand, enhance, add details, or "improve" the prompt in any way.** If the user says "画一只小狗", the prompt must be exactly "画一只小狗" — not "一只可爱的小狗在草地上奔跑" or "a cute puppy". Do NOT translate between languages. Do NOT add artistic style, lighting, composition, or any other details the user did not explicitly write. Your job is to extract and pass through, not to create. + +## Rules + +1. **Simple requests** (e.g. "画一只小狗", "a sunset", "cyberpunk city"): Use the user's description directly as the prompt, in the user's original language. Do NOT translate. +2. **Requests with explicit prompts** (e.g. "用这个提示词画图:a cat sitting on a moon"): Extract and use the provided prompt as-is. +3. **Complex requests that need analysis** (e.g. "分析这个文档内容然后画一张信息图", "summarize this article and create an illustration"): In this case, analyze the content and generate a descriptive prompt based on the content. Briefly explain your reasoning before the code block. Even here, do NOT embellish or add artistic flourishes — only describe what the user asked for. +4. **Requests with reference images**: If the user attached images (shown as `[User attached image: /path/to/file (name)]` or `[User attached file: /path/to/file (name)]`), include the file paths in the `referenceImages` array. The image generation model will use them as visual references. + +## Output Format + +Output the following code block. For simple requests, output it directly without extra explanation: + +```image-gen-request +{ + "prompt": "the prompt to use", + "aspectRatio": "1:1", + "resolution": "1K" +} +``` + +When the user provides reference images: + +```image-gen-request +{ + "prompt": "transform this into watercolor style", + "aspectRatio": "1:1", + "resolution": "1K", + "referenceImages": ["/path/to/uploaded/image.png"] +} +``` + +- **prompt**: Keep the user's original language. Do not translate. +- **aspectRatio**: Choose from 1:1, 16:9, 9:16, 3:2, 2:3, 4:3, 3:4, 4:5, 5:4, 21:9. Pick based on subject matter (portraits → 2:3, landscapes → 16:9, default → 1:1). +- **resolution**: Default to 1K unless the user specifies otherwise. +- **referenceImages** (optional): Array of file paths from the user's attached images. Only include when the user uploads images they want to use as reference for generation. + +## Examples + +User: "画一只小狗" +→ prompt: "画一只小狗", aspectRatio: "1:1" + +User: "用下面的提示词画图:a majestic eagle soaring over mountain peaks at golden hour" +→ prompt: "a majestic eagle soaring over mountain peaks at golden hour", aspectRatio: "16:9" + +User: [attached image.png] "把这张图片变成水彩画风格" +→ prompt: "把这张图片变成水彩画风格", referenceImages: ["/path/to/image.png"], aspectRatio: "1:1" + +User: "a cyberpunk city at night" +→ prompt: "a cyberpunk city at night", aspectRatio: "16:9" diff --git a/src/app/api/chat/messages/route.ts b/src/app/api/chat/messages/route.ts new file mode 100644 index 0000000..9f9c0ff --- /dev/null +++ b/src/app/api/chat/messages/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { addMessage, getSession } from '@/lib/db'; + +export const runtime = 'nodejs'; + +/** + * POST /api/chat/messages + * Persist a message to the DB without triggering the model. + * Used by image-gen mode to write user/assistant messages directly. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { session_id, role, content, token_usage } = body as { + session_id: string; + role: 'user' | 'assistant'; + content: string; + token_usage?: string; + }; + + if (!session_id || !role || !content) { + return NextResponse.json( + { error: 'session_id, role, and content are required' }, + { status: 400 }, + ); + } + + const session = getSession(session_id); + if (!session) { + return NextResponse.json({ error: 'Session not found' }, { status: 404 }); + } + + const message = addMessage(session_id, role, content, token_usage ?? null); + return NextResponse.json({ message }); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Failed to save message'; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 81ad7bd..76a5650 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from 'next/server'; import { streamClaude } from '@/lib/claude-client'; -import { addMessage, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId } from '@/lib/db'; +import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId } from '@/lib/db'; import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types'; import fs from 'fs'; import path from 'path'; @@ -10,8 +10,11 @@ export const dynamic = 'force-dynamic'; export async function POST(request: NextRequest) { try { - const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string } = await request.json(); - const { session_id, content, model, mode, files, toolTimeout, provider_id } = body; + const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string } = await request.json(); + const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend } = body; + + console.log('[chat API] content length:', content.length, 'first 200 chars:', content.slice(0, 200)); + console.log('[chat API] systemPromptAppend:', systemPromptAppend ? `${systemPromptAppend.length} chars` : 'none'); if (!session_id || !content) { return new Response(JSON.stringify({ error: 'session_id and content are required' }), { @@ -135,19 +138,43 @@ export async function POST(request: NextRequest) { }) : undefined; + // Append per-request system prompt (e.g. skill injection for image generation) + let finalSystemPrompt = systemPromptOverride || session.system_prompt || undefined; + if (systemPromptAppend) { + finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + systemPromptAppend; + } + + // Load recent conversation history from DB as fallback context. + // This is used when SDK session resume is unavailable or fails, + // so the model still has conversation context. + const { messages: recentMsgs } = getMessages(session_id, { limit: 50 }); + // Exclude the user message we just saved (last in the list) — it's already the prompt + const historyMsgs = recentMsgs.slice(0, -1).map(m => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })); + // Stream Claude response, using SDK session ID for resume if available + console.log('[chat API] streamClaude params:', { + promptLength: content.length, + promptFirst200: content.slice(0, 200), + sdkSessionId: session.sdk_session_id || 'none', + systemPromptLength: finalSystemPrompt?.length || 0, + systemPromptFirst200: finalSystemPrompt?.slice(0, 200) || 'none', + }); const stream = streamClaude({ prompt: content, sessionId: session_id, sdkSessionId: session.sdk_session_id || undefined, model: effectiveModel, - systemPrompt: systemPromptOverride || session.system_prompt || undefined, + systemPrompt: finalSystemPrompt, workingDirectory: session.working_directory || undefined, abortController, permissionMode, files: fileAttachments, toolTimeoutSeconds: toolTimeout || 300, provider: resolvedProvider, + conversationHistory: historyMsgs, }); // Tee the stream: one for client, one for collecting the response diff --git a/src/app/api/media/[id]/route.ts b/src/app/api/media/[id]/route.ts new file mode 100644 index 0000000..cec193a --- /dev/null +++ b/src/app/api/media/[id]/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDb } from '@/lib/db'; +import fs from 'fs'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const db = getDb(); + const row = db.prepare('SELECT * FROM media_generations WHERE id = ?').get(id); + + if (!row) { + return NextResponse.json( + { error: 'Media generation not found' }, + { status: 404 } + ); + } + + return NextResponse.json(row); + } catch (error) { + console.error('[media/[id]] GET Error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to get media generation' }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const db = getDb(); + + // Get the record first to find the file path + const row = db.prepare('SELECT * FROM media_generations WHERE id = ?').get(id) as { + id: string; + local_path: string; + thumbnail_path: string; + } | undefined; + + if (!row) { + return NextResponse.json( + { error: 'Media generation not found' }, + { status: 404 } + ); + } + + // Delete the file from disk + if (row.local_path && fs.existsSync(row.local_path)) { + fs.unlinkSync(row.local_path); + } + if (row.thumbnail_path && fs.existsSync(row.thumbnail_path)) { + fs.unlinkSync(row.thumbnail_path); + } + + // Delete the record + db.prepare('DELETE FROM media_generations WHERE id = ?').run(id); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('[media/[id]] DELETE Error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to delete media generation' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/media/[id]/tags/route.ts b/src/app/api/media/[id]/tags/route.ts new file mode 100644 index 0000000..5dbe4b8 --- /dev/null +++ b/src/app/api/media/[id]/tags/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDb } from '@/lib/db'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +export async function PUT(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const body = await request.json(); + + if (!Array.isArray(body.tags)) { + return NextResponse.json( + { error: 'Missing required field: tags (must be an array)' }, + { status: 400 } + ); + } + + const db = getDb(); + + // Verify the record exists + const row = db.prepare('SELECT id FROM media_generations WHERE id = ?').get(id); + if (!row) { + return NextResponse.json( + { error: 'Media generation not found' }, + { status: 404 } + ); + } + + // Update tags + db.prepare('UPDATE media_generations SET tags = ? WHERE id = ?').run( + JSON.stringify(body.tags), + id + ); + + const updated = db.prepare('SELECT * FROM media_generations WHERE id = ?').get(id); + return NextResponse.json(updated); + } catch (error) { + console.error('[media/[id]/tags] PUT Error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to update tags' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/media/gallery/route.ts b/src/app/api/media/gallery/route.ts new file mode 100644 index 0000000..b76eeea --- /dev/null +++ b/src/app/api/media/gallery/route.ts @@ -0,0 +1,154 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDb } from '@/lib/db'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface DbRow { + id: string; + prompt: string; + model: string; + aspect_ratio: string; + image_size: string; + local_path: string; + tags: string; + metadata: string; + created_at: string; + session_id: string | null; + [key: string]: unknown; +} + +function mapRow(row: DbRow) { + // Build images array from local_path + const images: Array<{ mimeType: string; localPath: string }> = []; + if (row.local_path) { + const ext = row.local_path.split('.').pop()?.toLowerCase(); + const mimeType = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' + : ext === 'webp' ? 'image/webp' + : 'image/png'; + images.push({ mimeType, localPath: row.local_path }); + } + + // Parse tags JSON string to array + let tags: string[] = []; + try { + tags = JSON.parse(row.tags || '[]'); + } catch { + tags = []; + } + + // Parse reference images from metadata + let referenceImages: Array<{ mimeType: string; localPath: string }> | undefined; + try { + const meta = JSON.parse(row.metadata || '{}'); + if (Array.isArray(meta.referenceImages) && meta.referenceImages.length > 0) { + referenceImages = meta.referenceImages; + } + } catch { + // ignore + } + + return { + id: row.id, + prompt: row.prompt, + images, + model: row.model, + aspectRatio: row.aspect_ratio, + imageSize: row.image_size, + tags, + created_at: row.created_at, + session_id: row.session_id || undefined, + referenceImages, + }; +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = request.nextUrl; + const tags = searchParams.get('tags'); + const dateFrom = searchParams.get('dateFrom'); + const dateTo = searchParams.get('dateTo'); + const sort = searchParams.get('sort') || 'newest'; + const limit = parseInt(searchParams.get('limit') || '50', 10); + const offset = parseInt(searchParams.get('offset') || '0', 10); + + const db = getDb(); + + const conditions: string[] = []; + const params: unknown[] = []; + + if (tags) { + const tagList = tags.split(',').map(t => t.trim()).filter(Boolean); + for (const tag of tagList) { + conditions.push("json_each.value = ?"); + params.push(tag); + } + } + + if (dateFrom) { + conditions.push("mg.created_at >= ?"); + params.push(dateFrom); + } + + if (dateTo) { + conditions.push("mg.created_at <= ?"); + params.push(dateTo); + } + + const orderDir = sort === 'oldest' ? 'ASC' : 'DESC'; + + let countSql: string; + let querySql: string; + + if (tags) { + const tagList = tags.split(',').map(t => t.trim()).filter(Boolean); + // Use json_each to filter by tags in the JSON array + const tagPlaceholders = tagList.map(() => '?').join(', '); + const baseConditions = conditions.filter(c => !c.startsWith('json_each')); + const whereClause = baseConditions.length > 0 ? `AND ${baseConditions.join(' AND ')}` : ''; + + // Rebuild params without tag params, then add them at the right positions + const nonTagParams: unknown[] = []; + if (dateFrom) nonTagParams.push(dateFrom); + if (dateTo) nonTagParams.push(dateTo); + + countSql = ` + SELECT COUNT(DISTINCT mg.id) as total + FROM media_generations mg, json_each(mg.tags) + WHERE json_each.value IN (${tagPlaceholders}) ${whereClause} + `; + + querySql = ` + SELECT DISTINCT mg.* + FROM media_generations mg, json_each(mg.tags) + WHERE json_each.value IN (${tagPlaceholders}) ${whereClause} + ORDER BY mg.created_at ${orderDir} + LIMIT ? OFFSET ? + `; + + const countResult = db.prepare(countSql).get(...tagList, ...nonTagParams) as { total: number }; + const rows = db.prepare(querySql).all(...tagList, ...nonTagParams, limit, offset) as DbRow[]; + + return NextResponse.json({ items: rows.map(mapRow), total: countResult.total }); + } else { + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const nonTagParams: unknown[] = []; + if (dateFrom) nonTagParams.push(dateFrom); + if (dateTo) nonTagParams.push(dateTo); + + countSql = `SELECT COUNT(*) as total FROM media_generations mg ${whereClause}`; + querySql = `SELECT * FROM media_generations mg ${whereClause} ORDER BY mg.created_at ${orderDir} LIMIT ? OFFSET ?`; + + const countResult = db.prepare(countSql).get(...nonTagParams) as { total: number }; + const rows = db.prepare(querySql).all(...nonTagParams, limit, offset) as DbRow[]; + + return NextResponse.json({ items: rows.map(mapRow), total: countResult.total }); + } + } catch (error) { + console.error('[media/gallery] Error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch gallery' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/media/generate/route.ts b/src/app/api/media/generate/route.ts new file mode 100644 index 0000000..42e112e --- /dev/null +++ b/src/app/api/media/generate/route.ts @@ -0,0 +1,204 @@ +import { NextRequest } from 'next/server'; +import { generateImage, NoImageGeneratedError } from 'ai'; +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { getDb, getSession } from '@/lib/db'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot'); +const MEDIA_DIR = path.join(dataDir, '.codepilot-media'); + +interface GenerateRequest { + prompt: string; + model?: string; + aspectRatio?: string; + imageSize?: string; + referenceImages?: { mimeType: string; data: string }[]; + referenceImagePaths?: string[]; + sessionId?: string; +} + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 300; + +export async function POST(request: NextRequest) { + const startTime = Date.now(); + + try { + const body: GenerateRequest = await request.json(); + + if (!body.prompt) { + return new Response( + JSON.stringify({ error: 'Missing required field: prompt' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + const db = getDb(); + const provider = db.prepare( + "SELECT api_key FROM api_providers WHERE provider_type = 'gemini-image' AND api_key != '' LIMIT 1" + ).get() as { api_key: string } | undefined; + + if (!provider) { + return new Response( + JSON.stringify({ error: 'No Gemini Image provider configured. Please add a provider with type "gemini-image" in Settings.' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + const requestedModel = body.model || 'gemini-3-pro-image-preview'; + const aspectRatio = (body.aspectRatio || '1:1') as `${number}:${number}`; + const imageSize = body.imageSize || '1K'; + + const google = createGoogleGenerativeAI({ apiKey: provider.api_key }); + + // Build prompt: plain string or { text, images } for reference images + // Support both base64 referenceImages and on-disk referenceImagePaths + let refImageData: string[] = []; + if (body.referenceImages && body.referenceImages.length > 0) { + refImageData = body.referenceImages.map(img => img.data); + } else if (body.referenceImagePaths && body.referenceImagePaths.length > 0) { + for (const filePath of body.referenceImagePaths) { + if (fs.existsSync(filePath)) { + const buf = fs.readFileSync(filePath); + refImageData.push(buf.toString('base64')); + } + } + } + const prompt = refImageData.length > 0 + ? { text: body.prompt, images: refImageData } + : body.prompt; + + const { images } = await generateImage({ + model: google.image(requestedModel), + prompt, + providerOptions: { + google: { + imageConfig: { aspectRatio, imageSize }, + }, + }, + maxRetries: 3, + abortSignal: AbortSignal.timeout(120_000), + }); + + const elapsed = Date.now() - startTime; + console.log(`[media/generate] ${requestedModel} ${imageSize} completed in ${elapsed}ms`); + + // Ensure media directory exists + if (!fs.existsSync(MEDIA_DIR)) { + fs.mkdirSync(MEDIA_DIR, { recursive: true }); + } + + // Write images to disk + const savedImages: Array<{ mimeType: string; localPath: string }> = []; + + for (const img of images) { + const ext = img.mediaType === 'image/jpeg' ? '.jpg' + : img.mediaType === 'image/webp' ? '.webp' + : '.png'; + const filename = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`; + const filePath = path.join(MEDIA_DIR, filename); + + fs.writeFileSync(filePath, Buffer.from(img.uint8Array)); + + savedImages.push({ + mimeType: img.mediaType, + localPath: filePath, + }); + } + + // Copy images to project directory if sessionId is provided + if (body.sessionId) { + try { + const session = getSession(body.sessionId); + if (session?.working_directory) { + const projectImgDir = path.join(session.working_directory, '.codepilot-images'); + if (!fs.existsSync(projectImgDir)) { + fs.mkdirSync(projectImgDir, { recursive: true }); + } + for (const saved of savedImages) { + const destPath = path.join(projectImgDir, path.basename(saved.localPath)); + fs.copyFileSync(saved.localPath, destPath); + } + console.log(`[media/generate] Copied ${savedImages.length} image(s) to ${projectImgDir}`); + } + } catch (copyErr) { + console.warn('[media/generate] Failed to copy images to project directory:', copyErr); + } + } + + // Save reference images to disk for gallery display + const savedRefImages: Array<{ mimeType: string; localPath: string }> = []; + if (refImageData.length > 0) { + const refMimeTypes = body.referenceImages + ? body.referenceImages.map(img => img.mimeType) + : body.referenceImagePaths + ? body.referenceImagePaths.map(() => 'image/png') + : []; + for (let i = 0; i < refImageData.length; i++) { + const mime = refMimeTypes[i] || 'image/png'; + const ext = mime === 'image/jpeg' ? '.jpg' : mime === 'image/webp' ? '.webp' : '.png'; + const filename = `ref-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`; + const filePath = path.join(MEDIA_DIR, filename); + fs.writeFileSync(filePath, Buffer.from(refImageData[i], 'base64')); + savedRefImages.push({ mimeType: mime, localPath: filePath }); + } + } + + // DB record + const id = crypto.randomBytes(16).toString('hex'); + const now = new Date().toISOString().replace('T', ' ').split('.')[0]; + const localPath = savedImages.length > 0 ? savedImages[0].localPath : ''; + + const metadata: Record = { + imageCount: savedImages.length, + elapsedMs: elapsed, + model: requestedModel, + }; + if (savedRefImages.length > 0) { + metadata.referenceImages = savedRefImages; + } + + db.prepare( + `INSERT INTO media_generations (id, type, status, provider, model, prompt, aspect_ratio, image_size, local_path, thumbnail_path, session_id, message_id, tags, metadata, error, created_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, 'image', 'completed', 'gemini', requestedModel, body.prompt, + aspectRatio, imageSize, localPath, '', + body.sessionId || null, null, + '[]', JSON.stringify(metadata), + null, now, now + ); + + return new Response( + JSON.stringify({ + id, + text: '', + images: savedImages, + model: requestedModel, + imageSize, + elapsedMs: elapsed, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } catch (error) { + const elapsed = Date.now() - startTime; + console.error(`[media/generate] Failed after ${elapsed}ms:`, error); + + if (NoImageGeneratedError.isInstance(error)) { + return new Response( + JSON.stringify({ error: 'No images were generated. Try a different prompt.' }), + { status: 422, headers: { 'Content-Type': 'application/json' } } + ); + } + + const message = error instanceof Error ? error.message : 'Failed to generate image'; + return new Response( + JSON.stringify({ error: message }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +} diff --git a/src/app/api/media/serve/route.ts b/src/app/api/media/serve/route.ts new file mode 100644 index 0000000..d799df8 --- /dev/null +++ b/src/app/api/media/serve/route.ts @@ -0,0 +1,58 @@ +import { NextRequest } from 'next/server'; +import fs from 'fs/promises'; +import path from 'path'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', +}; + +/** + * Serve media files from .codepilot-media/ directory. + * Only allows reading from paths that contain '.codepilot-media' to prevent directory traversal. + */ +export async function GET(request: NextRequest) { + const filePath = request.nextUrl.searchParams.get('path'); + + if (!filePath) { + return new Response(JSON.stringify({ error: 'path parameter is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Security: only allow files within .codepilot-media directories + const resolved = path.resolve(filePath); + if (!resolved.includes('.codepilot-media')) { + return new Response(JSON.stringify({ error: 'Access denied' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + try { + await fs.access(resolved); + } catch { + return new Response(JSON.stringify({ error: 'File not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const buffer = await fs.readFile(resolved); + const ext = path.extname(resolved).toLowerCase(); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + + return new Response(buffer, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); +} diff --git a/src/app/api/media/tags/[id]/route.ts b/src/app/api/media/tags/[id]/route.ts new file mode 100644 index 0000000..b5f0981 --- /dev/null +++ b/src/app/api/media/tags/[id]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDb } from '@/lib/db'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const db = getDb(); + + const result = db.prepare('DELETE FROM media_tags WHERE id = ?').run(id); + + if (result.changes === 0) { + return NextResponse.json( + { error: 'Tag not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('[media/tags/[id]] DELETE Error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to delete tag' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/media/tags/route.ts b/src/app/api/media/tags/route.ts new file mode 100644 index 0000000..c444834 --- /dev/null +++ b/src/app/api/media/tags/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDb } from '@/lib/db'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const db = getDb(); + const tags = db.prepare('SELECT * FROM media_tags ORDER BY name ASC').all(); + return NextResponse.json({ tags }); + } catch (error) { + console.error('[media/tags] GET Error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch tags' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + if (!body.name || typeof body.name !== 'string') { + return NextResponse.json( + { error: 'Missing required field: name' }, + { status: 400 } + ); + } + + const db = getDb(); + + // Check for duplicate + const existing = db.prepare('SELECT id FROM media_tags WHERE name = ?').get(body.name); + if (existing) { + return NextResponse.json( + { error: 'Tag with this name already exists' }, + { status: 409 } + ); + } + + const result = db.prepare( + 'INSERT INTO media_tags (name, color) VALUES (?, ?)' + ).run(body.name.trim(), body.color || ''); + + const tag = db.prepare('SELECT * FROM media_tags WHERE id = ?').get(result.lastInsertRowid); + return NextResponse.json({ tag }, { status: 201 }); + } catch (error) { + console.error('[media/tags] POST Error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to create tag' }, + { status: 500 } + ); + } +} diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 68476ff..e85cf30 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -76,7 +76,7 @@ export default function NewChatPage() { }, [pendingPermission, setPendingApprovalSessionId]); const sendFirstMessage = useCallback( - async (content: string) => { + async (content: string, _files?: unknown, systemPromptAppend?: string) => { if (isStreaming) return; // Require a project directory before sending @@ -144,7 +144,7 @@ export default function NewChatPage() { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: session.id, content, mode, model: currentModel, provider_id: currentProviderId }), + body: JSON.stringify({ session_id: session.id, content, mode, model: currentModel, provider_id: currentProviderId, ...(systemPromptAppend ? { systemPromptAppend } : {}) }), signal: controller.signal, }); diff --git a/src/app/gallery/page.tsx b/src/app/gallery/page.tsx new file mode 100644 index 0000000..1432fa0 --- /dev/null +++ b/src/app/gallery/page.tsx @@ -0,0 +1,266 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { + PaintBrush01Icon, + SortingDownIcon, + FilterIcon, + Loading02Icon, +} from '@hugeicons/core-free-icons'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { GalleryGrid, type GalleryItem } from '@/components/gallery/GalleryGrid'; +import { GalleryDetail } from '@/components/gallery/GalleryDetail'; +import { TagManager, useTags, type Tag } from '@/components/gallery/TagManager'; +import { useTranslation } from '@/hooks/useTranslation'; +import type { TranslationKey } from '@/i18n'; + +const PAGE_SIZE = 20; + +type SortOrder = 'newest' | 'oldest'; + +export default function GalleryPage() { + const { t } = useTranslation(); + const { tags, loading: tagsLoading, addTag, removeTag, fetchTags } = useTags(); + + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [offset, setOffset] = useState(0); + + // Filters + const [selectedTags, setSelectedTags] = useState([]); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [sort, setSort] = useState('newest'); + const [showFilters, setShowFilters] = useState(false); + + // Detail + const [selectedItem, setSelectedItem] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + + const fetchItems = useCallback(async (reset = false) => { + setLoading(true); + try { + const params = new URLSearchParams(); + if (selectedTags.length > 0) params.set('tags', selectedTags.join(',')); + if (dateFrom) params.set('dateFrom', dateFrom); + if (dateTo) params.set('dateTo', dateTo); + params.set('sort', sort); + params.set('limit', String(PAGE_SIZE)); + params.set('offset', reset ? '0' : String(offset)); + + const res = await fetch(`/api/media/gallery?${params.toString()}`); + if (res.ok) { + const data = await res.json(); + if (reset) { + setItems(data.items || []); + setOffset(PAGE_SIZE); + } else { + setItems((prev) => [...prev, ...(data.items || [])]); + setOffset((prev) => prev + PAGE_SIZE); + } + setTotal(data.total || 0); + } + } catch { + // ignore + } finally { + setLoading(false); + } + }, [selectedTags, dateFrom, dateTo, sort, offset]); + + // Initial load and reload on filter changes + useEffect(() => { + setOffset(0); + fetchItems(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedTags, dateFrom, dateTo, sort]); + + const handleToggleTag = useCallback((tagId: string) => { + setSelectedTags((prev) => + prev.includes(tagId) + ? prev.filter((t) => t !== tagId) + : [...prev, tagId] + ); + }, []); + + const handleSelect = useCallback((item: GalleryItem) => { + setSelectedItem(item); + setDetailOpen(true); + }, []); + + const handleDelete = useCallback(async (id: string) => { + try { + const res = await fetch(`/api/media/${id}`, { method: 'DELETE' }); + if (res.ok) { + setItems((prev) => prev.filter((item) => item.id !== id)); + setTotal((prev) => prev - 1); + } + } catch { + // ignore + } + }, []); + + const handleTagsChange = useCallback(async (id: string, newTags: string[]) => { + try { + const res = await fetch(`/api/media/${id}/tags`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tags: newTags }), + }); + if (res.ok) { + setItems((prev) => + prev.map((item) => + item.id === id ? { ...item, tags: newTags } : item + ) + ); + // Update selected item too + setSelectedItem((prev) => + prev && prev.id === id ? { ...prev, tags: newTags } : prev + ); + } + } catch { + // ignore + } + }, []); + + const hasMore = items.length < total; + + return ( +
+ {/* Header */} +
+
+

+ {t('gallery.title' as TranslationKey)} +

+
+ {/* Filter toggle */} + + + {/* Sort */} + +
+
+ + {/* Filter bar */} + {showFilters && ( +
+ {/* Tag filter */} + {!tagsLoading && tags.length > 0 && ( +
+ +
+ )} + + {/* Date range */} +
+ + setDateFrom(e.target.value)} + className="h-7 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-1 focus:ring-ring" + /> + + setDateTo(e.target.value)} + className="h-7 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-1 focus:ring-ring" + /> + {(dateFrom || dateTo || selectedTags.length > 0) && ( + + )} +
+
+ )} +
+ + {/* Content */} +
+ {loading && items.length === 0 ? ( +
+ +
+ ) : items.length === 0 ? ( +
+ +

{t('gallery.empty' as TranslationKey)}

+

{t('gallery.emptyHint' as TranslationKey)}

+
+ ) : ( +
+ + {hasMore && ( +
+ +
+ )} +
+ )} +
+ + {/* Detail dialog */} + +
+ ); +} diff --git a/src/components/ai-elements/code-block.tsx b/src/components/ai-elements/code-block.tsx index 44dac44..9b7bf4e 100644 --- a/src/components/ai-elements/code-block.tsx +++ b/src/components/ai-elements/code-block.tsx @@ -149,6 +149,10 @@ const getHighlighter = ( const highlighterPromise = createHighlighter({ langs: [language], themes: ["github-light", "github-dark"], + }).catch(() => { + // Language not supported by Shiki — fall back to plain text + highlighterCache.delete(language); + return getHighlighter("text" as BundledLanguage); }); highlighterCache.set(language, highlighterPromise); diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index a6f5d2f..20f75f5 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -7,6 +7,13 @@ import { MessageInput } from './MessageInput'; import { usePanel } from '@/hooks/usePanel'; import { consumeSSEStream } from '@/hooks/useSSEStream'; +/** + * Module-level store for reference image base64 data. + * Keyed by message ID — ImageGenConfirmation reads from here to pass + * reference images to /api/media/generate without storing base64 in DB. + */ +export const imageGenRefStore = new Map>(); + interface ToolUseInfo { id: string; name: string; @@ -218,14 +225,17 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal }, [pendingPermission, setPendingApprovalSessionId]); const sendMessage = useCallback( - async (content: string, files?: FileAttachment[]) => { + async (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => { if (isStreaming) return; + // Use displayOverride for UI if provided (e.g. image-gen skill injection hides the skill prompt) + const displayUserContent = displayOverride || content; + // Build display content: embed file metadata as HTML comment for MessageItem to parse - let displayContent = content; + let displayContent = displayUserContent; if (files && files.length > 0) { const fileMeta = files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size, data: f.data })); - displayContent = `${content}`; + displayContent = `${displayUserContent}`; } // Optimistic: add user message to UI immediately @@ -276,6 +286,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal model: currentModel, provider_id: currentProviderId, ...(files && files.length > 0 ? { files } : {}), + ...(systemPromptAppend ? { systemPromptAppend } : {}), }), signal: controller.signal, }); @@ -587,6 +598,112 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal } }, [sessionId, sendMessage]); + // Listen for image generation completion — persist notice to DB only (no model call) + // MessageItem hides messages matching this prefix so the user doesn't see them. + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail) return; + const paths = (detail.images || []) + .map((img: { localPath?: string }) => img.localPath) + .filter(Boolean); + const pathInfo = paths.length > 0 ? `, file path: ${paths.join(', ')}` : ''; + const notice = `[__IMAGE_GEN_NOTICE__ prompt: "${detail.prompt}", aspect ratio: ${detail.aspectRatio}, resolution: ${detail.resolution}${pathInfo}. You can use your Read tool to view the generated image if you need to analyze or discuss it.]`; + // Persist to DB only — does NOT trigger model + fetch('/api/chat/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId, role: 'user', content: notice }), + }).catch(() => {}); + }; + window.addEventListener('image-gen-completed', handler); + return () => window.removeEventListener('image-gen-completed', handler); + }, [sessionId]); + + // Direct image generation — bypasses /api/chat, shows ImageGenConfirmation for parameter selection + const handleImageGenerate = useCallback(async (prompt: string, files?: FileAttachment[]) => { + if (!prompt.trim() && (!files || files.length === 0)) return; + + const displayPrompt = prompt || 'Generate an image'; + + // Build user display content — only store file metadata (no base64) for DB + let userDbContent = displayPrompt; + if (files && files.length > 0) { + const metaOnly = files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size })); + userDbContent = `${displayPrompt}`; + } + + // Build user display content for UI — include base64 for immediate rendering + let userUiContent = displayPrompt; + if (files && files.length > 0) { + const metaWithData = files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size, data: f.data })); + userUiContent = `${displayPrompt}`; + } + + // Add user message to UI + setMessages(prev => [...prev, { + id: 'temp-' + Date.now(), + session_id: sessionId, + role: 'user', + content: userUiContent, + created_at: new Date().toISOString(), + token_usage: null, + }]); + + // Persist user message (lightweight, no base64) + fetch('/api/chat/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId, role: 'user', content: userDbContent }), + }).catch(() => {}); + + // Auto-generate title from first message if needed + if (messages.length === 0) { + const title = displayPrompt.slice(0, 50) + (displayPrompt.length > 50 ? '...' : ''); + fetch(`/api/chat/sessions/${sessionId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }).then(() => { + window.dispatchEvent(new CustomEvent('session-updated')); + }).catch(() => {}); + } + + // Store reference images in module-level map for ImageGenConfirmation to pick up + const msgId = 'imggen-' + Date.now(); + if (files && files.length > 0) { + const imageFiles = files.filter(f => f.type.startsWith('image/')); + if (imageFiles.length > 0) { + imageGenRefStore.set(msgId, imageFiles.map(f => ({ mimeType: f.type, data: f.data }))); + } + } + + // Build image-gen-request — renders ImageGenConfirmation with interactive UI + const requestPayload: Record = { + prompt: displayPrompt, + aspectRatio: '1:1', + resolution: '1K', + }; + const requestContent = '```image-gen-request\n' + JSON.stringify(requestPayload) + '\n```'; + + // Add assistant message with ImageGenConfirmation UI + setMessages(prev => [...prev, { + id: msgId, + session_id: sessionId, + role: 'assistant', + content: requestContent, + created_at: new Date().toISOString(), + token_usage: null, + }]); + + // Persist assistant message so it survives page reload + fetch('/api/chat/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId, role: 'assistant', content: requestContent }), + }).catch(() => {}); + }, [sessionId, messages.length]); + return (
isImageFile(f.type)); - const otherFiles = files.filter((f) => !isImageFile(f.type)); + const imageFiles = files.filter((f) => isImageFile(f.type) && fileUrl(f)); + const otherFiles = files.filter((f) => !isImageFile(f.type) || !fileUrl(f)); const lightboxImages = imageFiles.map((f) => ({ src: fileUrl(f), diff --git a/src/components/chat/ImageGenCard.tsx b/src/components/chat/ImageGenCard.tsx new file mode 100644 index 0000000..5e26c31 --- /dev/null +++ b/src/components/chat/ImageGenCard.tsx @@ -0,0 +1,191 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Download04Icon, RepeatIcon, PaintBrush01Icon } from '@hugeicons/core-free-icons'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { ImageLightbox } from './ImageLightbox'; +import { useTranslation } from '@/hooks/useTranslation'; +import type { TranslationKey } from '@/i18n'; + +interface ImageGenImage { + data: string; + mimeType: string; + localPath?: string; +} + +interface ImageGenCardProps { + images: ImageGenImage[]; + prompt: string; + aspectRatio?: string; + imageSize?: string; + model?: string; + onRegenerate?: () => void; + referenceImages?: Array<{ mimeType: string; data: string; localPath?: string }>; +} + +function imageUrl(img: ImageGenImage): string { + if (img.localPath) { + return `/api/media/serve?path=${encodeURIComponent(img.localPath)}`; + } + return `data:${img.mimeType};base64,${img.data}`; +} + +export function ImageGenCard({ + images, + prompt, + aspectRatio, + imageSize, + model, + onRegenerate, + referenceImages, +}: ImageGenCardProps) { + const { t } = useTranslation(); + const [lightboxOpen, setLightboxOpen] = useState(false); + const [lightboxIndex, setLightboxIndex] = useState(0); + + const lightboxImages = images.map((img, i) => ({ + src: imageUrl(img), + alt: `${t('imageGen.generated' as TranslationKey)} ${i + 1}`, + })); + + const handlePreview = useCallback((index: number) => { + setLightboxIndex(index); + setLightboxOpen(true); + }, []); + + const handleDownload = useCallback(async (img: ImageGenImage, index: number) => { + const url = imageUrl(img); + const ext = img.mimeType.split('/')[1] || 'png'; + const filename = `generated-${Date.now()}-${index + 1}.${ext}`; + + try { + const response = await fetch(url); + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = blobUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(blobUrl); + } catch { + // Fallback: open in new tab + window.open(url, '_blank'); + } + }, []); + + if (images.length === 0) return null; + + const gridCols = + images.length === 1 + ? 'grid-cols-1 max-w-sm' + : images.length === 2 + ? 'grid-cols-2 max-w-md' + : 'grid-cols-3 max-w-lg'; + + return ( +
+ {/* Image grid */} +
+ {images.map((img, i) => ( + + ))} +
+ + {/* Prompt text */} +

{prompt}

+ + {/* Reference images (垫图) */} + {referenceImages && referenceImages.length > 0 && ( +
+ + {t('imageGen.referenceImages' as TranslationKey)} + +
+ {referenceImages.map((ref, i) => { + const src = ref.localPath + ? `/api/media/serve?path=${encodeURIComponent(ref.localPath)}` + : ref.data + ? `data:${ref.mimeType};base64,${ref.data}` + : ''; + if (!src) return null; + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Reference +
+ ); + })} +
+
+ )} + + {/* Badges + Actions */} +
+
+ {model && ( + + + {model} + + )} + {aspectRatio && ( + + {aspectRatio} + + )} + {imageSize && ( + + {imageSize} + + )} +
+ +
+ + {onRegenerate && ( + + )} +
+
+ + +
+ ); +} diff --git a/src/components/chat/ImageGenConfirmation.tsx b/src/components/chat/ImageGenConfirmation.tsx new file mode 100644 index 0000000..aff298c --- /dev/null +++ b/src/components/chat/ImageGenConfirmation.tsx @@ -0,0 +1,355 @@ +'use client'; + +import { useState, useCallback, useEffect, useRef } from 'react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { ImageGenCard } from './ImageGenCard'; +import { useTranslation } from '@/hooks/useTranslation'; +import { usePanel } from '@/hooks/usePanel'; +import type { TranslationKey } from '@/i18n'; +import type { ImageGenResult } from '@/hooks/useImageGen'; + +const ASPECT_RATIOS = [ + '1:1', '16:9', '9:16', '3:2', '2:3', '4:3', '3:4', '4:5', '5:4', '21:9', +] as const; + +const RESOLUTIONS = ['1K', '2K', '4K'] as const; + +interface ImageGenConfirmationProps { + initialPrompt: string; + initialAspectRatio: string; + initialResolution: string; + referenceImagePaths?: string[]; + referenceImagesData?: Array<{ mimeType: string; data: string }>; +} + +type Status = 'idle' | 'generating' | 'completed' | 'error'; + +/** Stable key for persisting generation results in localStorage */ +function storageKey(prompt: string, sessionId?: string): string { + const prefix = sessionId ? `${sessionId}:` : ''; + return `imggen:${prefix}${prompt.slice(0, 80)}`; +} + +export function ImageGenConfirmation({ + initialPrompt, + initialAspectRatio, + initialResolution, + referenceImagePaths, + referenceImagesData, +}: ImageGenConfirmationProps) { + const { t } = useTranslation(); + const { sessionId } = usePanel(); + const [prompt, setPrompt] = useState(initialPrompt); + const [aspectRatio, setAspectRatio] = useState( + ASPECT_RATIOS.includes(initialAspectRatio as typeof ASPECT_RATIOS[number]) + ? initialAspectRatio + : '1:1' + ); + const [resolution, setResolution] = useState( + RESOLUTIONS.includes(initialResolution as typeof RESOLUTIONS[number]) + ? initialResolution + : '1K' + ); + const [status, setStatus] = useState('idle'); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + // Restore completed result from localStorage on mount + useEffect(() => { + try { + const saved = localStorage.getItem(storageKey(initialPrompt, sessionId)); + if (saved) { + const parsed: ImageGenResult = JSON.parse(saved); + if (parsed.images && parsed.images.length > 0) { + setResult(parsed); + setStatus('completed'); + } + } + } catch { + // ignore + } + }, [initialPrompt]); + + const handleStop = useCallback(() => { + if (abortRef.current) { + abortRef.current.abort(); + abortRef.current = null; + } + setStatus('idle'); + }, []); + + const handleGenerate = useCallback(async () => { + const controller = new AbortController(); + abortRef.current = controller; + setStatus('generating'); + setError(null); + + try { + const res = await fetch('/api/media/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt, + aspectRatio, + imageSize: resolution, + sessionId, + ...(referenceImagesData && referenceImagesData.length > 0 + ? { referenceImages: referenceImagesData } + : referenceImagePaths && referenceImagePaths.length > 0 + ? { referenceImagePaths } + : {}), + }), + signal: controller.signal, + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Generation failed' })); + throw new Error(err.error || 'Generation failed'); + } + + const data = await res.json(); + const genResult: ImageGenResult = { + id: data.id, + text: data.text, + images: data.images || [], + }; + + if (genResult.images.length > 0) { + setResult(genResult); + setStatus('completed'); + + // Store lightweight version in localStorage + try { + const storable = { + id: genResult.id, + text: genResult.text, + images: genResult.images.map(img => ({ + mimeType: img.mimeType, + localPath: img.localPath, + data: '', + })), + }; + localStorage.setItem(storageKey(initialPrompt, sessionId), JSON.stringify(storable)); + } catch { + // storage full + } + + // Defer event dispatch so React commits setResult/setStatus before + // ChatView's handler calls sendMessage and triggers a re-render + setTimeout(() => { + window.dispatchEvent(new CustomEvent('image-gen-completed', { + detail: { + prompt, + aspectRatio, + resolution, + id: genResult.id, + images: genResult.images, + }, + })); + }, 0); + } else { + setError('No images were generated'); + setStatus('error'); + } + } catch (err) { + if ((err as Error).name === 'AbortError') { + setStatus('idle'); + return; + } + setError((err as Error).message || 'Generation failed'); + setStatus('error'); + } finally { + abortRef.current = null; + } + }, [prompt, aspectRatio, resolution, initialPrompt, sessionId, referenceImagePaths, referenceImagesData]); + + const handleRegenerate = useCallback(() => { + setResult(null); + setStatus('idle'); + try { + localStorage.removeItem(storageKey(initialPrompt, sessionId)); + } catch { + // ignore + } + }, [initialPrompt]); + + // ── Completed: show result only ── + if (status === 'completed' && result && result.images.length > 0) { + return ( +
+ +
+ ); + } + + // ── Idle / Generating / Error: show params card ── + return ( +
+ {/* Header */} +
+ {t('imageGen.confirmTitle' as TranslationKey)} +
+ +
+ {/* Reference images preview */} + {referenceImagesData && referenceImagesData.length > 0 ? ( +
+ +
+ {referenceImagesData.map((img, i) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Reference +
+ ))} +
+
+ ) : referenceImagePaths && referenceImagePaths.length > 0 ? ( +
+ +
+ {referenceImagePaths.map((p, i) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Reference +
+ ))} +
+
+ ) : null} + + {/* Prompt textarea */} +
+ +