Skip to content

Commit

Permalink
翻訳状況とか (#32)
Browse files Browse the repository at this point in the history
  • Loading branch information
ttizze authored Jul 16, 2024
1 parent 60a2fe8 commit b6be924
Show file tree
Hide file tree
Showing 22 changed files with 517 additions and 112 deletions.
12 changes: 12 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ services:
volumes:
- db:/var/lib/postgresql/data

redis:
image: redis:6.2-alpine
restart: always
ports:
- '6379:6379'
command: redis-server --save 20 1 --loglevel warning --requirepass eYVX7EwVmmxKPCDmwMtyKVge8oLd2t81
volumes:
- redis:/data


volumes:
db:
driver: local
redis:
driver: local
7 changes: 6 additions & 1 deletion render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,9 @@ services:
- key: DATABASE_URL
fromDatabase:
name: remixdb
property: connectionString
property: connectionString

services:
- type: redis
name: eveeve-redis
ipAllowList: []
1 change: 0 additions & 1 deletion web/app/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export function Header({ safeUser, targetLanguage }: HeaderProps) {
<ModeToggle />
{safeUser ? (
<>
<span className="text-gray-700">Hello, {safeUser.name}!!</span>
<Link
to="/auth/logout"
className="text-gray-600 hover:text-gray-800"
Expand Down
36 changes: 36 additions & 0 deletions web/app/components/ui/badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "~/utils/cn"

const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)

export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}

function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}

export { Badge, badgeVariants }
26 changes: 26 additions & 0 deletions web/app/components/ui/progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"

import { cn } from "~/utils/cn"

const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName

export { Progress }
46 changes: 46 additions & 0 deletions web/app/components/ui/scroll-area.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"

import { cn } from "~/utils/cn"

const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName

const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName

export { ScrollArea, ScrollBar }
4 changes: 2 additions & 2 deletions web/app/routes/_index/components/URLTranslationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ export function URLTranslationForm() {
/>
<div id={fields.url.errorId}>{fields.url.errors}</div>
</div>
<Button type="submit" disabled={navigation.state === "submitting"}>
<Button type="submit" name="intent" value="translateUrl" disabled={navigation.state === "submitting"}>
{navigation.state === "submitting" ? (
<LoadingSpinner />
) : (
<Languages className="w-4 h-4 " color="black" />
<Languages className="w-4 h-4 " />
)}
</Button>
</div>
Expand Down
77 changes: 77 additions & 0 deletions web/app/routes/_index/components/UserReadHistoryList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { ScrollArea } from "~/components/ui/scroll-area";
import type { UserReadHistoryItem } from "../types";
import { Link } from "@remix-run/react";
import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress";

type UserReadHistoryListProps = {
userReadHistory: UserReadHistoryItem[];
targetLanguage: string;
};

export function UserReadHistoryList({ userReadHistory, targetLanguage }: UserReadHistoryListProps) {
return (
<Card>
<CardHeader>
<CardTitle>Recently Read</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[300px]">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{userReadHistory.map((item) => {
const translationInfo = item.pageVersion.pageVersionTranslationInfo[0];
return (
<Link
key={item.id}
to={`/reader/${encodeURIComponent(item.pageVersion.page.url)}`}
className="no-underline text-inherit"
>
<Card className="flex flex-col hover:shadow-md transition-shadow duration-200">
<CardHeader>
<CardTitle className="text-sm truncate">{item.pageVersion.title}</CardTitle>
</CardHeader>
<CardContent className="flex-grow">
<p className="text-xs text-muted-foreground truncate">
{item.pageVersion.page.url}
</p>
<p className="text-xs mt-2">
{new Date(item.readAt).toLocaleDateString()}
</p>
{translationInfo && (
<>
<Badge className="mt-2" variant={getVariantForStatus(translationInfo.translationStatus)}>
{translationInfo.translationStatus}
</Badge>
<Progress value={translationInfo.translationProgress} className="mt-2" />
</>
)}
{!translationInfo && (
<Badge className="mt-2" variant="outline">
Not started
</Badge>
)}
</CardContent>
</Card>
</Link>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
);
};

function getVariantForStatus(status: string): "default" | "secondary" | "destructive" | "outline" {
switch (status) {
case "completed":
return "default";
case "processing":
return "secondary";
case "failed":
return "destructive";
default:
return "outline";
}
}
45 changes: 41 additions & 4 deletions web/app/routes/_index/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import { fetchWithRetry } from "./utils/fetchWithRetry";
import { validateGeminiApiKey } from "./utils/gemini";
import { translate } from "./utils/translation";
import { geminiApiKeySchema } from "./types";
import type { UserReadHistoryItem } from "./types";

import { UserReadHistoryList } from "./components/UserReadHistoryList";

export const meta: MetaFunction = () => {
return [
Expand All @@ -36,21 +39,41 @@ export const meta: MetaFunction = () => {
];
};


export async function loader({ request }: LoaderFunctionArgs) {
const safeUser = await authenticator.isAuthenticated(request);
const session = await getSession(request.headers.get("Cookie"));
const targetLanguage = session.get("targetLanguage") || "ja";

let hasGeminiApiKey = false;
let userReadHistory: UserReadHistoryItem[] = [];
if (safeUser) {
const dbUser = await prisma.user.findUnique({ where: { id: safeUser.id } });
hasGeminiApiKey = !!dbUser?.geminiApiKey;
}

return typedjson({ safeUser, targetLanguage, hasGeminiApiKey });

userReadHistory = await prisma.userReadHistory.findMany({
where: { userId: safeUser.id },
include: {
pageVersion: {
include: {
page: true,
pageVersionTranslationInfo: {
where: { targetLanguage }
}
}
}
},
orderBy: { readAt: 'desc' },
take: 10
});
}

return typedjson({ safeUser, targetLanguage, hasGeminiApiKey, userReadHistory });
}

export async function action({ request }: ActionFunctionArgs) {

const formData = await request.clone().formData();
switch (formData.get("intent")) {
case "SignInWithGoogle":
Expand Down Expand Up @@ -93,6 +116,13 @@ export async function action({ request }: ActionFunctionArgs) {
if (submission.status !== "success") {
return submission.reply();
}
const dbUser = await prisma.user.findUnique({ where: { id: safeUser.id } });
const geminiApiKey = dbUser?.geminiApiKey;
if (!geminiApiKey) {
return submission.reply({
formErrors: ["Gemini API key is not set"],
});
}
const html = await fetchWithRetry(submission.value.url);
const { content, title } = extractArticle(html);
const numberedContent = addNumbersToContent(content);
Expand All @@ -101,6 +131,7 @@ export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
const targetLanguage = session.get("targetLanguage") || "ja";
await translate(
geminiApiKey,
safeUser.id,
targetLanguage,
title,
Expand All @@ -113,8 +144,9 @@ export async function action({ request }: ActionFunctionArgs) {
}
}
export default function Index() {
const { safeUser, targetLanguage, hasGeminiApiKey } =
useTypedLoaderData<typeof loader>();
const { safeUser, targetLanguage, hasGeminiApiKey, userReadHistory } =
useTypedLoaderData<typeof loader>();


return (
<div>
Expand All @@ -136,6 +168,11 @@ export default function Index() {
</div>
</div>
)}
{safeUser && hasGeminiApiKey && (
<div className="mt-8">
<UserReadHistoryList userReadHistory={userReadHistory} />
</div>
)}
</div>
</div>
</div>
Expand Down
20 changes: 19 additions & 1 deletion web/app/routes/_index/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";

export type TranslationStatus = "pending" | "in_progress" | "completed";
export type TranslationStatus = "pending" | "in_progress" | "completed" | "failed";

export interface TranslationStatusRecord {
id: number;
Expand All @@ -12,3 +12,21 @@ export interface TranslationStatusRecord {
export const geminiApiKeySchema = z.object({
geminiApiKey: z.string().min(1, "API key is required"),
});

export type UserReadHistoryItem = {
id: number;
readAt: Date;
pageVersion: {
id: number;
title: string;
page: {
url: string;
};
pageVersionTranslationInfo: Array<{
targetLanguage: string;
translationTitle: string;
translationStatus: string;
translationProgress: number;
}>;
};
};
3 changes: 2 additions & 1 deletion web/app/routes/_index/utils/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import {
import { generateSystemMessage } from "./generateGeminiMessage";

export async function getGeminiModelResponse(
geminiApiKey: string,
model: string,
title: string,
source_text: string,
target_language: string,
) {
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY ?? "");
const genAI = new GoogleGenerativeAI(geminiApiKey);
const safetySetting = [
{
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
Expand Down
Loading

0 comments on commit b6be924

Please sign in to comment.