up
This commit is contained in:
@@ -1 +1 @@
|
||||
8ae8a50dd0498aff1d9aa400b1da4f94
|
||||
e6d9601aa27dccaf278ac4a3f7247ae2
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
bfcb33c24266fe5f8784f39a32fa2f2c
|
||||
@@ -0,0 +1 @@
|
||||
2a6c359f91e7e7c95aa4b1f4064d40c
|
||||
+3
-2
@@ -279,7 +279,7 @@ tasks:
|
||||
summary: Builds and runs the Docker image
|
||||
desc: |
|
||||
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
|
||||
only changes the host port mapping. Ensure your app uses port 8080
|
||||
or modify the Dockerfile to match your ServerOptions.Port setting.
|
||||
@@ -289,9 +289,10 @@ tasks:
|
||||
TAG:
|
||||
ref: .TAG
|
||||
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:
|
||||
TAG: "{{.TAG}}"
|
||||
HOST: "{{.HOST}}"
|
||||
PORT: "{{.PORT}}"
|
||||
|
||||
setup:docker:
|
||||
|
||||
@@ -31,5 +31,7 @@
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>(c) 2026, OPIc Flow</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>OPIc Flow records your spoken practice answers for transcription and feedback.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -26,5 +26,7 @@
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>(c) 2026, OPIc Flow</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>OPIc Flow records your spoken practice answers for transcription and feedback.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -50,6 +50,7 @@
|
||||
|
||||
<Capabilities>
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
<DeviceCapability Name="microphone" />
|
||||
|
||||
</Capabilities>
|
||||
</Package>
|
||||
|
||||
+81
-24
@@ -1,28 +1,85 @@
|
||||
import { AppService } from "../bindings/opiccoach";
|
||||
import type { AppSettings, ExamReport, Evaluation, Question } from "./types";
|
||||
|
||||
export const api = {
|
||||
getSettings: () => AppService.GetSettings() as Promise<AppSettings>,
|
||||
configure: (settings: Record<string, unknown>) =>
|
||||
AppService.Configure(settings as never) as Promise<AppSettings>,
|
||||
testConnection: () => AppService.TestConnection() as Promise<void>,
|
||||
generateSpeech: (text: string) =>
|
||||
AppService.GenerateSpeech(text) as Promise<{ audioBase64: string; mimeType: string }>,
|
||||
startSession: (config: Record<string, unknown>) =>
|
||||
AppService.StartSession(config as never) as Promise<{
|
||||
sessionId: string;
|
||||
question: Question;
|
||||
totalCount: number;
|
||||
}>,
|
||||
submitAnswer: (answer: Record<string, unknown>) =>
|
||||
AppService.SubmitAnswer(answer as never) as Promise<{
|
||||
transcript: string;
|
||||
evaluation: Evaluation;
|
||||
next?: Question;
|
||||
completed: boolean;
|
||||
progress: number;
|
||||
}>,
|
||||
finalizeSession: (sessionId: string) =>
|
||||
AppService.FinalizeSession(sessionId) as Promise<ExamReport>,
|
||||
getReport: (sessionId: string) => AppService.GetReport(sessionId) as Promise<ExamReport>
|
||||
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 = {
|
||||
getSettings: () => isWailsRuntime()
|
||||
? AppService.GetSettings() as Promise<AppSettings>
|
||||
: request<AppSettings>("/api/settings"),
|
||||
configure: (settings: Record<string, unknown>) =>
|
||||
isWailsRuntime()
|
||||
? 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) =>
|
||||
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>) =>
|
||||
isWailsRuntime()
|
||||
? AppService.StartSession(config as never) as Promise<{
|
||||
sessionId: string;
|
||||
question: Question;
|
||||
totalCount: number;
|
||||
}>
|
||||
: request<{
|
||||
sessionId: string;
|
||||
question: Question;
|
||||
totalCount: number;
|
||||
}>("/api/sessions", { method: "POST", body: JSON.stringify(config) }),
|
||||
submitAnswer: (answer: Record<string, unknown>) =>
|
||||
isWailsRuntime()
|
||||
? AppService.SubmitAnswer(answer as never) as Promise<{
|
||||
transcript: string;
|
||||
evaluation: Evaluation;
|
||||
next?: Question;
|
||||
completed: boolean;
|
||||
progress: number;
|
||||
}>
|
||||
: request<{
|
||||
transcript: string;
|
||||
evaluation: Evaluation;
|
||||
next?: Question;
|
||||
completed: boolean;
|
||||
progress: number;
|
||||
}>("/api/answers", { method: "POST", body: JSON.stringify(answer) }),
|
||||
finalizeSession: (sessionId: string) =>
|
||||
isWailsRuntime()
|
||||
? 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`)
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !server
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -34,10 +36,12 @@ func main() {
|
||||
MinHeight: 680,
|
||||
BackgroundColour: application.NewRGB(246, 247, 243),
|
||||
URL: "/",
|
||||
Permissions: map[application.PermissionType]application.Permission{
|
||||
application.PermissionMicrophone: application.PermissionAllow,
|
||||
},
|
||||
})
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+150
@@ -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")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user