374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
import React, { useCallback, useState } from "react";
|
||
import {
|
||
Alert,
|
||
Modal,
|
||
Pressable,
|
||
ScrollView,
|
||
Text,
|
||
TouchableOpacity,
|
||
View,
|
||
} from "react-native";
|
||
import * as Clipboard from "expo-clipboard";
|
||
import { useThemeColors } from "@/lib/theme";
|
||
import {
|
||
clearVoicepeakDebugLogs,
|
||
getVoicepeakDebugLogs,
|
||
type VoicepeakDebugLogEntry,
|
||
} from "@/lib/voicepeakDebugLog";
|
||
import { VoicepeakDebugAudioActions } from "@/components/Settings/VoicepeakDebugAudioActions";
|
||
|
||
const formatDebugLog = (log: VoicepeakDebugLogEntry) =>
|
||
JSON.stringify(log, null, 2);
|
||
|
||
const formatAllDebugLogs = (logs: VoicepeakDebugLogEntry[]) =>
|
||
JSON.stringify(
|
||
{
|
||
exportedAt: new Date().toISOString(),
|
||
retentionDays: 7,
|
||
count: logs.length,
|
||
logs,
|
||
},
|
||
null,
|
||
2,
|
||
);
|
||
|
||
export const VoicepeakDebugLogSection = () => {
|
||
const { colors, fixed } = useThemeColors();
|
||
const [logs, setLogs] = useState<VoicepeakDebugLogEntry[]>([]);
|
||
const [selectedLog, setSelectedLog] = useState<VoicepeakDebugLogEntry | null>(
|
||
null,
|
||
);
|
||
const [loading, setLoading] = useState(false);
|
||
const [copied, setCopied] = useState(false);
|
||
const [expanded, setExpanded] = useState(false);
|
||
|
||
const refresh = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
setLogs(await getVoicepeakDebugLogs());
|
||
} catch (error) {
|
||
console.warn("Failed to load Voicepeak debug logs", error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const toggleExpanded = useCallback(() => {
|
||
setExpanded((current) => {
|
||
const next = !current;
|
||
if (next) void refresh();
|
||
return next;
|
||
});
|
||
}, [refresh]);
|
||
|
||
const copyText = useCallback(async (text: string) => {
|
||
await Clipboard.setStringAsync(text);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 1500);
|
||
}, []);
|
||
|
||
const clearLogs = useCallback(() => {
|
||
Alert.alert(
|
||
"音声作成ログを削除",
|
||
"端末に保存されたVoicepeakデバッグ履歴をすべて削除します。",
|
||
[
|
||
{ text: "キャンセル", style: "cancel" },
|
||
{
|
||
text: "削除",
|
||
style: "destructive",
|
||
onPress: () => {
|
||
void clearVoicepeakDebugLogs().then(() => {
|
||
setLogs([]);
|
||
setSelectedLog(null);
|
||
});
|
||
},
|
||
},
|
||
],
|
||
);
|
||
}, []);
|
||
|
||
const borderColor = colors.borderSecondary ?? "#ccc";
|
||
const secondaryText = colors.textSecondary ?? colors.text;
|
||
|
||
return (
|
||
<View
|
||
style={{
|
||
paddingHorizontal: 15,
|
||
paddingVertical: 18,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: borderColor,
|
||
backgroundColor: colors.surface,
|
||
}}
|
||
>
|
||
<View
|
||
style={{
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
marginBottom: expanded ? 8 : 0,
|
||
}}
|
||
>
|
||
<TouchableOpacity
|
||
accessibilityRole="button"
|
||
accessibilityState={{ expanded }}
|
||
onPress={toggleExpanded}
|
||
style={{
|
||
flex: 1,
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
paddingVertical: 2,
|
||
}}
|
||
>
|
||
<Text
|
||
style={{
|
||
flex: 1,
|
||
fontSize: 16,
|
||
fontWeight: "600",
|
||
color: colors.text,
|
||
}}
|
||
>
|
||
Voicepeak デバッグログ
|
||
{expanded ? `(${logs.length}件)` : ""}
|
||
</Text>
|
||
<Text style={{ color: fixed.primary, padding: 8 }}>
|
||
{expanded ? "閉じる ▲" : "表示する ▼"}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
{expanded && (
|
||
<TouchableOpacity onPress={() => void refresh()}>
|
||
<Text style={{ color: fixed.primary, padding: 8 }}>
|
||
{loading ? "読込中" : "更新"}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
|
||
{expanded && (
|
||
<>
|
||
<Text
|
||
style={{
|
||
fontSize: 12,
|
||
lineHeight: 18,
|
||
color: secondaryText,
|
||
marginBottom: 12,
|
||
}}
|
||
>
|
||
音声作成APIへ送信したテキスト、HTTP結果、処理時間、応答サイズを端末内に保存します。
|
||
APIトークンは保存しません。履歴は7日後に自動削除されます。
|
||
</Text>
|
||
|
||
<View style={{ flexDirection: "row", gap: 8, marginBottom: 12 }}>
|
||
<TouchableOpacity
|
||
disabled={logs.length === 0}
|
||
onPress={() => void copyText(formatAllDebugLogs(logs))}
|
||
style={{
|
||
flex: 1,
|
||
paddingVertical: 10,
|
||
borderRadius: 8,
|
||
backgroundColor: logs.length > 0 ? fixed.primary : borderColor,
|
||
}}
|
||
>
|
||
<Text
|
||
style={{
|
||
color: "#fff",
|
||
textAlign: "center",
|
||
fontWeight: "600",
|
||
}}
|
||
>
|
||
{copied ? "コピーしました" : "全履歴をコピー"}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
disabled={logs.length === 0}
|
||
onPress={clearLogs}
|
||
style={{
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 10,
|
||
borderRadius: 8,
|
||
borderWidth: 1,
|
||
borderColor,
|
||
}}
|
||
>
|
||
<Text
|
||
style={{ color: logs.length > 0 ? "#d32f2f" : secondaryText }}
|
||
>
|
||
削除
|
||
</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{logs.length === 0 ? (
|
||
<Text style={{ color: secondaryText, paddingVertical: 10 }}>
|
||
保存されたログはありません。
|
||
</Text>
|
||
) : (
|
||
logs.slice(0, 200).map((log) => {
|
||
const statusColor =
|
||
log.status === "success"
|
||
? "#2e7d32"
|
||
: log.status === "error"
|
||
? "#d32f2f"
|
||
: "#ed6c02";
|
||
return (
|
||
<TouchableOpacity
|
||
key={log.id}
|
||
onPress={() => setSelectedLog(log)}
|
||
style={{
|
||
paddingVertical: 11,
|
||
borderTopWidth: 1,
|
||
borderTopColor: borderColor,
|
||
}}
|
||
>
|
||
<View
|
||
style={{
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
marginBottom: 5,
|
||
}}
|
||
>
|
||
<Text style={{ color: statusColor, fontWeight: "700" }}>
|
||
{log.status === "success"
|
||
? "成功"
|
||
: log.status === "error"
|
||
? "失敗"
|
||
: "処理中"}
|
||
</Text>
|
||
<Text
|
||
style={{
|
||
flex: 1,
|
||
textAlign: "right",
|
||
color: secondaryText,
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{new Date(log.createdAt).toLocaleString("ja-JP")}
|
||
</Text>
|
||
</View>
|
||
<Text
|
||
numberOfLines={2}
|
||
style={{ color: colors.text, fontSize: 13, lineHeight: 18 }}
|
||
>
|
||
{log.text}
|
||
</Text>
|
||
<Text
|
||
style={{
|
||
color: secondaryText,
|
||
fontSize: 11,
|
||
marginTop: 5,
|
||
}}
|
||
>
|
||
{log.codePointCount ?? Array.from(log.text).length}文字
|
||
{log.chunkIndex && log.totalChunks
|
||
? ` ・ 分割 ${log.chunkIndex}/${log.totalChunks}`
|
||
: ""}
|
||
{log.attemptNumber ? ` ・ 試行 ${log.attemptNumber}` : ""}
|
||
{typeof log.httpStatus === "number"
|
||
? ` ・ HTTP ${log.httpStatus}`
|
||
: ""}
|
||
{log.errorCode ? ` ・ ${log.errorCode}` : ""}
|
||
{log.cacheStatus ? ` ・ ${log.cacheStatus}` : ""}
|
||
{typeof log.durationMilliseconds === "number"
|
||
? ` ・ ${log.durationMilliseconds}ms`
|
||
: ""}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
);
|
||
})
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
<Modal
|
||
visible={selectedLog !== null}
|
||
transparent
|
||
animationType="fade"
|
||
onRequestClose={() => setSelectedLog(null)}
|
||
>
|
||
<Pressable
|
||
onPress={() => setSelectedLog(null)}
|
||
style={{
|
||
flex: 1,
|
||
justifyContent: "center",
|
||
padding: 20,
|
||
backgroundColor: "rgba(0,0,0,0.55)",
|
||
}}
|
||
>
|
||
<Pressable
|
||
onPress={(event) => event.stopPropagation()}
|
||
style={{
|
||
maxHeight: "85%",
|
||
padding: 16,
|
||
borderRadius: 12,
|
||
backgroundColor: colors.surface,
|
||
}}
|
||
>
|
||
<Text
|
||
style={{
|
||
fontSize: 17,
|
||
fontWeight: "700",
|
||
color: colors.text,
|
||
marginBottom: 12,
|
||
}}
|
||
>
|
||
Voicepeakログ詳細
|
||
</Text>
|
||
{selectedLog && (
|
||
<VoicepeakDebugAudioActions
|
||
log={selectedLog}
|
||
onComplete={refresh}
|
||
/>
|
||
)}
|
||
<ScrollView>
|
||
<Text
|
||
selectable
|
||
style={{
|
||
color: colors.text,
|
||
fontSize: 12,
|
||
lineHeight: 18,
|
||
fontFamily: "monospace",
|
||
}}
|
||
>
|
||
{selectedLog ? formatDebugLog(selectedLog) : ""}
|
||
</Text>
|
||
</ScrollView>
|
||
<View style={{ flexDirection: "row", gap: 8, marginTop: 14 }}>
|
||
<TouchableOpacity
|
||
onPress={() =>
|
||
selectedLog && void copyText(formatDebugLog(selectedLog))
|
||
}
|
||
style={{
|
||
flex: 1,
|
||
paddingVertical: 11,
|
||
borderRadius: 8,
|
||
backgroundColor: fixed.primary,
|
||
}}
|
||
>
|
||
<Text
|
||
style={{
|
||
color: "#fff",
|
||
textAlign: "center",
|
||
fontWeight: "600",
|
||
}}
|
||
>
|
||
{copied ? "コピーしました" : "詳細をコピー"}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
onPress={() => setSelectedLog(null)}
|
||
style={{
|
||
paddingHorizontal: 18,
|
||
paddingVertical: 11,
|
||
borderRadius: 8,
|
||
borderWidth: 1,
|
||
borderColor,
|
||
}}
|
||
>
|
||
<Text style={{ color: colors.text }}>閉じる</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</Pressable>
|
||
</Pressable>
|
||
</Modal>
|
||
</View>
|
||
);
|
||
};
|