Skip to content

Instantly share code, notes, and snippets.

@ippu-i
Created September 17, 2025 14:33
Show Gist options
  • Select an option

  • Save ippu-i/cbff9bc767ba9b6f43380159af846dc4 to your computer and use it in GitHub Desktop.

Select an option

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.
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