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
|
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:
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
|
|
||||||
<Capabilities>
|
<Capabilities>
|
||||||
<rescap:Capability Name="runFullTrust" />
|
<rescap:Capability Name="runFullTrust" />
|
||||||
|
<DeviceCapability Name="microphone" />
|
||||||
|
|
||||||
</Capabilities>
|
</Capabilities>
|
||||||
</Package>
|
</Package>
|
||||||
|
|||||||
+81
-24
@@ -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";
|
||||||
|
|
||||||
export const api = {
|
type WailsWindow = Window & {
|
||||||
getSettings: () => AppService.GetSettings() as Promise<AppSettings>,
|
chrome?: { webview?: { postMessage?: unknown } };
|
||||||
configure: (settings: Record<string, unknown>) =>
|
webkit?: { messageHandlers?: { external?: { postMessage?: unknown } } };
|
||||||
AppService.Configure(settings as never) as Promise<AppSettings>,
|
wails?: { invoke?: unknown; invokeAsync?: unknown };
|
||||||
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>
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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
|
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
@@ -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