diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 776c221..b6d97e2 100755 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ diff --git a/frontend/package.json b/frontend/package.json index 5c9ddc1..3a2ffc4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -49,7 +49,7 @@ "dayjs": "^1.11.7", "dotenv": "^16.0.3", "eslint": "8.42.0", - "eslint-config-next": "13.4.4", + "eslint-config-next": "^13.5.4", "face-api.js": "^0.22.2", "framer-motion": "^10.12.16", "graphql": "^16.6.0", @@ -61,7 +61,7 @@ "kuromoji": "^0.1.2", "kuromojin": "^3.0.0", "langchain": "^0.0.95", - "next": "13.4.9", + "next": "^13.5.4", "next-auth": "0.0.0-manual.e65faa1c", "next-contentlayer": "^0.3.2", "node-fetch": "2.6.7", @@ -69,13 +69,14 @@ "openai": "^4.5.0", "playwright-aws-lambda": "^0.10.0", "playwright-core": "^1.38.0", - "react": "18.2.0", + "react": "^18.2.0", "react-d3-cloud": "^1.0.6", - "react-dom": "18.2.0", + "react-dom": "^18.2.0", "react-hook-form": "^7.44.3", "react-icons": "^4.8.0", "react-markdown": "^8.0.7", "react-textarea-autosize": "^8.4.1", + "recharts": "^2.8.0", "reflect-metadata": "^0.1.13", "rehype-raw": "^6.1.1", "rehype-sanitize": "^5.0.1", @@ -88,7 +89,6 @@ "typegraphql-prisma": "^0.27.0", "typescript": "5.2.0-dev.20230606", "video.js": "^8.5.2", - "vidstack": "^0.6.13", "zact": "^0.0.2", "zod": "^3.21.4" }, diff --git a/frontend/src/app/_components/Chatbot.tsx b/frontend/src/app/_components/Chatbot.tsx index a6ae388..7d44e50 100644 --- a/frontend/src/app/_components/Chatbot.tsx +++ b/frontend/src/app/_components/Chatbot.tsx @@ -1,24 +1,26 @@ "use client"; import { placeholderAtom } from "@src/store/placeholder"; -import { - ArrowDownIcon, - ArrowUpIcon, - MeOutlinedIcon, -} from "@xpadev-net/designsystem-icons"; +import { Country } from "@src/types/country"; +import { ArrowDownIcon, ArrowUpIcon } from "@xpadev-net/designsystem-icons"; import { useChat } from "ai/react"; -import cn from "classnames"; import { AnimatePresence, motion } from "framer-motion"; import { useAtom } from "jotai"; import { type Session } from "next-auth"; import { useState } from "react"; import { FaMagic } from "react-icons/fa"; import { IoMdSend } from "react-icons/io"; -import { SiOpenai } from "react-icons/si"; -import ReactMarkdown from "react-markdown"; import { toast } from "sonner"; -export default function Chatbot({ user }: { user: Session["user"] }) { +import MessageItem from "../chat/Message"; + +export default function Chatbot({ + countries, + user, +}: { + countries: Country[]; + user: Session["user"]; +}) { const [isOpen, setIsOpen] = useState(false); const variants = { @@ -33,6 +35,7 @@ export default function Chatbot({ user }: { user: Session["user"] }) { }; const { + data, handleInputChange, handleSubmit, input, @@ -87,38 +90,22 @@ export default function Chatbot({ user }: { user: Session["user"] }) { >
{messages.length ? ( - messages.map((m, i) => ( -
- {user && m.role === "user" ? ( - {user.name { + const correspondingData = data + ? data.find((d: any) => d.index === i) + : null; + + return ( +
+ - ) : ( -
- {m.role === "user" ? ( - - ) : ( - - )} -
- )} - - {m.content} - -
- )) +
+ ); + }) ) : (

diff --git a/frontend/src/app/api/chat/functions.ts b/frontend/src/app/api/chat/functions.ts index c1a5d88..6d3e4b8 100644 --- a/frontend/src/app/api/chat/functions.ts +++ b/frontend/src/app/api/chat/functions.ts @@ -1,6 +1,15 @@ import { conn } from "@src/lib/planetscale"; import { ChatCompletionCreateParams } from "openai/resources/chat"; +export type TransformedData = { + country_id: string; + country_value: string; + date: string; + indicator_id: string; + indicator_value: string; + value: number | null; +}; + export const functions: ChatCompletionCreateParams.Function[] = [ { name: "get_member_info", @@ -89,10 +98,10 @@ async function get_population(countryCode: string) { const data = result[1]; if (!data) { - return "Sorry, we could not retrieve population data because there was no data available for the country code."; + return "申し訳ありませんが、国番号に対応するデータがないため、人口データを取得できませんでした。"; } - const transformedData = data.map((datum: any) => { + const transformedData: TransformedData[] = data.map((datum: any) => { return { country_id: datum.country.id, country_value: datum.country.value, @@ -103,21 +112,21 @@ async function get_population(countryCode: string) { }; }); + transformedData.sort((a, b) => parseInt(a.date) - parseInt(b.date)); + return transformedData; } catch (e: any) { - return `Sorry, we could not retrieve population data due to an error: ${e.message}`; + return `申し訳ありませんが、エラーにより人口データを取得できませんでした`; } } async function get_member_info(name: string) { - if (!conn) return null; - const query = "SELECT * FROM Member WHERE name = ? LIMIT 1"; const params = [name]; const data = await conn.execute(query, params); if (data.rows.length === 0) { - return "Sorry, the member information could not be found."; + return "申し訳ありませんが、議員情報が見つかりませんでした。"; } return data.rows[0]; @@ -152,8 +161,6 @@ async function meeting_list(args: any) { }; }); - console.log(newArray); - return newArray; } diff --git a/frontend/src/app/api/chat/route.ts b/frontend/src/app/api/chat/route.ts index 28373f1..8bd4ad3 100644 --- a/frontend/src/app/api/chat/route.ts +++ b/frontend/src/app/api/chat/route.ts @@ -1,6 +1,11 @@ import { Ratelimit } from "@upstash/ratelimit"; import { Redis } from "@upstash/redis"; -import { OpenAIStream, StreamingTextResponse } from "ai"; +import { + createStreamDataTransformer, + experimental_StreamData, + OpenAIStream, + StreamingTextResponse, +} from "ai"; import { OpenAI } from "openai"; import { functions, runFunction } from "./functions"; @@ -47,41 +52,94 @@ export async function POST(req: Request) { const { messages } = await req.json(); const key = JSON.stringify(messages); - const cached = await await redis.get(key); + const cached = (await redis.get(key)) as any; - if (cached) { - return new Response(cached as any); - } + const data = new experimental_StreamData(); + + if (cached && cached.completion) { + console.log(cached); + + const chunks: string[] = []; + for (let i = 0; i < cached.completion.length; i += 5) { + chunks.push(cached.completion.substring(i, i + 5)); + } + + const stream = new ReadableStream({ + async start(controller) { + for (const chunk of chunks) { + const bytes = new TextEncoder().encode(chunk); + controller.enqueue(bytes); + await new Promise((r) => + setTimeout(r, Math.floor(Math.random() * 40) + 10) + ); + } + controller.close(); + }, + }); + + if (cached.data) { + cached.data.forEach((item: any) => { + data.append(item); + }); + data.close(); + + const transformedStream = stream.pipeThrough( + createStreamDataTransformer(true) + ); + return new StreamingTextResponse(transformedStream, {}, data); + } else { + return new StreamingTextResponse(stream); + } + } const initialResponse = await openai.chat.completions.create({ function_call: "auto", functions, messages, - model: "gpt-3.5-turbo-0613", + model: "gpt-3.5-turbo", stream: true, temperature: 0, }); + const allDataAppends: any[] = []; + const stream = OpenAIStream(initialResponse, { experimental_onFunctionCall: async ( { name, arguments: args }, createFunctionCallMessages ) => { const functionResponse = await runFunction(name, args); - const newMessages = createFunctionCallMessages(functionResponse); + + const appendData = { + body: functionResponse, + index: messages.length - 1 + 1, + type: typeof functionResponse === "string" ? "error" : name, + }; + + data.append(appendData); + allDataAppends.push(appendData); + return openai.chat.completions.create({ + functions, messages: [...messages, ...newMessages], model: "gpt-3.5-turbo-0613", stream: true, + temperature: 0, }); }, - async onCompletion(completion) { - // Cache the response - await redis.set(key, completion); + experimental_streamData: true, + async onFinal(completion) { + data.close(); + + const cacheValue = { + completion: completion, + data: allDataAppends, + }; + await redis.set(key, JSON.stringify(cacheValue)); await redis.expire(key, 60 * 60); }, }); - return new StreamingTextResponse(stream); + return new StreamingTextResponse(stream, {}, data); } diff --git a/frontend/src/app/chat/Chat.tsx b/frontend/src/app/chat/Chat.tsx index c18cbc0..291c9b2 100644 --- a/frontend/src/app/chat/Chat.tsx +++ b/frontend/src/app/chat/Chat.tsx @@ -2,17 +2,16 @@ import { Dialog } from "@headlessui/react"; import Modal from "@src/app/_components/Modal"; -import { MeOutlinedIcon } from "@xpadev-net/designsystem-icons"; +import { Country } from "@src/types/country"; import { useChat } from "ai/react"; -import cn from "classnames"; import { type Session } from "next-auth"; import { useEffect, useState } from "react"; import { AiOutlineArrowRight } from "react-icons/ai"; import { FaMagic } from "react-icons/fa"; -import { SiOpenai } from "react-icons/si"; -import ReactMarkdown from "react-markdown"; import { toast } from "sonner"; +import MessageItem from "./Message"; + export function EmptyScreen({ president, setInput, @@ -27,7 +26,7 @@ export function EmptyScreen({ }, { heading: `日本の人口推移の調査`, - message: `日本の人口の推移を教えてください。またその理由を考えてください`, + message: `日本の人口の推移を教えてください。またその要因を考えてください`, }, { heading: `議会での議論を調べる`, @@ -60,13 +59,15 @@ export function EmptyScreen({ } export default function Chat({ + countries, president, user, }: { + countries: Country[]; president: string; user: Session["user"]; }) { - const { handleInputChange, handleSubmit, input, messages, setInput } = + const { data, handleInputChange, handleSubmit, input, messages, setInput } = useChat({ api: "/api/chat", onResponse: (response) => { @@ -92,41 +93,25 @@ export default function Chat({ } return ( -

-
+
+
{messages.length ? ( - messages.map((m, i) => ( -
- {user && m.role === "user" ? ( - {user.name { + const correspondingData = data + ? data.find((d: any) => d.index === i) + : null; + + return ( +
+ - ) : ( -
- {m.role === "user" ? ( - - ) : ( - - )} -
- )} - - {m.content} - -
- )) +
+ ); + }) ) : ( )} diff --git a/frontend/src/app/chat/Message.tsx b/frontend/src/app/chat/Message.tsx new file mode 100644 index 0000000..bc628c3 --- /dev/null +++ b/frontend/src/app/chat/Message.tsx @@ -0,0 +1,68 @@ +import Speaker from "@src/app/meetings/[id]/Speaker"; +import { Country } from "@src/types/country"; +import { MeOutlinedIcon } from "@xpadev-net/designsystem-icons"; +import type { Message } from "ai"; +import cn from "classnames"; +import type { Session } from "next-auth"; +import { SiOpenai } from "react-icons/si"; +import ReactMarkdown from "react-markdown"; + +import Population from "./population"; + +export default function MessageItem({ + countries, + data, + message, + user, +}: { + countries: Country[]; + data: any; + message: Message; + user: Session["user"]; +}) { + return ( + <> +
+ {user && message.role === "user" ? ( + {user.name + ) : ( +
+ {message.role === "user" ? ( + + ) : ( + + )} +
+ )} + + {message.content} + +
+
+ {data?.type === "get_member_info" ? ( + + ) : data?.type === "get_population" ? ( + + ) : ( + data?.type === "error" && ( + ⚠ {data.body} + ) + )} +
+ + ); +} diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index 07acc3b..a42608c 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -1,10 +1,11 @@ import { auth } from "@auth"; +import { Country } from "@src/types/country"; import type { Metadata } from "next"; import Chat from "./Chat"; export const metadata: Metadata = { - title: "チャット", + title: "チャットAI", }; export default async function Page() { @@ -23,7 +24,7 @@ export default async function Page() { const url = "https://query.wikidata.org/sparql"; - const fetchPromise = fetch( + const presidentPromise = fetch( url + "?query=" + encodeURIComponent(sparqlQuery), { headers: { @@ -35,15 +36,24 @@ export default async function Page() { } ); - const sessionPromise = auth(); - const [response, session] = await Promise.all([fetchPromise, sessionPromise]); + const countriesPromise = fetch( + "https://cdn.jsdelivr.net/npm/country-flag-emoji-json@2.0.0/dist/index.json" + ); - const data = await response.json(); + const sessionPromise = auth(); + const [response, session, countries_flag] = await Promise.all([ + presidentPromise, + sessionPromise, + countriesPromise, + ]); + const president_data = await response.json(); + const countries: Country[] = await countries_flag.json(); return ( ); } diff --git a/frontend/src/app/chat/population.tsx b/frontend/src/app/chat/population.tsx new file mode 100644 index 0000000..e960ce6 --- /dev/null +++ b/frontend/src/app/chat/population.tsx @@ -0,0 +1,80 @@ +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +type Country = { + name: string; + code: string; + emoji: string; + image: string; + unicode: string; +}; + +function formatJapaneseNumber(num: number): string { + if (num >= 100000000) return (num / 100000000).toFixed(1) + "億"; + if (num >= 10000) return (num / 10000).toFixed(1) + "万"; + return num.toString(); +} + +function CustomTooltip({ active, label, payload }: any) { + if (active && payload && payload.length) { + return ( +
+

{label}

+

人口: {formatJapaneseNumber(payload[0].value)}

+
+ ); + } + + return null; +} + +export default function Population({ + countries, + transformedData, +}: { + countries: Country[]; + transformedData: any; +}) { + function findCountryByCode(code: string) { + return countries.find((country) => country.code === code); + } + + return ( +
+

+ {findCountryByCode(transformedData[0].country_id)?.emoji}{" "} + {findCountryByCode(transformedData[0].country_id)?.name}の人口推移 +

+ + + + + + } /> + + + + + 提供:{" "} + + 世界銀行 + + +
+ ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 4962ff4..913ea00 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -50,7 +50,18 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { - const session = await auth(); + const countryPromise = fetch( + "https://cdn.jsdelivr.net/npm/country-flag-emoji-json@2.0.0/dist/index.json" + ); + + const sessionPromise = auth(); + + const [session, country_flag] = await Promise.all([ + sessionPromise, + countryPromise, + ]); + + const countries = await country_flag.json(); return ( @@ -77,7 +88,7 @@ export default async function RootLayout({ {children}
- + diff --git a/frontend/src/app/meetings/[id]/page.tsx b/frontend/src/app/meetings/[id]/page.tsx index c1eb45d..1c85f11 100644 --- a/frontend/src/app/meetings/[id]/page.tsx +++ b/frontend/src/app/meetings/[id]/page.tsx @@ -36,27 +36,21 @@ export async function generateMetadata({ title: (meeting.house === "COUNCILLORS" ? "参議院 " : "衆議院 ") + meeting.meeting_name, - images: `https://capitalens-og.onrender.com/video_frame/?time=${ - searchParams.t - ? searchParams.t - : meeting.utterances[0] - ? meeting.utterances[0].start - : "1" - }&url=${meeting.m3u8_url}`, locale: "ja-JP", siteName: config.siteMeta.title, url: `${config.siteRoot}meetings/${meeting.id}`, }, twitter: { title: + "Capitalensで" + (meeting.house === "COUNCILLORS" ? "参議院 " : "衆議院 ") + - meeting.meeting_name, + meeting.meeting_name + + "をチェックしよう", description: meeting.summary ?? `${dayjs(meeting.date).format("YYYY年MM月DD日")}の${ meeting.house === "COUNCILLORS" ? "参議院 " : "衆議院 " } ${meeting.meeting_name}の情報をチェックする`, - images: `https://capitalens-og.onrender.com/video_frame/?time=${meeting.utterances[0].start}&url=${meeting.m3u8_url}`, }, }; } diff --git a/frontend/src/app/members/[id]/page.tsx b/frontend/src/app/members/[id]/page.tsx index 27d43b5..a93b665 100644 --- a/frontend/src/app/members/[id]/page.tsx +++ b/frontend/src/app/members/[id]/page.tsx @@ -14,8 +14,6 @@ import { notFound } from "next/navigation"; import { AiOutlineLink } from "react-icons/ai"; import { FaFacebook, FaTwitter, FaWikipediaW, FaYoutube } from "react-icons/fa"; -import Chat from "./Chat"; - dayjs.locale("ja"); dayjs.extend(relativeTime); @@ -102,7 +100,7 @@ export async function generateMetadata({ if (!member) notFound(); const ogImage = member.image ?? `${config.siteRoot}opengraph.jpg`; - const title = member.name; + const title = member.name + "議員のプロフィール"; const description = member.abstract ?? member.description ?? @@ -243,7 +241,6 @@ export default async function Page({ params }: { params: { id: string } }) {

詳細情報

- {member.group && (
{member.group.image ? ( diff --git a/frontend/src/types/country.ts b/frontend/src/types/country.ts new file mode 100644 index 0000000..b7d9dbe --- /dev/null +++ b/frontend/src/types/country.ts @@ -0,0 +1,7 @@ +export type Country = { + name: string; + code: string; + emoji: string; + image: string; + unicode: string; +};