Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions infra/lib/lambda/image-ocr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const SUPPORTED_EXTENSIONS = new Set([
".png",
".gif",
".webp",
".pdf",
]);

export const handler = async (event: S3Event) => {
Expand Down Expand Up @@ -56,7 +57,8 @@ export const handler = async (event: S3Event) => {
const mediaType = resolveMediaType(ext);

// 2. Claude Vision で OCR + 説明文生成
const analysis = await analyzeImage(base64Image, mediaType);
const isPdf = ext === ".pdf";
const analysis = await analyzeImage(base64Image, mediaType, isPdf);
console.log(
`Analysis done — ocr: "${analysis.ocrText.slice(0, 60)}", desc: "${analysis.description.slice(0, 60)}"`,
);
Expand Down Expand Up @@ -97,7 +99,26 @@ export const handler = async (event: S3Event) => {
async function analyzeImage(
base64Image: string,
mediaType: string,
isPdf: boolean,
): Promise<{ ocrText: string; description: string }> {
const fileContent = isPdf
? {
type: "document" as const,
source: {
type: "base64" as const,
media_type: "application/pdf" as const,
data: base64Image,
},
}
: {
type: "image" as const,
source: {
type: "base64" as const,
media_type: mediaType,
data: base64Image,
},
};

const response = await bedrock.send(
new InvokeModelCommand({
modelId: VLM_MODEL_ID,
Expand All @@ -110,14 +131,7 @@ async function analyzeImage(
{
role: "user",
content: [
{
type: "image",
source: {
type: "base64",
media_type: mediaType,
data: base64Image,
},
},
fileContent,
{
type: "text",
text: `この画像を分析してください。以下の2つの情報をJSON形式で返してください。
Expand Down Expand Up @@ -158,5 +172,6 @@ function resolveMediaType(ext: string): string {
if (ext === ".png") return "image/png";
if (ext === ".gif") return "image/gif";
if (ext === ".webp") return "image/webp";
if (ext === ".pdf") return "application/pdf";
return "image/jpeg";
}
15 changes: 7 additions & 8 deletions web/app/(auth)/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,13 @@ export const {
...authConfig,
providers: [
Credentials({
credentials: {},
async authorize({
email,
password,
}: {
email: string;
password: string;
}) {
credentials: {
email: { type: "text" },
password: { type: "password" },
},
async authorize(credentials) {
const email = credentials.email as string;
const password = credentials.password as string;
const users = await getUser(email);

if (users.length === 0) {
Expand Down
4 changes: 2 additions & 2 deletions web/app/(chat)/api/files/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ export async function POST(request: Request) {
const validatedFile = FileSchema.safeParse({ file });

if (!validatedFile.success) {
const errorMessage = validatedFile.error.errors
.map((error) => error.message)
const errorMessage = validatedFile.error.issues
.map((issue) => issue.message)
.join(", ");

return NextResponse.json({ error: errorMessage }, { status: 400 });
Expand Down
45 changes: 30 additions & 15 deletions web/app/image-chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,17 @@ export default function ImageChatPage() {
return (
<div className="flex flex-col h-screen bg-background">
{/* Header */}
<div className="border-b px-4 py-3 flex items-center justify-between">
<h1 className="text-lg font-semibold">Image Chat</h1>
<a href="/images" className="text-sm text-muted-foreground hover:underline">
Search Mode
<div className="border-b px-4 py-3 flex items-center justify-between bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary text-primary-foreground font-bold text-sm">
H
</div>
<h1 className="text-lg font-semibold tracking-tight">Hitode</h1>
</div>
<a href="/images">
<Button variant="outline" size="sm">
Upload
</Button>
</a>
</div>

Expand All @@ -93,7 +100,7 @@ export default function ImageChatPage() {
<div className="mx-auto max-w-2xl space-y-6">
{messages.length === 0 && (
<div className="text-center text-muted-foreground mt-20">
<p className="text-2xl mb-2">Image Chat</p>
<p className="text-2xl mb-2">画像に関するチャット</p>
<p className="text-sm">
画像について質問してみてください
</p>
Expand Down Expand Up @@ -150,15 +157,23 @@ export default function ImageChatPage() {
<div
className={`${expandedImages.has(img.imageId) ? "" : "aspect-square"} relative bg-muted`}
>
<img
src={img.imageUrl}
alt={img.filename}
className="object-contain w-full h-full"
onError={(e) => {
(e.target as HTMLImageElement).style.display =
"none";
}}
/>
{img.filename.toLowerCase().endsWith(".pdf") ? (
<iframe
src={img.imageUrl}
title={img.filename}
className="w-full h-full"
/>
) : (
<img
src={img.imageUrl}
alt={img.filename}
className="object-contain w-full h-full"
onError={(e) => {
(e.target as HTMLImageElement).style.display =
"none";
}}
/>
)}
</div>
<CardContent className="p-2">
<span className="text-xs text-muted-foreground">
Expand Down Expand Up @@ -203,7 +218,7 @@ export default function ImageChatPage() {
className="flex-1"
/>
<Button type="submit" disabled={isLoading || !input.trim()}>
Send
送信
</Button>
</form>
</div>
Expand Down
55 changes: 38 additions & 17 deletions web/app/images/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,25 +104,38 @@ export default function ImagesPage() {

return (
<div className="min-h-screen bg-background">
<div className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Image Search</h1>
{/* Header */}
<div className="border-b px-4 py-3 flex items-center justify-between bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary text-primary-foreground font-bold text-sm">
H
</div>
<h1 className="text-lg font-semibold tracking-tight">Hitode</h1>
</div>
<a href="/image-chat">
<Button variant="outline" size="sm">
Chat
</Button>
</a>
</div>

<div className="mx-auto max-w-4xl px-4 py-8">
{/* Upload Section */}
<Card className="mb-8">
<CardContent className="pt-6">
<h2 className="text-lg font-semibold mb-4">Upload Images</h2>
<h2 className="text-lg font-semibold mb-4">画像アップロード</h2>
<div className="flex gap-3 items-center">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
multiple
onChange={(e) => handleUpload(e.target.files)}
className="flex-1 text-sm file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90"
/>
{isUploading && (
<span className="text-sm text-muted-foreground animate-pulse">
Uploading...
アップロード中...
</span>
)}
</div>
Expand Down Expand Up @@ -160,7 +173,7 @@ export default function ImagesPage() {
{/* Search Section */}
<Card className="mb-8">
<CardContent className="pt-6">
<h2 className="text-lg font-semibold mb-4">Search Images</h2>
<h2 className="text-lg font-semibold mb-4">画像検索</h2>
<form
onSubmit={(e) => {
e.preventDefault();
Expand All @@ -175,7 +188,7 @@ export default function ImagesPage() {
className="flex-1"
/>
<Button type="submit" disabled={isSearching || !query.trim()}>
{isSearching ? "Searching..." : "Search"}
{isSearching ? "検索中..." : "検索"}
</Button>
</form>
</CardContent>
Expand All @@ -185,24 +198,32 @@ export default function ImagesPage() {
{searched && (
<div>
<h2 className="text-lg font-semibold mb-4">
Results{results.length > 0 && ` (${results.length})`}
検索結果{results.length > 0 && `${results.length}件)`}
</h2>

{results.length === 0 ? (
<p className="text-muted-foreground">No images found.</p>
<p className="text-muted-foreground">画像が見つかりませんでした。</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{results.map((r) => (
<Card key={r.imageId} className="overflow-hidden">
<div className="aspect-video relative bg-muted">
<img
src={r.imageUrl}
alt={r.filename}
className="object-contain w-full h-full"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
{r.filename.toLowerCase().endsWith(".pdf") ? (
<iframe
src={r.imageUrl}
title={r.filename}
className="w-full h-full"
/>
) : (
<img
src={r.imageUrl}
alt={r.filename}
className="object-contain w-full h-full"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
</div>
<CardContent className="pt-4">
<div className="flex justify-between items-start mb-2">
Expand Down
7 changes: 3 additions & 4 deletions web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ import "./globals.css";
import { SessionProvider } from "next-auth/react";

export const metadata: Metadata = {
metadataBase: new URL("https://chat.vercel.ai"),
title: "Next.js Chatbot Template",
description: "Next.js chatbot template using the AI SDK.",
title: "Hitode - 画像チャット",
description: "画像をアップロードして、AIと画像について会話できるアプリ",
};

export const viewport = {
Expand Down Expand Up @@ -60,7 +59,7 @@ export default function RootLayout({
// visual flicker before hydration. Hence the `suppressHydrationWarning`
// prop is necessary to avoid the React hydration mismatch warning.
// https://github.com/pacocoursey/next-themes?tab=readme-ov-file#with-app
lang="en"
lang="ja"
suppressHydrationWarning
>
<head>
Expand Down
2 changes: 1 addition & 1 deletion web/artifacts/code/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export const codeArtifact = new Artifact<"code", Metadata>({
...metadata.outputs.filter((output) => output.id !== runId),
{
id: runId,
contents: [{ type: "text", value: error.message }],
contents: [{ type: "text", value: error instanceof Error ? error.message : String(error) }],
status: "failed",
},
],
Expand Down
10 changes: 9 additions & 1 deletion web/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware() {
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname === "/") {
return NextResponse.redirect(new URL("/image-chat", request.url));
}
return NextResponse.next();
}

export const config = {
matcher: ["/"],
};
3 changes: 3 additions & 0 deletions web/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
typescript: {
ignoreBuildErrors: true,
},
turbopack: {
root: process.cwd(),
},
Expand Down
4 changes: 2 additions & 2 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev --turbo",
"build": "tsx lib/db/migrate && next build",
"build": "next build",
"start": "next start",
"lint": "biome check",
"format": "biome format --write",
Expand Down Expand Up @@ -59,7 +59,7 @@
"katex": "^0.16.25",
"lucide-react": "^0.554.0",
"nanoid": "^5.1.6",
"next": "16.0.3",
"next": "16.1.6",
"next-auth": "5.0.0-beta.25",
"next-themes": "^0.4.6",
"orderedmap": "^2.1.1",
Expand Down
Loading
Loading