feat: refactor image generation to bypass model dependency, fix UI issues

Image Generation Refactor:
- MessageInput: add onImageGenerate callback, remove skill fetch dependency
- ChatView: implement handleImageGenerate that creates image-gen-request
  message for ImageGenConfirmation UI, then user selects params and generates
- New API route POST /api/chat/messages for DB-only message persistence
- image-gen-completed event now only persists notice to DB, no longer triggers model
- Reference images passed via module-level Map (imageGenRefStore) to avoid
  DB bloat from base64 data; user message DB content stores metadata only

Bug Fixes:
- Fix useCallback dependency missing imageGen causing stale closure reads
- Fix React Hooks order violation in MessageItem (early return before hooks)
- Fix localStorage cache collision across sessions (key now includes sessionId)
- Fix empty src attribute in FileAttachmentDisplay for DB-restored messages
- Add JSON parse fallback for malformed image-gen-request blocks

Gallery Reference Images:
- /api/media/generate saves reference images to disk, stores paths in metadata
- /api/media/gallery extracts referenceImages from metadata in mapRow
- GalleryDetail displays reference image thumbnails
- ImageGenCard supports referenceImages prop for chat display

UI Changes:
- ImageGenToggle: replace paint brush icon with text label (en: Image, zh: 生图)
- Model selector button: remove provider name display to save space
- ImageGenConfirmation: support referenceImagesData prop for base64 preview

i18n:
- Add imageGen.toggleLabel key (en: Image, zh: 生图)
This commit is contained in:
7418 committed 2026-02-26 21:07:48 +08:00
1 parent 97dc9d3a8c
commit 00d3fef5a4
40 files changed
+3272 -94

No files matched your search

