Skip to content

Commit

Permalink
Merge pull request #29 from sora32127/add_ai_completion_textbox
Browse files Browse the repository at this point in the history
AI入力補助機能を追加
  • Loading branch information
sora32127 authored Apr 24, 2024
2 parents ca94d66 + 33012e1 commit 7d7cb16
Show file tree
Hide file tree
Showing 8 changed files with 449 additions and 20 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/playwright-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
run : npm install

- name : Install Playwright with dependencies
run : npx playwright install --with-deps chromium
run : sudo apt update && npx playwright install --with-deps chromium

- name : Run Playwright tests
run : npx playwright test
Expand Down
15 changes: 9 additions & 6 deletions app/components/SubmitFormComponents/StaticTextInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Form } from "@remix-run/react";
import TextInputBoxAI from "./TextInputBoxAI";

interface StaticTextInputProps {
row: number;
Expand All @@ -7,6 +8,7 @@ interface StaticTextInputProps {
placeholders?: string[];
onInputChange: (values: string[]) => void;
parentComponentStateValues: string[];
prompt: string;
}

function StaticTextInput({
Expand All @@ -16,6 +18,7 @@ function StaticTextInput({
placeholders = [],
onInputChange,
parentComponentStateValues,
prompt = "",
}: StaticTextInputProps) {
const handleInputChange = (index: number, value: string) => {
const newInputValues = [...parentComponentStateValues];
Expand All @@ -29,12 +32,12 @@ function StaticTextInput({
const placeholder = placeholders[i] || "";
inputs.push(
<div key={i} className="mb-4">
<textarea
<TextInputBoxAI
className={`w-full py-2 placeholder-slate-500 rounded-lg focus:outline-none ${title}-${i}`}
parentComponentStateValue={parentComponentStateValues[i] || ""}
onInputChange={(value) => handleInputChange(i, value)}
placeholder={placeholder}
value={parentComponentStateValues[i] || ""}
onChange={(e) => handleInputChange(i, e.target.value)}
className={`w-full px-3 py-2 placeholder-slate-500 border rounded-lg focus:outline-none ${title}-${i}`}
rows={4}
prompt={prompt}
/>
</div>
);
Expand All @@ -51,4 +54,4 @@ function StaticTextInput({
);
}

export default StaticTextInput;
export default StaticTextInput;
132 changes: 132 additions & 0 deletions app/components/SubmitFormComponents/TextInputBoxAI.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";

interface ComponentProps {
className?: string;
parentComponentStateValue: string;
onInputChange: (value: string) => void;
placeholder?: string;
prompt?: string;
}

export default function TextInputBoxAI({
className = "",
parentComponentStateValue,
onInputChange,
placeholder = "",
prompt="",
}: ComponentProps) {
const [suggestions, setSuggestions] = useState<string | null>(null);
const textarea = useRef<HTMLTextAreaElement>(null);
const timer = useRef<NodeJS.Timeout | null>(null);

const resetSuggestions = () => {
setSuggestions(null);
};

const handleInputValue = useCallback(
(e: FormEvent<HTMLTextAreaElement>) => {
if (timer.current) {
clearTimeout(timer.current);
}

if (e.currentTarget.value) {
const text = e.currentTarget.value;

timer.current = setTimeout(async () => {
try {
const formData = new FormData();
formData.append("text", text);
formData.append("context", createContextSentense());
formData.append("prompt", prompt);
const response = await fetch("/api/ai/getCompletion", {
method: "POST",
body: formData,
});
const suggestion = await response.json();
setSuggestions(suggestion);
} catch (e) {
console.error(e);
}
}, 1000);
} else {
setSuggestions("");
}
onInputChange(e.currentTarget.value);
},
[onInputChange]
);

const commitSuggestions = () => {
const textareaElement = textarea.current;
if (textareaElement && suggestions) {
const newValue = textareaElement.value + suggestions;
textareaElement.value = newValue;
textareaElement.focus();
resetSuggestions();
onInputChange(newValue);
}
};

const handleSuggestions = (e: KeyboardEvent) => {
if (e.key === "Shift") {
e.preventDefault();
commitSuggestions();
}
};

useEffect(() => {
if (suggestions) {
addEventListener("keydown", handleSuggestions);
addEventListener("click", resetSuggestions);
}
return () => {
removeEventListener("keydown", handleSuggestions);
removeEventListener("click", resetSuggestions);
};
}, [suggestions]);

const getContextFromLocalStorage = () => {
const situationValue = window.localStorage.getItem("situationValue");
const reflectionValue = window.localStorage.getItem("reflectionValue");
const counterReflectionValue = window.localStorage.getItem("counterReflectionValue");
const noteValue = window.localStorage.getItem("noteValue");
return {
situationValue,
reflectionValue,
counterReflectionValue,
noteValue,
};
}

const createContextSentense = () => {
const contextValues = getContextFromLocalStorage();
const contextSetense = `以下のテキストはコンテキストを表すものです。文章を補完する際の参考にしてください。状況: ${contextValues.situationValue}。反省: ${contextValues.reflectionValue}。反省に対する反省: ${contextValues.counterReflectionValue}。メモ: ${contextValues.noteValue}。`;
return contextSetense;
}

return (
<div className={className}>
<textarea
ref={textarea}
value={parentComponentStateValue}
onChange={handleInputValue}
placeholder={placeholder}
className="w-full border-2 border-base-content rounded-lg p-2 placeholder-slate-500"
/>
{suggestions && (
<>
<p className="text-base-content mt-2">[補完候補]: {suggestions}</p>
<p className="text-info mt-2">
Shiftキーまたはボタンを押して補完できます。
</p>
<button
onClick={commitSuggestions}
className="bg-primary text-white font-bold py-2 px-4 rounded mt-2"
>
補完する
</button>
</>
)}
</div>
);
}
6 changes: 6 additions & 0 deletions app/routes/_layout.post.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export default function Component() {
description='上で記述した状況がどのような点でアウトだったのかの説明です。 できる範囲で構わないので、なるべく理由は深堀りしてください。 「マナーだから」は理由としては認められません。 健常者エミュレータはマナー講師ではありません。一つずつ追加してください。3つ記入する必要はありません。'
onInputChange={handleReflectionChange}
parentComponentStateValues={reflectionValues}
prompt="reflection"
/>
<StaticTextInput
row={3}
Expand All @@ -203,6 +204,7 @@ export default function Component() {
description='5W1H状況説明、健常行動ブレイクポイントを踏まえ、どのようにするべきだったかを提示します。'
onInputChange={handleCounterFactualReflectionChange}
parentComponentStateValues={counterFactualReflectionValues}
prompt="counterReflection"
/>
</>
) : (
Expand All @@ -214,6 +216,7 @@ export default function Component() {
description='上で記述した行動がなぜやってよかったのか、理由を説明します。できる範囲で構わないので、なるべく理由は深堀りしてください。なんとなくただ「よかった」は理由としては認められません。一つずつ追加してください。3つ記入する必要はありません。'
onInputChange={handleReflectionChange}
parentComponentStateValues={reflectionValues}
prompt="reflection"
/>
<StaticTextInput
row={3}
Expand All @@ -222,6 +225,7 @@ export default function Component() {
description='仮に上で記述した行動を実行しなかった場合、どのような不利益が起こりうるか記述してください。推論の範囲内で構いません。'
onInputChange={handleCounterFactualReflectionChange}
parentComponentStateValues={counterFactualReflectionValues}
prompt="counterReflection"
/>
</>
)}
Expand All @@ -232,6 +236,7 @@ export default function Component() {
placeholders={selectedType === "misDeed" ? ["友人が詠んだ句は「ため池や 水がいっぱい きれいだね」だった"] : ["舌が過度に肥えてしまい、コンビニ弁当が食べられなくなった。"]}
onInputChange={handleNoteChange}
parentComponentStateValues={noteValues}
prompt="note"
/>
<Preview
selectedType={selectedType}
Expand All @@ -248,6 +253,7 @@ export default function Component() {
description='得られる教訓を要約してください'
onInputChange={handleTitleChange}
parentComponentStateValues={titleValues}
prompt="title"
/>
<TagSelectionBox
onTagsSelected={handleTagSelection}
Expand Down
62 changes: 62 additions & 0 deletions app/routes/api.ai.getCompletion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { ActionFunctionArgs } from '@remix-run/node';
import { OpenAI } from 'openai';


const OPENAI_API_KEY = process.env.OPENAI_API_KEY

export async function action ({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const text = formData.get("text") as string | null;
const context = formData.get("context") as string | "";
const prompt = formData.get("prompt") as string | "";
if (!text) {
return new Response("Bad Request", { status: 400 });
}
const result = await getCompletion(text, context, prompt);
return new Response(JSON.stringify(result), {
headers: {
"Content-Type": "application/json",
},
});
}

function createPrompt(prompt: string) {
if (prompt == "reflection") {
return "自分の行動に何が問題があったのか気にしています。あなたは、何が問題だったのかを考える補佐をしてください。"
} else if(prompt== "counterReflection") {
return "もし自分がその行動をしなかったらどうなっていたのかや、本当はどうするべきだったのか反実仮想をしようとしています。あなたは反実仮想の補佐をしてください。"
} else if (prompt == "note") {
return "書ききれなかった何かを補足したいと思っています。あなたは、ユーザーの補足を補佐してください。"
} else if (prompt == "title") {
return "タイトルを考えるのに困っています。あなたは、タイトルを考える補佐をしてください。"
} else {
return ""
}
}

export async function getCompletion(text:string, context:string, prompt:string) {
const openAI = new OpenAI({
apiKey: OPENAI_API_KEY,
})

const promptSentence = createPrompt(prompt)
const result = await openAI.chat.completions.create({
messages: [
{
role: "system",
content: `あなたは優秀な日本語の作文アシスタントです。ユーザーが入力した文章の続きを、自然で文法的に正しい日本語で簡潔に生成してください。与えられた文脈を考慮し、ユーザーの意図を汲み取って適切な文章を生成することを心がけてください。${promptSentence}`
},
{
role: "assistant",
content: `承知しました。ユーザーの入力文に続く自然な日本語の文章を簡潔に生成いたします。以下の文脈情報を参考にします。\n文脈情報: ${context}`,
},
{
role: "user",
content: `次の文章の続きを、文脈を考慮して自然な日本語で簡潔に生成してください。40文字程度でお願いします。\n「${text}」`,
}
],
model: 'gpt-3.5-turbo'
});
const completion = result.choices[0].message.content
return completion
}
Loading

1 comment on commit 7d7cb16

@vercel
Copy link

@vercel vercel bot commented on 7d7cb16 Apr 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.