This commit is contained in:
arhat0307
2026-06-28 21:17:11 +09:00
parent 3e63af5b44
commit e207b9580d
10 changed files with 247 additions and 28 deletions
@@ -1 +1 @@
8ae8a50dd0498aff1d9aa400b1da4f94 e6d9601aa27dccaf278ac4a3f7247ae2
+1
View File
@@ -0,0 +1 @@
bfcb33c24266fe5f8784f39a32fa2f2c
@@ -0,0 +1 @@
2a6c359f91e7e7c95aa4b1f4064d40c
+3 -2
View File
@@ -279,7 +279,7 @@ tasks:
summary: Builds and runs the Docker image summary: Builds and runs the Docker image
desc: | desc: |
Builds the Docker image and runs it, exposing port 8080. Builds the Docker image and runs it, exposing port 8080.
Usage: task run:docker [TAG=myapp:latest] [PORT=8080] Usage: task run:docker [TAG=myapp:latest] [HOST=0.0.0.0] [PORT=8080]
Note: The internal container port is always 8080. The PORT variable Note: The internal container port is always 8080. The PORT variable
only changes the host port mapping. Ensure your app uses port 8080 only changes the host port mapping. Ensure your app uses port 8080
or modify the Dockerfile to match your ServerOptions.Port setting. or modify the Dockerfile to match your ServerOptions.Port setting.
@@ -289,9 +289,10 @@ tasks:
TAG: TAG:
ref: .TAG ref: .TAG
cmds: cmds:
- docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}} - docker run --rm -p {{.HOST | default "0.0.0.0"}}:{{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}}
vars: vars:
TAG: "{{.TAG}}" TAG: "{{.TAG}}"
HOST: "{{.HOST}}"
PORT: "{{.PORT}}" PORT: "{{.PORT}}"
setup:docker: setup:docker:
+2
View File
@@ -31,5 +31,7 @@
<string>true</string> <string>true</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>(c) 2026, OPIc Flow</string> <string>(c) 2026, OPIc Flow</string>
<key>NSMicrophoneUsageDescription</key>
<string>OPIc Flow records your spoken practice answers for transcription and feedback.</string>
</dict> </dict>
</plist> </plist>
+2
View File
@@ -26,5 +26,7 @@
<string>true</string> <string>true</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>(c) 2026, OPIc Flow</string> <string>(c) 2026, OPIc Flow</string>
<key>NSMicrophoneUsageDescription</key>
<string>OPIc Flow records your spoken practice answers for transcription and feedback.</string>
</dict> </dict>
</plist> </plist>
+1
View File
@@ -50,6 +50,7 @@
<Capabilities> <Capabilities>
<rescap:Capability Name="runFullTrust" /> <rescap:Capability Name="runFullTrust" />
<DeviceCapability Name="microphone" />
</Capabilities> </Capabilities>
</Package> </Package>
+67 -10
View File
@@ -1,28 +1,85 @@
import { AppService } from "../bindings/opiccoach"; import { AppService } from "../bindings/opiccoach";
import type { AppSettings, ExamReport, Evaluation, Question } from "./types"; import type { AppSettings, ExamReport, Evaluation, Question } from "./types";
type WailsWindow = Window & {
chrome?: { webview?: { postMessage?: unknown } };
webkit?: { messageHandlers?: { external?: { postMessage?: unknown } } };
wails?: { invoke?: unknown; invokeAsync?: unknown };
};
const isWailsRuntime = () => {
if (typeof window === "undefined") return false;
const runtimeWindow = window as WailsWindow;
return Boolean(
runtimeWindow.chrome?.webview?.postMessage ||
runtimeWindow.webkit?.messageHandlers?.external?.postMessage ||
runtimeWindow.wails?.invoke ||
runtimeWindow.wails?.invokeAsync
);
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: {
"Content-Type": "application/json",
...init?.headers
}
});
if (!response.ok) {
throw new Error(await response.text());
}
return response.json() as Promise<T>;
}
export const api = { export const api = {
getSettings: () => AppService.GetSettings() as Promise<AppSettings>, getSettings: () => isWailsRuntime()
? AppService.GetSettings() as Promise<AppSettings>
: request<AppSettings>("/api/settings"),
configure: (settings: Record<string, unknown>) => configure: (settings: Record<string, unknown>) =>
AppService.Configure(settings as never) as Promise<AppSettings>, isWailsRuntime()
testConnection: () => AppService.TestConnection() as Promise<void>, ? AppService.Configure(settings as never) as Promise<AppSettings>
: request<AppSettings>("/api/configure", { method: "POST", body: JSON.stringify(settings) }),
testConnection: () => isWailsRuntime()
? AppService.TestConnection() as Promise<void>
: request<void>("/api/test-connection", { method: "POST", body: "{}" }),
generateSpeech: (text: string) => generateSpeech: (text: string) =>
AppService.GenerateSpeech(text) as Promise<{ audioBase64: string; mimeType: string }>, isWailsRuntime()
? AppService.GenerateSpeech(text) as Promise<{ audioBase64: string; mimeType: string }>
: request<{ audioBase64: string; mimeType: string }>("/api/speech", { method: "POST", body: JSON.stringify({ text }) }),
startSession: (config: Record<string, unknown>) => startSession: (config: Record<string, unknown>) =>
AppService.StartSession(config as never) as Promise<{ isWailsRuntime()
? AppService.StartSession(config as never) as Promise<{
sessionId: string; sessionId: string;
question: Question; question: Question;
totalCount: number; totalCount: number;
}>, }>
: request<{
sessionId: string;
question: Question;
totalCount: number;
}>("/api/sessions", { method: "POST", body: JSON.stringify(config) }),
submitAnswer: (answer: Record<string, unknown>) => submitAnswer: (answer: Record<string, unknown>) =>
AppService.SubmitAnswer(answer as never) as Promise<{ isWailsRuntime()
? AppService.SubmitAnswer(answer as never) as Promise<{
transcript: string; transcript: string;
evaluation: Evaluation; evaluation: Evaluation;
next?: Question; next?: Question;
completed: boolean; completed: boolean;
progress: number; progress: number;
}>, }>
: request<{
transcript: string;
evaluation: Evaluation;
next?: Question;
completed: boolean;
progress: number;
}>("/api/answers", { method: "POST", body: JSON.stringify(answer) }),
finalizeSession: (sessionId: string) => finalizeSession: (sessionId: string) =>
AppService.FinalizeSession(sessionId) as Promise<ExamReport>, isWailsRuntime()
getReport: (sessionId: string) => AppService.GetReport(sessionId) as Promise<ExamReport> ? AppService.FinalizeSession(sessionId) as Promise<ExamReport>
: request<ExamReport>(`/api/sessions/${sessionId}/finalize`, { method: "POST", body: "{}" }),
getReport: (sessionId: string) => isWailsRuntime()
? AppService.GetReport(sessionId) as Promise<ExamReport>
: request<ExamReport>(`/api/sessions/${sessionId}/report`)
}; };
+5 -1
View File
@@ -1,3 +1,5 @@
//go:build !server
package main package main
import ( import (
@@ -34,10 +36,12 @@ func main() {
MinHeight: 680, MinHeight: 680,
BackgroundColour: application.NewRGB(246, 247, 243), BackgroundColour: application.NewRGB(246, 247, 243),
URL: "/", URL: "/",
Permissions: map[application.PermissionType]application.Permission{
application.PermissionMicrophone: application.PermissionAllow,
},
}) })
if err := app.Run(); err != nil { if err := app.Run(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
+150
View File
@@ -0,0 +1,150 @@
//go:build server
package main
import (
"embed"
"encoding/json"
"errors"
"io/fs"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
//go:embed all:frontend/dist
var serverAssets embed.FS
func main() {
service := NewAppService()
mux := http.NewServeMux()
registerAPI(mux, service)
registerStatic(mux)
port := strings.TrimSpace(os.Getenv("PORT"))
if port == "" {
port = "8080"
}
host := strings.TrimSpace(os.Getenv("HOST"))
if host == "" {
host = strings.TrimSpace(os.Getenv("WAILS_SERVER_HOST"))
}
if host == "" {
host = "127.0.0.1"
}
addr := host + ":" + port
server := &http.Server{
Addr: addr,
Handler: logRequests(mux),
ReadHeaderTimeout: 10 * time.Second,
}
log.Printf("OPIc Flow server listening on http://%s", addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}
func registerAPI(mux *http.ServeMux, service *AppService) {
mux.HandleFunc("GET /api/settings", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, service.GetSettings())
})
mux.HandleFunc("POST /api/configure", func(w http.ResponseWriter, r *http.Request) {
var request ConfigureRequest
if !readJSON(w, r, &request) {
return
}
result, err := service.Configure(request)
writeResult(w, result, err)
})
mux.HandleFunc("POST /api/test-connection", func(w http.ResponseWriter, r *http.Request) {
writeResult(w, map[string]bool{"ok": true}, service.TestConnection())
})
mux.HandleFunc("POST /api/speech", func(w http.ResponseWriter, r *http.Request) {
var request struct {
Text string `json:"text"`
}
if !readJSON(w, r, &request) {
return
}
result, err := service.GenerateSpeech(request.Text)
writeResult(w, result, err)
})
mux.HandleFunc("POST /api/sessions", func(w http.ResponseWriter, r *http.Request) {
var request ExamConfig
if !readJSON(w, r, &request) {
return
}
result, err := service.StartSession(request)
writeResult(w, result, err)
})
mux.HandleFunc("POST /api/answers", func(w http.ResponseWriter, r *http.Request) {
var request SubmitAnswerRequest
if !readJSON(w, r, &request) {
return
}
result, err := service.SubmitAnswer(request)
writeResult(w, result, err)
})
mux.HandleFunc("POST /api/sessions/{sessionID}/finalize", func(w http.ResponseWriter, r *http.Request) {
result, err := service.FinalizeSession(r.PathValue("sessionID"))
writeResult(w, result, err)
})
mux.HandleFunc("GET /api/sessions/{sessionID}/report", func(w http.ResponseWriter, r *http.Request) {
result, err := service.GetReport(r.PathValue("sessionID"))
writeResult(w, result, err)
})
}
func registerStatic(mux *http.ServeMux) {
dist, err := fs.Sub(serverAssets, "frontend/dist")
if err != nil {
log.Fatalf("frontend assets are missing: %v", err)
}
fileServer := http.FileServer(http.FS(dist))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
if _, err := fs.Stat(dist, strings.TrimPrefix(r.URL.Path, "/")); err != nil {
r.URL.Path = "/"
}
}
fileServer.ServeHTTP(w, r)
})
}
func readJSON(w http.ResponseWriter, r *http.Request, target any) bool {
defer r.Body.Close()
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return false
}
return true
}
func writeResult(w http.ResponseWriter, value any, err error) {
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, value)
}
func writeJSON(w http.ResponseWriter, value any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(value); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, strconv.FormatInt(time.Since(start).Milliseconds(), 10)+"ms")
})
}