+14 -40
View File
@@ -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
+211
View File
@@ -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),稳定后再切读路径
+46
View File
@@ -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",
+1
View File
@@ -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",
+61
View File
@@ -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"
+39
View File
@@ -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 });
}
}
+31 -4
View File
@@ -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
+73
View File
@@ -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 }
);
}
}
+49
View File
@@ -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 }
);
}
}
+154
View File
@@ -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 }
);
}
}
+204
View File
@@ -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<string, unknown> = {
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' } }
);
}
}
+58
View File
@@ -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<string, string> = {
'.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',
},
});
}
+33
View File
@@ -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 }
);
}
}
+56
View File
@@ -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 }
);
}
}
+2 -2
View File
@@ -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,
});
+266
View File
@@ -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<GalleryItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [offset, setOffset] = useState(0);
// Filters
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [sort, setSort] = useState<SortOrder>('newest');
const [showFilters, setShowFilters] = useState(false);
// Detail
const [selectedItem, setSelectedItem] = useState<GalleryItem | null>(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 (
<div className="flex h-full flex-col overflow-hidden">
{/* Header */}
<div className="shrink-0 border-b border-border/50 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<h1 className="text-lg font-semibold">
{t('gallery.title' as TranslationKey)}
</h1>
<div className="flex items-center gap-2">
{/* Filter toggle */}
<Button
variant={showFilters ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setShowFilters(!showFilters)}
>
<HugeiconsIcon icon={FilterIcon} className="h-3.5 w-3.5" />
{t('gallery.filters' as TranslationKey)}
</Button>
{/* Sort */}
<Button
variant="ghost"
size="sm"
onClick={() => setSort((s) => (s === 'newest' ? 'oldest' : 'newest'))}
>
<HugeiconsIcon icon={SortingDownIcon} className="h-3.5 w-3.5" />
{sort === 'newest'
? t('gallery.newestFirst' as TranslationKey)
: t('gallery.oldestFirst' as TranslationKey)}
</Button>
</div>
</div>
{/* Filter bar */}
{showFilters && (
<div className="mt-3 space-y-2.5">
{/* Tag filter */}
{!tagsLoading && tags.length > 0 && (
<div>
<TagManager
tags={tags}
selectedTags={selectedTags}
onToggleTag={handleToggleTag}
compact
/>
</div>
)}
{/* Date range */}
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">
{t('gallery.dateFrom' as TranslationKey)}
</label>
<input
type="date"
value={dateFrom}
onChange={(e) => 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"
/>
<label className="text-xs text-muted-foreground">
{t('gallery.dateTo' as TranslationKey)}
</label>
<input
type="date"
value={dateTo}
onChange={(e) => 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) && (
<Button
variant="ghost"
size="xs"
onClick={() => {
setDateFrom('');
setDateTo('');
setSelectedTags([]);
}}
>
{t('gallery.clearFilters' as TranslationKey)}
</Button>
)}
</div>
</div>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{loading && items.length === 0 ? (
<div className="flex h-full items-center justify-center">
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : items.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground">
<HugeiconsIcon icon={PaintBrush01Icon} className="h-10 w-10 opacity-30" />
<p className="text-sm">{t('gallery.empty' as TranslationKey)}</p>
<p className="text-xs opacity-70">{t('gallery.emptyHint' as TranslationKey)}</p>
</div>
) : (
<div className="space-y-4">
<GalleryGrid
items={items}
tags={tags}
onSelect={handleSelect}
/>
{hasMore && (
<div className="flex justify-center py-4">
<Button
variant="outline"
size="sm"
onClick={() => fetchItems(false)}
disabled={loading}
>
{loading ? (
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin" />
) : (
t('gallery.loadMore' as TranslationKey)
)}
</Button>
</div>
)}
</div>
)}
</div>
{/* Detail dialog */}
<GalleryDetail
item={selectedItem}
tags={tags}
open={detailOpen}
onOpenChange={setDetailOpen}
onDelete={handleDelete}
onTagsChange={handleTagsChange}
/>
</div>
);
}
@@ -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);
+121 -3
View File
@@ -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<string, Array<{ mimeType: string; data: string }>>();
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 = `<!--files:${JSON.stringify(fileMeta)}-->${content}`;
displayContent = `<!--files:${JSON.stringify(fileMeta)}-->${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 = `<!--files:${JSON.stringify(metaOnly)}-->${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 = `<!--files:${JSON.stringify(metaWithData)}-->${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<string, unknown> = {
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 (
<div className="flex h-full min-h-0 flex-col">
<MessageList
@@ -607,6 +724,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
/>
<MessageInput
onSend={sendMessage}
onImageGenerate={handleImageGenerate}
onCommand={handleCommand}
onStop={stopStreaming}
disabled={false}
@@ -26,8 +26,8 @@ export function FileAttachmentDisplay({ files }: FileAttachmentDisplayProps) {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const imageFiles = files.filter((f) => 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),
+191
View File
@@ -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 (
<div className="rounded-lg border border-border/50 bg-card p-3 space-y-2.5">
{/* Image grid */}
<div className={cn('grid gap-2', gridCols)}>
{images.map((img, i) => (
<button
key={i}
type="button"
onClick={() => handlePreview(i)}
className="relative group overflow-hidden rounded-md border border-border/30 bg-muted/30"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl(img)}
alt={`${t('imageGen.generated' as TranslationKey)} ${i + 1}`}
className="w-full h-auto object-cover transition-transform group-hover:scale-[1.02]"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
</button>
))}
</div>
{/* Prompt text */}
<p className="text-sm text-foreground/80 leading-relaxed">{prompt}</p>
{/* Reference images (垫图) */}
{referenceImages && referenceImages.length > 0 && (
<div>
<span className="text-[10px] text-muted-foreground/60 mb-1 block">
{t('imageGen.referenceImages' as TranslationKey)}
</span>
<div className="flex gap-1.5 flex-wrap">
{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 (
<div key={i} className="w-12 h-12 rounded border border-border/30 overflow-hidden bg-muted/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={`Reference ${i + 1}`} className="w-full h-full object-cover" />
</div>
);
})}
</div>
</div>
)}
{/* Badges + Actions */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 flex-wrap">
{model && (
<Badge variant="secondary" className="text-[10px] gap-1">
<HugeiconsIcon icon={PaintBrush01Icon} className="h-3 w-3" />
{model}
</Badge>
)}
{aspectRatio && (
<Badge variant="outline" className="text-[10px]">
{aspectRatio}
</Badge>
)}
{imageSize && (
<Badge variant="outline" className="text-[10px]">
{imageSize}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-xs"
onClick={() => handleDownload(images[0], 0)}
title={t('imageGen.download' as TranslationKey)}
>
<HugeiconsIcon icon={Download04Icon} className="h-3.5 w-3.5" />
</Button>
{onRegenerate && (
<Button
variant="ghost"
size="icon-xs"
onClick={onRegenerate}
title={t('imageGen.regenerate' as TranslationKey)}
>
<HugeiconsIcon icon={RepeatIcon} className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
<ImageLightbox
images={lightboxImages}
initialIndex={lightboxIndex}
open={lightboxOpen}
onOpenChange={setLightboxOpen}
/>
</div>
);
}
@@ -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<Status>('idle');
const [result, setResult] = useState<ImageGenResult | null>(null);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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 (
<div className="my-2">
<ImageGenCard
images={result.images}
prompt={prompt}
aspectRatio={aspectRatio}
imageSize={resolution}
onRegenerate={handleRegenerate}
referenceImages={referenceImagesData}
/>
</div>
);
}
// ── Idle / Generating / Error: show params card ──
return (
<div className="rounded-lg border border-border/50 bg-card overflow-hidden my-2">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/30 bg-muted/30">
<span className="text-sm font-medium">{t('imageGen.confirmTitle' as TranslationKey)}</span>
</div>
<div className="p-4 space-y-3">
{/* Reference images preview */}
{referenceImagesData && referenceImagesData.length > 0 ? (
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
{t('imageGen.referenceImages' as TranslationKey)}
</label>
<div className="flex gap-2 flex-wrap">
{referenceImagesData.map((img, i) => (
<div key={i} className="w-16 h-16 rounded-md border border-border/30 overflow-hidden bg-muted/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`data:${img.mimeType};base64,${img.data}`}
alt={`Reference ${i + 1}`}
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</div>
) : referenceImagePaths && referenceImagePaths.length > 0 ? (
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
{t('imageGen.referenceImages' as TranslationKey)}
</label>
<div className="flex gap-2 flex-wrap">
{referenceImagePaths.map((p, i) => (
<div key={i} className="w-16 h-16 rounded-md border border-border/30 overflow-hidden bg-muted/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/uploads?path=${encodeURIComponent(p)}`}
alt={`Reference ${i + 1}`}
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</div>
) : null}
{/* Prompt textarea */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t('imageGen.prompt' as TranslationKey)}
</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
disabled={status === 'generating'}
rows={3}
className={cn(
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm',
'resize-none focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30',
'disabled:opacity-60 disabled:cursor-not-allowed'
)}
/>
</div>
{/* Aspect Ratio */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
{t('imageGen.aspectRatio' as TranslationKey)}
</label>
<div className="flex flex-wrap gap-1.5">
{ASPECT_RATIOS.map((ratio) => (
<button
key={ratio}
type="button"
disabled={status === 'generating'}
onClick={() => setAspectRatio(ratio)}
className={cn(
'px-2.5 py-1 text-xs font-medium rounded-md border transition-colors',
'disabled:opacity-60 disabled:cursor-not-allowed',
aspectRatio === ratio
? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400'
: 'border-border/60 text-muted-foreground hover:text-foreground hover:border-foreground/30'
)}
>
{ratio}
</button>
))}
</div>
</div>
{/* Resolution */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
{t('imageGen.resolution' as TranslationKey)}
</label>
<div className="flex items-center gap-1.5">
{RESOLUTIONS.map((res) => (
<button
key={res}
type="button"
disabled={status === 'generating'}
onClick={() => setResolution(res)}
className={cn(
'px-3 py-1 text-xs font-medium rounded-md border transition-colors',
'disabled:opacity-60 disabled:cursor-not-allowed',
resolution === res
? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400'
: 'border-border/60 text-muted-foreground hover:text-foreground hover:border-foreground/30'
)}
>
{res}
</button>
))}
</div>
</div>
{/* Generate button */}
{status === 'idle' && (
<div className="pt-1">
<Button
onClick={handleGenerate}
disabled={!prompt.trim()}
size="sm"
className="gap-1.5"
>
{t('imageGen.generateButton' as TranslationKey)}
</Button>
</div>
)}
{/* Generating: spinner + stop */}
{status === 'generating' && (
<div className="pt-1">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm text-muted-foreground">
{t('imageGen.generatingStatus' as TranslationKey)}
</span>
</div>
<Button onClick={handleStop} variant="outline" size="sm">
{t('imageGen.stopButton' as TranslationKey)}
</Button>
</div>
</div>
)}
{/* Error */}
{status === 'error' && error && (
<div className="space-y-2">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
<Button onClick={handleGenerate} variant="outline" size="sm">
{t('imageGen.retryButton' as TranslationKey)}
</Button>
</div>
)}
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
'use client';
import { cn } from '@/lib/utils';
import { useImageGen } from '@/hooks/useImageGen';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
export function ImageGenToggle() {
const { state, setEnabled } = useImageGen();
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setEnabled(!state.enabled)}
className={cn(
'inline-flex items-center justify-center rounded-full px-2.5 h-7 text-xs font-medium border transition-all',
state.enabled
? 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30'
: 'text-muted-foreground border-border/60 hover:text-foreground hover:border-foreground/30 hover:bg-accent/50'
)}
>
{t('imageGen.toggleLabel' as TranslationKey)}
</button>
</TooltipTrigger>
<TooltipContent>
{t('imageGen.toggleTooltip' as TranslationKey)}
</TooltipContent>
</Tooltip>
);
}
+20 -5
View File
@@ -35,6 +35,8 @@ import {
import type { ChatStatus } from 'ai';
import type { FileAttachment, ProviderModelGroup } from '@/types';
import { nanoid } from 'nanoid';
import { ImageGenToggle } from './ImageGenToggle';
import { useImageGen } from '@/hooks/useImageGen';
// Accepted file types for upload
const ACCEPTED_FILE_TYPES = [
@@ -50,7 +52,8 @@ const MAX_DOC_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_FILE_SIZE = MAX_DOC_SIZE; // Use larger limit; we validate per-type in conversion
interface MessageInputProps {
onSend: (content: string, files?: FileAttachment[]) => void;
onSend: (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => void;
onImageGenerate?: (prompt: string, files?: FileAttachment[]) => void;
onCommand?: (command: string) => void;
onStop?: () => void;
disabled?: boolean;
@@ -326,6 +329,7 @@ function FileAttachmentsCapsules() {
export function MessageInput({
onSend,
onImageGenerate,
onCommand,
onStop,
disabled,
@@ -340,6 +344,7 @@ export function MessageInput({
onModeChange,
}: MessageInputProps) {
const { t } = useTranslation();
const imageGen = useImageGen();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -596,6 +601,16 @@ export function MessageInput({
return attachments;
};
// If imageGen toggle is on and no badge, call onImageGenerate directly (no model dependency)
if (imageGen.state.enabled && !badge && !isStreaming && onImageGenerate) {
const files = await convertFiles();
const hasFiles = files.length > 0;
if (!content && !hasFiles) return;
setInputValue('');
onImageGenerate(content, hasFiles ? files : undefined);
return;
}
// If badge is active, expand the command/skill and send
if (badge && !isStreaming) {
let expandedPrompt = '';
@@ -674,7 +689,7 @@ export function MessageInput({
onSend(content || 'Please review the attached file(s).', hasFiles ? files : undefined);
setInputValue('');
}, [inputValue, onSend, onCommand, disabled, isStreaming, closePopover, badge]);
}, [inputValue, onSend, onImageGenerate, onCommand, disabled, isStreaming, closePopover, badge, imageGen]);
const filteredItems = popoverItems.filter((item) => {
const q = popoverFilter.toLowerCase();
@@ -1083,9 +1098,6 @@ export function MessageInput({
onClick={() => setModelMenuOpen((prev) => !prev)}
>
<span className="text-xs font-mono">{currentModelOption.label}</span>
{currentGroup && currentGroup.provider_name !== 'Anthropic' && currentGroup.provider_name !== 'Environment' && (
<span className="text-[9px] text-muted-foreground">{currentGroup.provider_name}</span>
)}
<HugeiconsIcon icon={ArrowDown01Icon} className={cn("h-2.5 w-2.5 transition-transform duration-200", modelMenuOpen && "rotate-180")} />
</PromptInputButton>
@@ -1130,6 +1142,9 @@ export function MessageInput({
</div>
)}
</div>
{/* Image generation toggle */}
<ImageGenToggle />
</PromptInputTools>
<FileAwareSubmitButton
+167 -9
View File
@@ -11,6 +11,87 @@ import { ToolActionsGroup } from '@/components/ai-elements/tool-actions-group';
import { HugeiconsIcon } from "@hugeicons/react";
import { Copy01Icon, Tick01Icon, ArrowDown01Icon, ArrowUp01Icon } from "@hugeicons/core-free-icons";
import { FileAttachmentDisplay } from './FileAttachmentDisplay';
import { ImageGenConfirmation } from './ImageGenConfirmation';
import { ImageGenCard } from './ImageGenCard';
import { imageGenRefStore } from './ChatView';
import { parseDBDate } from '@/lib/utils';
interface ImageGenRequest {
prompt: string;
aspectRatio: string;
resolution: string;
referenceImages?: string[];
}
function parseImageGenRequest(text: string): { beforeText: string; request: ImageGenRequest; afterText: string } | null {
const regex = /```image-gen-request\s*\n?([\s\S]*?)\n?\s*```/;
const match = text.match(regex);
if (!match) return null;
try {
let raw = match[1].trim();
let json: Record<string, unknown>;
try {
json = JSON.parse(raw);
} catch {
// Attempt to fix common model output issues: unescaped quotes in values
raw = raw.replace(/"prompt"\s*:\s*"([\s\S]*?)"\s*([,}])/g, (_m, val, tail) => {
const escaped = val.replace(/(?<!\\)"/g, '\\"');
return `"prompt": "${escaped}"${tail}`;
});
json = JSON.parse(raw);
}
const beforeText = text.slice(0, match.index).trim();
const afterText = text.slice((match.index || 0) + match[0].length).trim();
return {
beforeText,
request: {
prompt: String(json.prompt || ''),
aspectRatio: String(json.aspectRatio || '1:1'),
resolution: String(json.resolution || '1K'),
referenceImages: Array.isArray(json.referenceImages) ? json.referenceImages : undefined,
},
afterText,
};
} catch {
return null;
}
}
interface ImageGenResultData {
status: 'generating' | 'completed' | 'error';
prompt: string;
aspectRatio?: string;
resolution?: string;
model?: string;
images?: Array<{ mimeType: string; localPath?: string; data?: string }>;
error?: string;
}
function parseImageGenResult(text: string): { beforeText: string; result: ImageGenResultData; afterText: string } | null {
const regex = /```image-gen-result\s*\n?([\s\S]*?)\n?\s*```/;
const match = text.match(regex);
if (!match) return null;
try {
const json = JSON.parse(match[1]);
const beforeText = text.slice(0, match.index).trim();
const afterText = text.slice((match.index || 0) + match[0].length).trim();
return {
beforeText,
result: {
status: json.status || 'completed',
prompt: String(json.prompt || ''),
aspectRatio: json.aspectRatio,
resolution: json.resolution,
model: json.model,
images: Array.isArray(json.images) ? json.images : undefined,
error: json.error,
},
afterText,
};
} catch {
return null;
}
}
interface MessageItemProps {
message: Message;
@@ -206,6 +287,17 @@ const COLLAPSE_HEIGHT = 300;
export function MessageItem({ message }: MessageItemProps) {
const isUser = message.role === 'user';
// Collapse/expand state for long user messages (hooks must be called unconditionally)
const [isExpanded, setIsExpanded] = useState(false);
const [isOverflowing, setIsOverflowing] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
// Hide image-gen system notices — they exist in DB for Claude's context but shouldn't render
if (isUser && message.content.startsWith('[__IMAGE_GEN_NOTICE__')) {
return null;
}
const { text, tools } = parseToolBlocks(message.content);
const pairedTools = pairTools(tools);
@@ -216,11 +308,6 @@ export function MessageItem({ message }: MessageItemProps) {
const displayText = isUser ? textWithoutFiles : text;
// Collapse/expand state for long user messages
const [isExpanded, setIsExpanded] = useState(false);
const [isOverflowing, setIsOverflowing] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isUser && contentRef.current) {
setIsOverflowing(contentRef.current.scrollHeight > COLLAPSE_HEIGHT);
@@ -236,7 +323,7 @@ export function MessageItem({ message }: MessageItemProps) {
}
}
const timestamp = new Date(message.created_at).toLocaleTimeString([], {
const timestamp = parseDBDate(message.created_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
@@ -300,9 +387,80 @@ export function MessageItem({ message }: MessageItemProps) {
</button>
)}
</div>
) : (
<MessageResponse>{displayText}</MessageResponse>
)
) : (() => {
// Try image-gen-result first (new direct-call format)
const genResult = parseImageGenResult(displayText);
if (genResult) {
const { result } = genResult;
if (result.status === 'generating') {
return (
<>
{genResult.beforeText && <MessageResponse>{genResult.beforeText}</MessageResponse>}
<div className="flex items-center gap-2 py-3">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm text-muted-foreground">Generating image...</span>
</div>
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
</>
);
}
if (result.status === 'error') {
return (
<>
{genResult.beforeText && <MessageResponse>{genResult.beforeText}</MessageResponse>}
<div className="rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/30 p-3">
<p className="text-sm text-red-600 dark:text-red-400">{result.error || 'Image generation failed'}</p>
</div>
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
</>
);
}
if (result.status === 'completed' && result.images && result.images.length > 0) {
return (
<>
{genResult.beforeText && <MessageResponse>{genResult.beforeText}</MessageResponse>}
<ImageGenCard
images={result.images.map(img => ({
data: img.data || '',
mimeType: img.mimeType,
localPath: img.localPath,
}))}
prompt={result.prompt}
aspectRatio={result.aspectRatio}
imageSize={result.resolution}
model={result.model}
/>
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
</>
);
}
}
// Legacy: image-gen-request (model-dependent format, for old messages)
const parsed = parseImageGenRequest(displayText);
if (parsed) {
// Check module-level store for reference image base64 data
const refData = imageGenRefStore.get(message.id);
return (
<>
{parsed.beforeText && <MessageResponse>{parsed.beforeText}</MessageResponse>}
<ImageGenConfirmation
initialPrompt={parsed.request.prompt}
initialAspectRatio={parsed.request.aspectRatio}
initialResolution={parsed.request.resolution}
referenceImagePaths={parsed.request.referenceImages}
referenceImagesData={refData}
/>
{parsed.afterText && <MessageResponse>{parsed.afterText}</MessageResponse>}
</>
);
}
const stripped = displayText
.replace(/```image-gen-request[\s\S]*?```/g, '')
.replace(/```image-gen-result[\s\S]*?```/g, '')
.trim();
return stripped ? <MessageResponse>{stripped}</MessageResponse> : null;
})()
)}
</MessageContent>
+72 -3
View File
@@ -18,9 +18,51 @@ import {
ConfirmationAction,
} from '@/components/ai-elements/confirmation';
import { Shimmer } from '@/components/ai-elements/shimmer';
import { ImageGenConfirmation } from './ImageGenConfirmation';
import type { ToolUIPart } from 'ai';
import type { PermissionRequestEvent } from '@/types';
interface ImageGenRequest {
prompt: string;
aspectRatio: string;
resolution: string;
referenceImages?: string[];
}
function parseImageGenRequest(text: string): { beforeText: string; request: ImageGenRequest; afterText: string } | null {
const regex = /```image-gen-request\s*\n?([\s\S]*?)\n?\s*```/;
const match = text.match(regex);
if (!match) return null;
try {
let raw = match[1].trim();
let json: Record<string, unknown>;
try {
json = JSON.parse(raw);
} catch {
// Attempt to fix common model output issues: unescaped quotes in values
raw = raw.replace(/"prompt"\s*:\s*"([\s\S]*?)"\s*([,}])/g, (_m, val, tail) => {
const escaped = val.replace(/(?<!\\)"/g, '\\"');
return `"prompt": "${escaped}"${tail}`;
});
json = JSON.parse(raw);
}
const beforeText = text.slice(0, match.index).trim();
const afterText = text.slice((match.index || 0) + match[0].length).trim();
return {
beforeText,
request: {
prompt: String(json.prompt || ''),
aspectRatio: String(json.aspectRatio || '1:1'),
resolution: String(json.resolution || '1K'),
referenceImages: Array.isArray(json.referenceImages) ? json.referenceImages : undefined,
},
afterText,
};
} catch {
return null;
}
}
interface ToolUseInfo {
id: string;
name: string;
@@ -449,9 +491,36 @@ export function StreamingMessage({
)}
{/* Streaming text content rendered via Streamdown */}
{content && (
<MessageResponse>{content}</MessageResponse>
)}
{content && (() => {
console.log('[StreamingMessage] content:', JSON.stringify(content.slice(0, 500)));
const parsed = parseImageGenRequest(content);
console.log('[StreamingMessage] parseImageGenRequest result:', parsed ? 'OK' : 'null', 'isStreaming:', isStreaming);
if (parsed) {
return (
<>
{parsed.beforeText && <MessageResponse>{parsed.beforeText}</MessageResponse>}
<ImageGenConfirmation
initialPrompt={parsed.request.prompt}
initialAspectRatio={parsed.request.aspectRatio}
initialResolution={parsed.request.resolution}
referenceImagePaths={parsed.request.referenceImages}
/>
{parsed.afterText && <MessageResponse>{parsed.afterText}</MessageResponse>}
</>
);
}
// Strip partial or unparseable image-gen-request blocks to avoid Shiki errors
if (isStreaming) {
const hasImageGenBlock = /```image-gen-request/.test(content);
const stripped = content.replace(/```image-gen-request[\s\S]*$/, '').trim();
if (stripped) return <MessageResponse>{stripped}</MessageResponse>;
// Show shimmer while the image-gen block is being streamed
if (hasImageGenBlock) return <Shimmer>{t('streaming.thinking')}</Shimmer>;
return null;
}
const stripped = content.replace(/```image-gen-request[\s\S]*?```/g, '').trim();
return stripped ? <MessageResponse>{stripped}</MessageResponse> : null;
})()}
{/* Loading indicator when no content yet */}
{isStreaming && !content && toolUses.length === 0 && !pendingPermission && (
+278
View File
@@ -0,0 +1,278 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { HugeiconsIcon } from '@hugeicons/react';
import {
Download04Icon,
Delete02Icon,
PaintBrush01Icon,
Message02Icon,
ArrowLeft01Icon,
ArrowRight01Icon,
} from '@hugeicons/core-free-icons';
import { cn, parseDBDate } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { TagManager, type Tag } from './TagManager';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import type { GalleryItem } from './GalleryGrid';
interface GalleryDetailProps {
item: GalleryItem | null;
tags: Tag[];
open: boolean;
onOpenChange: (open: boolean) => void;
onDelete?: (id: string) => void;
onTagsChange?: (id: string, tags: string[]) => void;
}
function imageUrl(img: GalleryItem['images'][0]): string {
if (img.localPath) {
return `/api/media/serve?path=${encodeURIComponent(img.localPath)}`;
}
if (img.data) {
return `data:${img.mimeType};base64,${img.data}`;
}
return '';
}
function formatDate(dateStr: string): string {
try {
const date = parseDBDate(dateStr);
return date.toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return dateStr;
}
}
export function GalleryDetail({
item,
tags,
open,
onOpenChange,
onDelete,
onTagsChange,
}: GalleryDetailProps) {
const { t } = useTranslation();
const router = useRouter();
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [confirmDelete, setConfirmDelete] = useState(false);
// Reset image index when item changes
useEffect(() => {
setCurrentImageIndex(0);
setConfirmDelete(false);
}, [item?.id]);
const handleDownload = useCallback(async () => {
if (!item) return;
const img = item.images[currentImageIndex];
if (!img) return;
const url = imageUrl(img);
const ext = img.mimeType.split('/')[1] || 'png';
const filename = `generated-${item.id}-${currentImageIndex + 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 {
window.open(url, '_blank');
}
}, [item, currentImageIndex]);
const handleDelete = useCallback(() => {
if (!item) return;
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
onDelete?.(item.id);
onOpenChange(false);
setConfirmDelete(false);
}, [item, confirmDelete, onDelete, onOpenChange]);
const handleToggleTag = useCallback((tagId: string) => {
if (!item) return;
const current = item.tags || [];
const next = current.includes(tagId)
? current.filter((t) => t !== tagId)
: [...current, tagId];
onTagsChange?.(item.id, next);
}, [item, onTagsChange]);
if (!item) return null;
const currentImage = item.images[currentImageIndex];
const hasMultipleImages = item.images.length > 1;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto p-0 gap-0" showCloseButton>
<DialogTitle className="sr-only">
{t('gallery.imageDetail' as TranslationKey)}
</DialogTitle>
{/* Image preview */}
<div className="relative bg-muted/20 flex items-center justify-center min-h-[300px]">
{currentImage && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageUrl(currentImage)}
alt={item.prompt}
className="max-w-full max-h-[60vh] object-contain"
/>
)}
{hasMultipleImages && (
<>
<button
type="button"
onClick={() => setCurrentImageIndex((i) => (i > 0 ? i - 1 : item.images.length - 1))}
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70 transition"
>
<HugeiconsIcon icon={ArrowLeft01Icon} className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => setCurrentImageIndex((i) => (i < item.images.length - 1 ? i + 1 : 0))}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70 transition"
>
<HugeiconsIcon icon={ArrowRight01Icon} className="h-5 w-5" />
</button>
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2 py-0.5 text-xs text-white">
{currentImageIndex + 1} / {item.images.length}
</div>
</>
)}
</div>
{/* Details */}
<div className="p-4 space-y-4">
{/* Prompt */}
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
{t('gallery.prompt' as TranslationKey)}
</div>
<p className="text-sm text-foreground leading-relaxed">{item.prompt}</p>
</div>
{/* Metadata badges */}
<div className="flex items-center gap-1.5 flex-wrap">
{item.model && (
<Badge variant="secondary" className="text-[10px] gap-1">
<HugeiconsIcon icon={PaintBrush01Icon} className="h-3 w-3" />
{item.model}
</Badge>
)}
{item.aspectRatio && (
<Badge variant="outline" className="text-[10px]">
{item.aspectRatio}
</Badge>
)}
{item.imageSize && (
<Badge variant="outline" className="text-[10px]">
{item.imageSize}
</Badge>
)}
</div>
{/* Reference images (垫图) */}
{item.referenceImages && item.referenceImages.length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-1.5">
{t('imageGen.referenceImages' as TranslationKey)}
</div>
<div className="flex gap-2 flex-wrap">
{item.referenceImages.map((ref, i) => {
const src = ref.localPath
? `/api/media/serve?path=${encodeURIComponent(ref.localPath)}`
: '';
if (!src) return null;
return (
<div key={i} className="w-14 h-14 rounded-md border border-border/30 overflow-hidden bg-muted/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={`Reference ${i + 1}`} className="w-full h-full object-cover" />
</div>
);
})}
</div>
</div>
)}
{/* Date */}
<div className="text-xs text-muted-foreground">
{formatDate(item.created_at)}
</div>
{/* Tags */}
<div>
<div className="text-xs font-medium text-muted-foreground mb-1.5">
{t('gallery.tags' as TranslationKey)}
</div>
<TagManager
tags={tags}
selectedTags={item.tags}
onToggleTag={handleToggleTag}
editable={false}
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
{item.session_id && (
<Button
variant="outline"
size="sm"
onClick={() => {
onOpenChange(false);
router.push(`/chat/${item.session_id}`);
}}
>
<HugeiconsIcon icon={Message02Icon} className="h-3.5 w-3.5" />
{t('gallery.openChat' as TranslationKey)}
</Button>
)}
<Button variant="outline" size="sm" onClick={handleDownload}>
<HugeiconsIcon icon={Download04Icon} className="h-3.5 w-3.5" />
{t('gallery.download' as TranslationKey)}
</Button>
<div className="ml-auto">
<Button
variant={confirmDelete ? 'destructive' : 'ghost'}
size="sm"
onClick={handleDelete}
>
<HugeiconsIcon icon={Delete02Icon} className="h-3.5 w-3.5" />
{confirmDelete
? t('gallery.confirmDelete' as TranslationKey)
: t('gallery.delete' as TranslationKey)}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+124
View File
@@ -0,0 +1,124 @@
'use client';
import { HugeiconsIcon } from '@hugeicons/react';
import { PaintBrush01Icon } from '@hugeicons/core-free-icons';
import { parseDBDate } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import type { Tag } from './TagManager';
export interface GalleryItem {
id: string;
prompt: string;
images: Array<{ data?: string; mimeType: string; localPath?: string }>;
model?: string;
aspectRatio?: string;
imageSize?: string;
tags: string[];
created_at: string;
session_id?: string;
referenceImages?: Array<{ mimeType: string; localPath: string }>;
}
interface GalleryGridProps {
items: GalleryItem[];
tags: Tag[];
onSelect: (item: GalleryItem) => void;
}
function thumbnailUrl(item: GalleryItem): string {
const img = item.images[0];
if (!img) return '';
if (img.localPath) {
return `/api/media/serve?path=${encodeURIComponent(img.localPath)}`;
}
if (img.data) {
return `data:${img.mimeType};base64,${img.data}`;
}
return '';
}
function formatDate(dateStr: string): string {
try {
const date = parseDBDate(dateStr);
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
} catch {
return dateStr;
}
}
export function GalleryGrid({ items, tags, onSelect }: GalleryGridProps) {
const tagMap = new Map(tags.map((t) => [t.id, t]));
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{items.map((item) => {
const url = thumbnailUrl(item);
const itemTags = item.tags
.map((tid) => tagMap.get(tid))
.filter((t): t is Tag => !!t);
return (
<button
key={item.id}
type="button"
onClick={() => onSelect(item)}
className="group text-left rounded-lg border border-border/50 bg-card overflow-hidden hover:border-border transition-colors"
>
{/* Thumbnail */}
<div className="relative aspect-square bg-muted/30 overflow-hidden">
{url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={item.prompt}
className="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
loading="lazy"
/>
) : (
<div className="flex h-full items-center justify-center">
<HugeiconsIcon icon={PaintBrush01Icon} className="h-8 w-8 text-muted-foreground/30" />
</div>
)}
{item.images.length > 1 && (
<span className="absolute top-1.5 right-1.5 rounded-full bg-black/50 px-1.5 py-0.5 text-[10px] text-white font-medium">
{item.images.length}
</span>
)}
</div>
{/* Info */}
<div className="p-2.5 space-y-1.5">
<p className="text-xs text-foreground/80 line-clamp-2 leading-relaxed">
{item.prompt}
</p>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground">
{formatDate(item.created_at)}
</span>
{itemTags.length > 0 && (
<div className="flex items-center gap-1 overflow-hidden">
{itemTags.slice(0, 2).map((tag) => (
<span
key={tag.id}
className="inline-block h-2 w-2 rounded-full shrink-0"
style={{ backgroundColor: tag.color || '#6b7280' }}
title={tag.name}
/>
))}
{itemTags.length > 2 && (
<span className="text-[9px] text-muted-foreground">+{itemTags.length - 2}</span>
)}
</div>
)}
</div>
</div>
</button>
);
})}
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { HugeiconsIcon } from '@hugeicons/react';
import { PlusSignIcon, Cancel01Icon } from '@hugeicons/core-free-icons';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
export interface Tag {
id: string;
name: string;
color?: string;
}
const PRESET_COLORS = [
'#3b82f6', // blue
'#10b981', // emerald
'#f59e0b', // amber
'#ef4444', // red
'#8b5cf6', // violet
'#ec4899', // pink
'#06b6d4', // cyan
'#f97316', // orange
];
interface TagManagerProps {
tags: Tag[];
selectedTags?: string[];
onToggleTag?: (tagId: string) => void;
onAddTag?: (name: string, color?: string) => void;
onRemoveTag?: (tagId: string) => void;
editable?: boolean;
compact?: boolean;
}
export function TagManager({
tags,
selectedTags = [],
onToggleTag,
onAddTag,
onRemoveTag,
editable = false,
compact = false,
}: TagManagerProps) {
const { t } = useTranslation();
const [adding, setAdding] = useState(false);
const [newName, setNewName] = useState('');
const [newColor, setNewColor] = useState(PRESET_COLORS[0]);
const handleAdd = useCallback(() => {
if (!newName.trim()) return;
onAddTag?.(newName.trim(), newColor);
setNewName('');
setAdding(false);
}, [newName, newColor, onAddTag]);
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-1.5">
{tags.map((tag) => {
const isSelected = selectedTags.includes(tag.id);
return (
<button
key={tag.id}
type="button"
onClick={() => onToggleTag?.(tag.id)}
className="group inline-flex items-center gap-1"
>
<Badge
variant={isSelected ? 'default' : 'outline'}
className={cn(
'text-[11px] cursor-pointer transition-colors',
compact && 'px-1.5 py-0',
isSelected && tag.color && 'border-transparent'
)}
style={isSelected && tag.color ? { backgroundColor: `${tag.color}20`, color: tag.color, borderColor: `${tag.color}40` } : undefined}
>
{tag.color && (
<span
className="inline-block h-2 w-2 rounded-full shrink-0"
style={{ backgroundColor: tag.color }}
/>
)}
{tag.name}
{editable && onRemoveTag && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveTag(tag.id);
}}
className="ml-0.5 rounded-full p-0.5 opacity-0 group-hover:opacity-100 hover:bg-foreground/10 transition-opacity"
>
<HugeiconsIcon icon={Cancel01Icon} className="h-2.5 w-2.5" />
</button>
)}
</Badge>
</button>
);
})}
{editable && !adding && (
<Button
variant="ghost"
size="icon-xs"
onClick={() => setAdding(true)}
className="h-5 w-5"
>
<HugeiconsIcon icon={PlusSignIcon} className="h-3 w-3" />
</Button>
)}
</div>
{adding && (
<div className="flex items-center gap-2">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleAdd();
if (e.key === 'Escape') setAdding(false);
}}
placeholder={t('gallery.newTagPlaceholder' as TranslationKey)}
className="flex-1 h-7 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
autoFocus
/>
<div className="flex items-center gap-1">
{PRESET_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setNewColor(color)}
className={cn(
'h-4 w-4 rounded-full transition-all',
newColor === color ? 'ring-2 ring-offset-1 ring-offset-background' : 'hover:scale-110'
)}
style={{ backgroundColor: color, '--tw-ring-color': color } as React.CSSProperties}
/>
))}
</div>
<Button size="xs" onClick={handleAdd}>
{t('gallery.addTag' as TranslationKey)}
</Button>
<Button variant="ghost" size="xs" onClick={() => setAdding(false)}>
{t('gallery.cancel' as TranslationKey)}
</Button>
</div>
)}
</div>
);
}
// Hook to fetch tags from API
export function useTags() {
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const fetchTags = useCallback(async () => {
try {
const res = await fetch('/api/media/tags');
if (res.ok) {
const data = await res.json();
setTags(data.tags || []);
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTags();
}, [fetchTags]);
const addTag = useCallback(async (name: string, color?: string) => {
try {
const res = await fetch('/api/media/tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, color }),
});
if (res.ok) {
const tag = await res.json();
setTags((prev) => [...prev, tag]);
return tag;
}
} catch {
// ignore
}
return null;
}, []);
const removeTag = useCallback(async (tagId: string) => {
try {
const res = await fetch(`/api/media/tags/${tagId}`, { method: 'DELETE' });
if (res.ok) {
setTags((prev) => prev.filter((t) => t.id !== tagId));
}
} catch {
// ignore
}
}, []);
return { tags, loading, fetchTags, addTag, removeTag };
}
+5
View File
@@ -12,6 +12,7 @@ import { UpdateBanner } from "./UpdateBanner";
import { DocPreview } from "./DocPreview";
import { PanelContext, type PanelContent, type PreviewViewMode } from "@/hooks/usePanel";
import { UpdateContext, type UpdateInfo } from "@/hooks/useUpdate";
import { ImageGenContext, useImageGenState } from "@/hooks/useImageGen";
import { ErrorBoundary } from "./ErrorBoundary";
const CHATLIST_MIN = 180;
@@ -342,9 +343,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
[panelOpen, setPanelOpen, panelContent, workingDirectory, sessionId, sessionTitle, streamingSessionId, pendingApprovalSessionId, previewFile, setPreviewFile, previewViewMode]
);
const imageGenValue = useImageGenState();
return (
<UpdateContext.Provider value={updateContextValue}>
<PanelContext.Provider value={panelContextValue}>
<ImageGenContext.Provider value={imageGenValue}>
<TooltipProvider delayDuration={300}>
<div className="flex h-screen overflow-hidden">
<NavRail
@@ -396,6 +400,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div>
<UpdateDialog />
</TooltipProvider>
</ImageGenContext.Provider>
</PanelContext.Provider>
</UpdateContext.Provider>
);
+4 -4
View File
@@ -23,7 +23,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { cn, parseDBDate } from "@/lib/utils";
import { usePanel } from "@/hooks/usePanel";
import { useTranslation } from "@/hooks/useTranslation";
import { useNativeFolderPicker } from "@/hooks/useNativeFolderPicker";
@@ -38,7 +38,7 @@ interface ChatListPanelProps {
}
function formatRelativeTime(dateStr: string, t: (key: import('@/i18n').TranslationKey, params?: Record<string, string | number>) => string): string {
const date = new Date(dateStr.includes("T") ? dateStr : dateStr + "Z");
const date = parseDBDate(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
@@ -90,13 +90,13 @@ function groupSessionsByProject(sessions: ChatSession[]): ProjectGroup[] {
// Sort sessions within group by updated_at DESC
groupSessions.sort(
(a, b) =>
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
parseDBDate(b.updated_at).getTime() - parseDBDate(a.updated_at).getTime()
);
const displayName =
wd === ""
? "No Project"
: groupSessions[0]?.project_name || wd.split("/").pop() || wd;
const latestUpdatedAt = new Date(groupSessions[0].updated_at).getTime();
const latestUpdatedAt = parseDBDate(groupSessions[0].updated_at).getTime();
groups.push({
workingDirectory: wd,
displayName,
@@ -24,7 +24,7 @@ import {
MessageAddIcon,
} from "@hugeicons/core-free-icons";
import { useTranslation } from "@/hooks/useTranslation";
import { cn } from "@/lib/utils";
import { cn, parseDBDate } from "@/lib/utils";
interface ClaudeSessionInfo {
sessionId: string;
@@ -47,7 +47,7 @@ interface ImportSessionDialogProps {
}
function formatRelativeTime(dateStr: string): { key: 'import.justNow' | 'import.minutesAgo' | 'import.hoursAgo' | 'import.daysAgo'; params?: Record<string, number> } | string {
const date = new Date(dateStr);
const date = parseDBDate(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
+3
View File
@@ -8,6 +8,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
import {
Message02Icon,
GridIcon,
Image01Icon,
Settings02Icon,
Moon02Icon,
Sun02Icon,
@@ -34,6 +35,7 @@ interface NavRailProps {
const navItems = [
{ href: "/chat", label: "Chats", icon: Message02Icon },
{ href: "/extensions", label: "Extensions", icon: GridIcon },
{ href: "/gallery", label: "Gallery", icon: Image01Icon },
{ href: "/settings", label: "Settings", icon: Settings02Icon },
] as const;
@@ -45,6 +47,7 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
const navLabelKeys: Record<string, TranslationKey> = {
'Chats': 'nav.chats',
'Extensions': 'nav.extensions',
'Gallery': 'gallery.title',
'Settings': 'nav.settings',
};
const emptySubscribe = useCallback(() => () => {}, []);
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState, useRef, useEffect } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Delete02Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { cn, parseDBDate } from "@/lib/utils";
import type { TaskItem, TaskStatus } from "@/types";
interface TaskCardProps {
@@ -28,7 +28,7 @@ const nextStatus: Record<TaskStatus, TaskStatus> = {
};
function formatTime(dateStr: string): string {
const date = new Date(dateStr.includes("T") ? dateStr : dateStr + "Z");
const date = parseDBDate(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
+49 -2
View File
@@ -96,6 +96,8 @@ interface QuickPreset {
extra_env: string;
// Which fields user must fill
fields: ("name" | "api_key" | "base_url" | "extra_env" | "model_names")[];
// Category: 'chat' (default) or 'media'
category?: "chat" | "media";
}
const QUICK_PRESETS: QuickPreset[] = [
@@ -242,6 +244,18 @@ const QUICK_PRESETS: QuickPreset[] = [
extra_env: "{}",
fields: ["api_key", "base_url"],
},
{
key: "gemini-image",
name: "Google Gemini (Image)",
description: "Nano Banana Pro — AI image generation by Google Gemini",
descriptionZh: "Nano Banana Pro — Google Gemini AI 图片生成",
icon: <Google size={18} />,
provider_type: "gemini-image",
base_url: "https://generativelanguage.googleapis.com/v1beta",
extra_env: '{"GEMINI_API_KEY":""}',
fields: ["api_key"],
category: "media",
},
];
// ---------------------------------------------------------------------------
@@ -644,8 +658,41 @@ export function ProviderManager() {
{t('provider.addProviderDesc')}
</p>
<div>
{QUICK_PRESETS.map((preset) => (
{/* Chat Providers */}
<div className="mb-1">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
{t('provider.chatProviders')}
</h4>
{QUICK_PRESETS.filter((p) => p.category !== "media").map((preset) => (
<div
key={preset.key}
className="flex items-center gap-3 py-2.5 px-1 border-b border-border/30 last:border-b-0"
>
<div className="shrink-0 w-[22px] flex justify-center">{preset.icon}</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{preset.name}</span>
<p className="text-xs text-muted-foreground truncate">
{isZh ? preset.descriptionZh : preset.description}
</p>
</div>
<Button
variant="outline"
size="xs"
className="shrink-0 gap-1"
onClick={() => handleOpenPresetDialog(preset)}
>
+ {t('provider.connect')}
</Button>
</div>
))}
</div>
{/* Media Providers */}
<div className="mt-4 pt-3 border-t border-border/30">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
{t('provider.mediaProviders')}
</h4>
{QUICK_PRESETS.filter((p) => p.category === "media").map((preset) => (
<div
key={preset.key}
className="flex items-center gap-3 py-2.5 px-1 border-b border-border/30 last:border-b-0"
+108
View File
@@ -0,0 +1,108 @@
'use client';
import { createContext, useContext, useState, useCallback, useRef } from 'react';
export interface ImageGenResult {
id: string;
text?: string;
images: Array<{ data: string; mimeType: string; localPath?: string }>;
}
export interface ImageGenState {
enabled: boolean;
generating: boolean;
}
export interface ImageGenContextValue {
state: ImageGenState;
setEnabled: (v: boolean) => void;
generate: (prompt: string, aspectRatio: string, imageSize: string, referenceImages?: File[]) => Promise<ImageGenResult | null>;
lastResult: ImageGenResult | null;
}
export const ImageGenContext = createContext<ImageGenContextValue | null>(null);
export function useImageGen(): ImageGenContextValue {
const ctx = useContext(ImageGenContext);
if (!ctx) {
throw new Error('useImageGen must be used within an ImageGenProvider');
}
return ctx;
}
export function useImageGenState(): ImageGenContextValue {
const [enabled, setEnabled] = useState(false);
const [generating, setGenerating] = useState(false);
const [lastResult, setLastResult] = useState<ImageGenResult | null>(null);
const abortRef = useRef<AbortController | null>(null);
const generate = useCallback(async (prompt: string, aspectRatio: string, imageSize: string, referenceImages?: File[]): Promise<ImageGenResult | null> => {
if (abortRef.current) {
abortRef.current.abort();
}
const controller = new AbortController();
abortRef.current = controller;
setGenerating(true);
try {
const body: Record<string, unknown> = {
prompt,
aspectRatio,
imageSize,
};
if (referenceImages && referenceImages.length > 0) {
const refImagesData: Array<{ data: string; mimeType: string; name: string }> = [];
for (const file of referenceImages) {
const buffer = await file.arrayBuffer();
const base64 = btoa(
new Uint8Array(buffer).reduce((data, byte) => data + String.fromCharCode(byte), '')
);
refImagesData.push({
data: base64,
mimeType: file.type,
name: file.name,
});
}
body.referenceImages = refImagesData;
}
const res = await fetch('/api/media/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
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 result: ImageGenResult = {
id: data.id,
text: data.text,
images: data.images || [],
};
setLastResult(result);
return result;
} catch (err) {
if ((err as Error).name === 'AbortError') {
return null;
}
throw err;
} finally {
setGenerating(false);
abortRef.current = null;
}
}, []);
return {
state: { enabled, generating },
setEnabled,
generate,
lastResult,
};
}
+65
View File
@@ -307,6 +307,71 @@ const en = {
'update.readyToInstall': 'CodePilot v{version} is ready — restart to update',
'update.later': 'Later',
// ── Image Generation ──────────────────────────────────────
'imageGen.toggle': 'Image Generation',
'imageGen.toggleLabel': 'Image',
'imageGen.toggleTooltip': 'Toggle image generation mode',
'imageGen.generating': 'Generating image...',
'imageGen.params': 'Generation Parameters',
'imageGen.aspectRatio': 'Aspect Ratio',
'imageGen.resolution': 'Resolution',
'imageGen.generate': 'Generate',
'imageGen.regenerate': 'Regenerate',
'imageGen.download': 'Download',
'imageGen.prompt': 'Prompt',
'imageGen.model': 'Model',
'imageGen.noApiKey': 'Please configure Gemini API Key in Settings > Providers',
'imageGen.error': 'Image generation failed',
'imageGen.success': 'Image generated successfully',
'imageGen.referenceImages': 'Reference Images',
'imageGen.uploadReference': 'Upload reference image',
'imageGen.resetChat': 'Reset conversation',
'imageGen.multiTurnHint': 'You can describe changes to modify the generated image',
'imageGen.settings': 'Generation Settings',
'imageGen.generated': 'Generated image',
'imageGen.confirmTitle': 'Image Generation',
'imageGen.editPrompt': 'Edit prompt',
'imageGen.generateButton': 'Generate',
'imageGen.retryButton': 'Retry',
'imageGen.generatingStatus': 'Generating...',
'imageGen.stopButton': 'Stop',
// ── Gallery ─────────────────────────────────────────────────
'gallery.title': 'Gallery',
'gallery.empty': 'No images generated yet',
'gallery.emptyHint': 'Generate images in chat to see them here.',
'gallery.filterByTag': 'Filter by tag',
'gallery.dateFrom': 'From',
'gallery.dateTo': 'To',
'gallery.sortNewest': 'Newest first',
'gallery.sortOldest': 'Oldest first',
'gallery.newestFirst': 'Newest first',
'gallery.oldestFirst': 'Oldest first',
'gallery.filters': 'Filters',
'gallery.clearFilters': 'Clear filters',
'gallery.loadMore': 'Load more',
'gallery.openChat': 'Open Chat',
'gallery.delete': 'Delete',
'gallery.confirmDelete': 'Confirm delete?',
'gallery.deleteConfirm': 'Delete this image? This cannot be undone.',
'gallery.tags': 'Tags',
'gallery.addTag': 'Add tag',
'gallery.newTag': 'New tag name',
'gallery.newTagPlaceholder': 'New tag name...',
'gallery.cancel': 'Cancel',
'gallery.removeTag': 'Remove tag',
'gallery.noTags': 'No tags',
'gallery.generatedAt': 'Generated at',
'gallery.viewDetails': 'View details',
'gallery.imageDetail': 'Image Detail',
'gallery.prompt': 'Prompt',
'gallery.download': 'Download',
// ── Provider: Gemini Image ──────────────────────────────────
'provider.chatProviders': 'Chat Providers',
'provider.mediaProviders': 'Media Providers',
'provider.geminiImageDesc': 'Nano Banana Pro — AI image generation by Google Gemini',
// ── CLI dynamic field labels ──────────────────────────────
'cli.loadingSettings': 'Loading settings...',
'cli.field.skipDangerousModePermissionPrompt': 'Skip Dangerous Mode Permission Prompt',
+65
View File
@@ -304,6 +304,71 @@ const zh: Record<TranslationKey, string> = {
'update.readyToInstall': 'CodePilot v{version} 已就绪 — 重启以完成更新',
'update.later': '稍后',
// ── Image Generation ──────────────────────────────────────
'imageGen.toggle': '图片生成',
'imageGen.toggleLabel': '生图',
'imageGen.toggleTooltip': '切换图片生成模式',
'imageGen.generating': '正在生成图片...',
'imageGen.params': '生成参数',
'imageGen.aspectRatio': '画面比例',
'imageGen.resolution': '分辨率',
'imageGen.generate': '生成',
'imageGen.regenerate': '重新生成',
'imageGen.download': '下载',
'imageGen.prompt': '提示词',
'imageGen.model': '模型',
'imageGen.noApiKey': '请先在设置 > 服务商中配置 Gemini API Key',
'imageGen.error': '图片生成失败',
'imageGen.success': '图片生成成功',
'imageGen.referenceImages': '参考图片',
'imageGen.uploadReference': '上传参考图片',
'imageGen.resetChat': '重置对话',
'imageGen.multiTurnHint': '你可以描述需要的修改来调整生成的图片',
'imageGen.settings': '生成设置',
'imageGen.generated': '生成的图片',
'imageGen.confirmTitle': '图片生成',
'imageGen.editPrompt': '编辑提示词',
'imageGen.generateButton': '生成',
'imageGen.retryButton': '重试',
'imageGen.generatingStatus': '正在生成...',
'imageGen.stopButton': '停止',
// ── Gallery ─────────────────────────────────────────────────
'gallery.title': '素材库',
'gallery.empty': '暂无生成的图片',
'gallery.emptyHint': '在对话中生成图片后即可在此查看。',
'gallery.filterByTag': '按标签筛选',
'gallery.dateFrom': '开始日期',
'gallery.dateTo': '结束日期',
'gallery.sortNewest': '最新优先',
'gallery.sortOldest': '最早优先',
'gallery.newestFirst': '最新优先',
'gallery.oldestFirst': '最早优先',
'gallery.filters': '筛选',
'gallery.clearFilters': '清除筛选',
'gallery.loadMore': '加载更多',
'gallery.openChat': '打开对话',
'gallery.delete': '删除',
'gallery.confirmDelete': '确认删除?',
'gallery.deleteConfirm': '确定删除这张图片?此操作无法撤销。',
'gallery.tags': '标签',
'gallery.addTag': '添加标签',
'gallery.newTag': '新标签名称',
'gallery.newTagPlaceholder': '新标签名称...',
'gallery.cancel': '取消',
'gallery.removeTag': '移除标签',
'gallery.noTags': '暂无标签',
'gallery.generatedAt': '生成时间',
'gallery.viewDetails': '查看详情',
'gallery.imageDetail': '图片详情',
'gallery.prompt': '提示词',
'gallery.download': '下载',
// ── Provider: Gemini Image ──────────────────────────────────
'provider.chatProviders': '聊天服务商',
'provider.mediaProviders': '媒体服务商',
'provider.geminiImageDesc': 'Nano Banana Pro — Google Gemini AI 图片生成',
// ── CLI dynamic field labels ──────────────────────────────
'cli.loadingSettings': '加载设置中...',
'cli.field.skipDangerousModePermissionPrompt': '跳过危险模式权限提示',
+69 -16
View File
@@ -224,6 +224,46 @@ function getUploadedFilePaths(files: FileAttachment[], workDir: string): string[
return paths;
}
/**
* Build a context-enriched prompt by prepending conversation history.
* Used when SDK session resume is unavailable or fails.
*/
function buildPromptWithHistory(
prompt: string,
history?: Array<{ role: 'user' | 'assistant'; content: string }>,
): string {
if (!history || history.length === 0) return prompt;
const lines: string[] = ['<conversation_history>'];
for (const msg of history) {
// For assistant messages with tool blocks (JSON arrays), summarize
let content = msg.content;
if (msg.role === 'assistant' && content.startsWith('[')) {
try {
const blocks = JSON.parse(content);
const parts: string[] = [];
for (const b of blocks) {
if (b.type === 'text' && b.text) parts.push(b.text);
else if (b.type === 'tool_use') parts.push(`[Used tool: ${b.name}]`);
else if (b.type === 'tool_result') {
const resultStr = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
// Truncate long tool results
parts.push(`[Tool result: ${resultStr.slice(0, 500)}${resultStr.length > 500 ? '...' : ''}]`);
}
}
content = parts.join('\n');
} catch {
// Not JSON, use as-is
}
}
lines.push(`${msg.role === 'user' ? 'Human' : 'Assistant'}: ${content}`);
}
lines.push('</conversation_history>');
lines.push('');
lines.push(prompt);
return lines.join('\n');
}
export function streamClaude(options: ClaudeStreamOptions): ReadableStream<string> {
const {
prompt,
@@ -237,6 +277,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
permissionMode,
files,
toolTimeoutSeconds = 0,
conversationHistory,
} = options;
return new ReadableStream<string>({
@@ -493,28 +534,39 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
}
};
// Build the prompt with file attachments.
// Images → sent as multimodal base64 content blocks (vision).
// Non-image files → saved to disk and referenced via Read tool.
let finalPrompt: string | AsyncIterable<SDKUserMessage> = prompt;
// Build the prompt with file attachments and optional conversation history.
// When resuming, the SDK has full context so we send the raw prompt.
// When NOT resuming (fresh or fallback), prepend DB history for context.
function buildFinalPrompt(useHistory: boolean): string | AsyncIterable<SDKUserMessage> {
const basePrompt = useHistory
? buildPromptWithHistory(prompt, conversationHistory)
: prompt;
if (!files || files.length === 0) return basePrompt;
if (files && files.length > 0) {
const imageFiles = files.filter(f => isImageFile(f.type));
const nonImageFiles = files.filter(f => !isImageFile(f.type));
// Save non-image files to disk for Read tool access
let textPrompt = prompt;
let textPrompt = basePrompt;
if (nonImageFiles.length > 0) {
const workDir = workingDirectory || os.homedir();
const savedPaths = getUploadedFilePaths(nonImageFiles, workDir);
const fileReferences = savedPaths
.map((p, i) => `[User attached file: ${p} (${nonImageFiles[i].name})]`)
.join('\n');
textPrompt = `${fileReferences}\n\nPlease read the attached file(s) above using your Read tool, then respond to the user's message:\n\n${prompt}`;
textPrompt = `${fileReferences}\n\nPlease read the attached file(s) above using your Read tool, then respond to the user's message:\n\n${basePrompt}`;
}
// If there are images, build a multimodal SDKUserMessage
if (imageFiles.length > 0) {
// Append image disk paths to the text prompt so Claude knows where
// the files are on disk (enables skills to reference them by path).
const workDir = workingDirectory || os.homedir();
const imagePaths = getUploadedFilePaths(imageFiles, workDir);
const imageReferences = imagePaths
.map((p, i) => `[User attached image: ${p} (${imageFiles[i].name})]`)
.join('\n');
const textWithImageRefs = `${imageReferences}\n\n${textPrompt}`;
const contentBlocks: Array<
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
| { type: 'text'; text: string }
@@ -531,7 +583,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
});
}
contentBlocks.push({ type: 'text', text: textPrompt });
contentBlocks.push({ type: 'text', text: textWithImageRefs });
const userMessage: SDKUserMessage = {
type: 'user',
@@ -543,15 +595,16 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
session_id: sdkSessionId || '',
};
// Create a single-message async iterable
finalPrompt = (async function* () {
return (async function* () {
yield userMessage;
})();
} else {
finalPrompt = textPrompt;
}
return textPrompt;
}
let finalPrompt = buildFinalPrompt(!shouldResume);
// Try to start the conversation. If resuming a previous session fails
// (e.g. stale/corrupt session file, CLI version mismatch), automatically
// fall back to starting a fresh conversation without resume.
@@ -592,10 +645,10 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
message: 'Previous session could not be resumed. Starting fresh conversation.',
}),
}));
// Remove resume and try again as a fresh conversation
// Remove resume and try again as a fresh conversation with history context
delete queryOptions.resume;
conversation = query({
prompt: finalPrompt,
prompt: buildFinalPrompt(true),
options: queryOptions,
});
}
+18
View File
@@ -4,3 +4,21 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Parse a date string from the database as UTC.
*
* DB timestamps are stored as UTC but without timezone indicator
* (e.g. "2026-02-26 07:23:45"). `new Date()` would misinterpret
* these as local time. This helper normalises the string so the
* browser always treats it as UTC.
*/
export function parseDBDate(dateStr: string): Date {
if (!dateStr) return new Date(0);
// Already has timezone info (ISO with Z or offset) — parse as-is
if (dateStr.includes('Z') || dateStr.includes('+') || /T\d{2}:\d{2}:\d{2}[+-]/.test(dateStr)) {
return new Date(dateStr);
}
// DB format "YYYY-MM-DD HH:mm:ss" — append Z to mark as UTC
return new Date(dateStr.replace(' ', 'T') + 'Z');
}
+2
View File
@@ -448,4 +448,6 @@ export interface ClaudeStreamOptions {
files?: FileAttachment[];
toolTimeoutSeconds?: number;
provider?: ApiProvider;
/** Recent conversation history from DB — used as fallback context when SDK resume is unavailable or fails */
conversationHistory?: Array<{ role: 'user' | 'assistant'; content: string }>;
}