Created
September 17, 2025 14:33
-
-
Save ippu-i/cbff9bc767ba9b6f43380159af846dc4 to your computer and use it in GitHub Desktop.
React component App.tsx for a Power Apps Code App that enables conversational interactions with Azure OpenAI.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { | |
| FluentProvider, | |
| webLightTheme, | |
| makeStyles, | |
| shorthands, | |
| Button, | |
| Input, | |
| Label, | |
| Card, | |
| Text, | |
| Spinner, | |
| tokens, | |
| Divider, | |
| } from '@fluentui/react-components'; | |
| import { | |
| ChatMultipleRegular, | |
| SettingsRegular, | |
| SendRegular, | |
| DismissRegular, | |
| ArrowSyncRegular, | |
| } from '@fluentui/react-icons'; | |
| type Message = { role: 'user' | 'assistant'; content: string }; | |
| const useStyles = makeStyles({ | |
| root: { | |
| minHeight: '100vh', | |
| background: 'linear-gradient(135deg, #e3eafc 0%, #f6f8fc 100%)', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| ...shorthands.padding('30px'), | |
| }, | |
| twoColumn: { | |
| display: 'flex', | |
| flexDirection: 'row', | |
| gap: '36px', | |
| width: '950px', | |
| maxWidth: '98vw', | |
| alignItems: 'flex-start', | |
| }, | |
| chatCard: { | |
| flex: 2, | |
| minWidth: 0, | |
| ...shorthands.padding('0px 0px 24px 0px'), | |
| borderRadius: '24px', | |
| boxShadow: '0 6px 24px #2848a222', | |
| background: 'rgba(255,255,255,0.95)', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| minHeight: '540px', | |
| height: '70vh', | |
| maxHeight: '92vh', | |
| }, | |
| chatHeader: { | |
| display: 'flex', | |
| alignItems: 'center', | |
| ...shorthands.padding('24px', '32px', '14px', '32px'), | |
| gap: '12px', | |
| borderBottom: `1.5px solid ${tokens.colorNeutralStroke2}`, | |
| }, | |
| chatArea: { | |
| flex: 1, | |
| overflowY: 'auto', | |
| background: 'transparent', | |
| ...shorthands.padding('18px', '32px'), | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: '0px', | |
| }, | |
| chatBubbleRow: { | |
| display: 'flex', | |
| marginBottom: '16px', | |
| alignItems: 'flex-start', | |
| }, | |
| bubbleUser: { | |
| marginLeft: 'auto', | |
| background: 'linear-gradient(120deg, #388cf9 80%, #9ecbff 100%)', | |
| color: '#fff', | |
| ...shorthands.padding('13px', '18px'), | |
| borderRadius: '18px 18px 6px 18px', | |
| fontSize: '16px', | |
| boxShadow: '0 2px 8px #1e324c25', | |
| maxWidth: '85%', | |
| wordBreak: 'break-word', | |
| position: 'relative', | |
| fontWeight: 500, | |
| fontFamily: 'inherit', | |
| }, | |
| bubbleAI: { | |
| marginRight: 'auto', | |
| background: 'linear-gradient(110deg, #f6f8fc 70%, #e3eafc 100%)', | |
| color: '#2848a6', | |
| ...shorthands.padding('13px', '18px'), | |
| borderRadius: '18px 18px 18px 6px', | |
| fontSize: '16px', | |
| maxWidth: '85%', | |
| wordBreak: 'break-word', | |
| border: `1px solid #c4dbed33`, | |
| fontFamily: 'inherit', | |
| }, | |
| speakerLabelUser: { | |
| color: tokens.colorBrandForeground2, | |
| fontWeight: 700, | |
| fontSize: '13px', | |
| textAlign: 'right', | |
| margin: '2px 17px 0px 0', | |
| }, | |
| speakerLabelAI: { | |
| color: tokens.colorNeutralForeground3, | |
| fontWeight: 700, | |
| fontSize: '13px', | |
| textAlign: 'left', | |
| margin: '2px 0 0 17px', | |
| }, | |
| inputRow: { | |
| display: 'flex', | |
| gap: '12px', | |
| alignItems: 'center', | |
| background: 'rgba(247,250,255,0.9)', | |
| ...shorthands.padding('19px', '28px', '13px', '28px'), | |
| borderBottomLeftRadius: '24px', | |
| borderBottomRightRadius: '24px', | |
| borderTop: `1.5px solid ${tokens.colorNeutralStroke2}`, | |
| marginTop: 0, | |
| }, | |
| sendBtn: { | |
| minWidth: '44px', | |
| minHeight: '44px', | |
| borderRadius: '50%', | |
| }, | |
| stopBtn: { | |
| minWidth: '44px', | |
| minHeight: '44px', | |
| borderRadius: '50%', | |
| }, | |
| resetBtn: { | |
| minWidth: '44px', | |
| minHeight: '44px', | |
| borderRadius: '50%', | |
| }, | |
| paramCard: { | |
| flex: 1.2, | |
| minWidth: '300px', | |
| ...shorthands.padding('26px', '26px'), | |
| borderRadius: '22px', | |
| boxShadow: '0 6px 24px #2848a210', | |
| background: 'rgba(251,252,255,0.98)', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: '12px', | |
| position: 'sticky', | |
| top: '42px', | |
| alignSelf: 'flex-start', | |
| maxWidth: '340px', | |
| minHeight: '370px', | |
| border: `1.5px solid #e2eafb` | |
| }, | |
| configIcon: { | |
| marginRight: '8px', | |
| color: '#2848a8', | |
| fontSize: '17px', | |
| verticalAlign: 'middle', | |
| }, | |
| configForm: { | |
| display: 'grid', | |
| gap: '12px 6px', | |
| gridTemplateColumns: '1fr', | |
| alignItems: 'baseline', | |
| }, | |
| sectionTitle: { | |
| color: tokens.colorBrandForeground2, | |
| fontWeight: 700, | |
| letterSpacing: '0.02em', | |
| marginBottom: '5px', | |
| fontSize: '17px', | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '7px', | |
| }, | |
| note: { | |
| color: tokens.colorNeutralForeground4, | |
| fontSize: '13px', | |
| marginTop: '10px', | |
| fontWeight: 500, | |
| }, | |
| }); | |
| const defaultParams = { | |
| endpoint: '', | |
| deployment: '', | |
| apiKey: '', | |
| temperature: '0.7', | |
| maxHistoryTokens: '1000', | |
| }; | |
| export default function App() { | |
| const styles = useStyles(); | |
| const [params, setParams] = useState(defaultParams); | |
| const [messages, setMessages] = useState<Message[]>([]); | |
| const [input, setInput] = useState(''); | |
| const [pending, setPending] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const chatAreaRef = useRef<HTMLDivElement | null>(null); | |
| const controllerRef = useRef<AbortController | null>(null); | |
| const handleParamChange = ( | |
| e: React.ChangeEvent<HTMLInputElement> | |
| ) => { | |
| const { name, value } = e.target; | |
| setParams((p) => ({ | |
| ...p, | |
| [name]: value, | |
| })); | |
| }; | |
| const resetChat = () => { | |
| setMessages([]); | |
| setError(null); | |
| setInput(''); | |
| }; | |
| // どれか一つでも空なら送信不可 | |
| const isParamsIncomplete = | |
| !params.apiKey || | |
| !params.endpoint || | |
| !params.deployment || | |
| !params.temperature || | |
| !params.maxHistoryTokens; | |
| async function send() { | |
| if (!input || pending || isParamsIncomplete) return; | |
| setPending(true); | |
| setError(null); | |
| const userMsg: Message = { role: 'user', content: input }; | |
| setMessages((msgs) => [...msgs, userMsg]); | |
| setInput(''); | |
| const chatHistory = [...messages, userMsg].slice( | |
| -Math.floor(Number(params.maxHistoryTokens) / 50) | |
| ); | |
| const apiUrl = `${params.endpoint}/openai/deployments/${params.deployment}/chat/completions?api-version=2025-01-01-preview`; | |
| const headers = { | |
| 'api-key': params.apiKey, | |
| 'Content-Type': 'application/json', | |
| }; | |
| const body = { | |
| messages: chatHistory, | |
| temperature: Number(params.temperature), | |
| stream: true, | |
| max_tokens: 1024, | |
| }; | |
| controllerRef.current = new AbortController(); | |
| try { | |
| const res = await fetch(apiUrl, { | |
| method: 'POST', | |
| headers, | |
| body: JSON.stringify(body), | |
| signal: controllerRef.current.signal, | |
| }); | |
| if (!res.body) throw new Error('No body in response'); | |
| let full = ''; | |
| setMessages((msgs) => [...msgs, { role: 'assistant', content: '' }]); | |
| const reader = res.body.getReader(); | |
| while (true) { | |
| const { value, done } = await reader.read(); | |
| if (done) break; | |
| const text = new TextDecoder().decode(value); | |
| for (const line of text.split('\n')) { | |
| const m = /^data:\s*(.+)$/.exec(line); | |
| if (m) { | |
| if (m[1] === '[DONE]') break; | |
| try { | |
| const delta = JSON.parse(m[1]); | |
| if (delta.choices && delta.choices[0].delta?.content) { | |
| full += delta.choices[0].delta.content; | |
| setMessages((msgs) => { | |
| const last = msgs[msgs.length - 1]; | |
| if (last.role === 'assistant') { | |
| return [ | |
| ...msgs.slice(0, -1), | |
| { ...last, content: full }, | |
| ]; | |
| } | |
| return msgs; | |
| }); | |
| } | |
| } catch {} | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| setError((e as Error).message); | |
| } finally { | |
| setPending(false); | |
| } | |
| } | |
| function stop() { | |
| controllerRef.current?.abort(); | |
| setPending(false); | |
| } | |
| useEffect(() => { | |
| if (chatAreaRef.current) { | |
| chatAreaRef.current.scrollTop = chatAreaRef.current.scrollHeight + 200; | |
| } | |
| }, [messages, pending]); | |
| return ( | |
| <FluentProvider theme={webLightTheme}> | |
| <div className={styles.root}> | |
| <div className={styles.twoColumn}> | |
| {/* チャット左カラム */} | |
| <Card className={styles.chatCard}> | |
| <div className={styles.chatHeader}> | |
| <ChatMultipleRegular fontSize={23} color="#388cf9" /> | |
| <Text weight="bold" size={500}> | |
| Azure OpenAI専用 | |
| </Text> | |
| </div> | |
| <div className={styles.chatArea} ref={chatAreaRef}> | |
| {messages.length === 0 && ( | |
| <div style={{ color: '#99a', margin: '30px 0', fontSize: 16, textAlign: 'center' }}> | |
| 会話しよう! | |
| </div> | |
| )} | |
| {messages.map((m, i) => ( | |
| <div key={i} className={styles.chatBubbleRow}> | |
| {m.role === 'assistant' && ( | |
| <div style={{ alignSelf: 'flex-end' }}> | |
| <div className={styles.speakerLabelAI}>AI</div> | |
| <div className={styles.bubbleAI}>{m.content}</div> | |
| </div> | |
| )} | |
| {m.role === 'user' && ( | |
| <div style={{ alignSelf: 'flex-end', marginLeft: 'auto' }}> | |
| <div className={styles.speakerLabelUser}>You</div> | |
| <div className={styles.bubbleUser}>{m.content}</div> | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| {pending && ( | |
| <div className={styles.chatBubbleRow}> | |
| <div> | |
| <div className={styles.speakerLabelAI}>AI</div> | |
| <div className={styles.bubbleAI}> | |
| <Spinner size="small" label="応答生成中..." /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {error && ( | |
| <div style={{ color: 'red', textAlign: 'center', margin: '8px 0 12px' }}>{error}</div> | |
| )} | |
| </div> | |
| {/* 送信・停止・リセットをすべてアイコンで横並び */} | |
| <form | |
| className={styles.inputRow} | |
| onSubmit={(e) => { | |
| e.preventDefault(); | |
| send(); | |
| }} | |
| > | |
| <Input | |
| style={{ flex: 1, fontSize: 16, borderRadius: 12 }} | |
| value={input} | |
| disabled={pending} | |
| onChange={(_, d) => setInput(d.value)} | |
| placeholder="メッセージを入力..." | |
| autoFocus | |
| /> | |
| <Button | |
| icon={<SendRegular />} | |
| appearance="primary" | |
| className={styles.sendBtn} | |
| type="submit" | |
| disabled={pending || !input || isParamsIncomplete} | |
| aria-label="送信" | |
| title="送信" | |
| size="large" | |
| /> | |
| <Button | |
| icon={<DismissRegular />} | |
| appearance="outline" | |
| className={styles.stopBtn} | |
| type="button" | |
| disabled={!pending} | |
| onClick={stop} | |
| aria-label="停止" | |
| title="停止" | |
| size="large" | |
| /> | |
| <Button | |
| icon={<ArrowSyncRegular />} | |
| appearance="subtle" | |
| className={styles.resetBtn} | |
| type="button" | |
| disabled={pending} | |
| onClick={resetChat} | |
| aria-label="リセット" | |
| title="リセット" | |
| size="large" | |
| /> | |
| </form> | |
| </Card> | |
| {/* 設定右カラム */} | |
| <Card className={styles.paramCard}> | |
| <div className={styles.sectionTitle}> | |
| <SettingsRegular className={styles.configIcon} /> | |
| パラメータ設定 | |
| </div> | |
| <Divider /> | |
| <form className={styles.configForm} onSubmit={e => e.preventDefault()}> | |
| <Label htmlFor="apiKey">API Key</Label> | |
| <Input type="password" id="apiKey" name="apiKey" value={params.apiKey} onChange={handleParamChange} /> | |
| <Label htmlFor="endpoint">エンドポイント</Label> | |
| <Input | |
| id="endpoint" | |
| name="endpoint" | |
| type="password" | |
| value={params.endpoint} | |
| onChange={handleParamChange} | |
| placeholder="https://xxx.openai.azure.com" | |
| /> | |
| <Label htmlFor="deployment">デプロイID</Label> | |
| <Input id="deployment" type="password" name="deployment" value={params.deployment} onChange={handleParamChange} /> | |
| <Label htmlFor="temperature">温度</Label> | |
| <Input | |
| type="number" | |
| id="temperature" | |
| name="temperature" | |
| value={params.temperature} | |
| onChange={handleParamChange} | |
| min={0} | |
| max={2} | |
| step={0.01} | |
| /> | |
| <Label htmlFor="maxHistoryTokens">履歴トークン(最大)</Label> | |
| <Input | |
| type="number" | |
| id="maxHistoryTokens" | |
| name="maxHistoryTokens" | |
| min={100} | |
| max={4096} | |
| step={50} | |
| value={params.maxHistoryTokens} | |
| onChange={handleParamChange} | |
| /> | |
| </form> | |
| <Divider /> | |
| <div className={styles.note}> | |
| *APIキーなどはローカルのみ | |
| <br /> | |
| *直接Azure OpenAI API(SSE)で動作 | |
| </div> | |
| </Card> | |
| </div> | |
| </div> | |
| </FluentProvider> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment