Diff
1diff --git a/cmd/jp/main.go b/cmd/jp/main.go
2index 7dba3f8f47d3c65b2a77be9d0bf6a561346291cd..3728ca97e20ab89fb59b281a96fb7b5a2e679e9c 100644
3--- a/cmd/jp/main.go
4+++ b/cmd/jp/main.go
5@@ -55,8 +55,6 @@ func runCmd(args []string) {
6 personasDir := fs.String("personas", "assets/personas", "directory of persona JSON files")
7 fs.Parse(args)
8
9- services := probeServices(cfg)
10-
11 data, err := os.ReadFile(*mapPath)
12 if err != nil {
13 fatalf("read map: %v", err)
14@@ -74,16 +72,24 @@ func runCmd(args []string) {
15 playDone := make(chan struct{}, 1)
16 orch := buildOrchestrator(cfg, state, personas, playDone)
17
18- m := ui.NewModel(state, orch, services, playDone, int(recordCap.Seconds()))
19- if _, err := tea.NewProgram(m).Run(); err != nil {
20+ m := ui.NewModel(state, orch, checkingServices(cfg), playDone, int(recordCap.Seconds()))
21+ p := tea.NewProgram(m)
22+ availability.CheckAllAsync(cfg, func(svc availability.Service) {
23+ p.Send(ui.ProbeResultMsg{Svc: svc})
24+ })
25+ if _, err := p.Run(); err != nil {
26 fatalf("run: %v", err)
27 }
28 }
29
30-func probeServices(cfg config.Config) []availability.Service {
31- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
32- defer cancel()
33- return availability.CheckAll(ctx, cfg)
34+// checkingServices seeds the UI with one "checking…" row per configured
35+// service before any probe result has arrived.
36+func checkingServices(cfg config.Config) []availability.Service {
37+ return []availability.Service{
38+ {Name: "LLM", URL: cfg.LLMBaseURL},
39+ {Name: "TTS", URL: cfg.TTSBaseURL},
40+ {Name: "STT", URL: cfg.STTURL},
41+ }
42 }
43
44 func buildOrchestrator(cfg config.Config, state *game.State, personas []persona.Persona, playDone chan struct{}) *game.Orchestrator {
45diff --git a/internal/availability/availability.go b/internal/availability/availability.go
46index 5907843bee3e6acc2fd5dbdf6aad5090e377ff82..0bdb89769cb6f95a658d180fdee9aefa6f7ee514 100644
47--- a/internal/availability/availability.go
48+++ b/internal/availability/availability.go
49@@ -48,6 +48,23 @@ func CheckAll(ctx context.Context, cfg config.Config) []Service {
50 }
51 }
52
53+// CheckAllAsync probes every configured service concurrently and invokes fn
54+// once per service, in completion order. Each probe keeps its bounded timeout
55+// and names the affected URL on failure, so one slow or dead service does not
56+// hold up the others. It returns immediately after launching the probes.
57+func CheckAllAsync(cfg config.Config, fn func(Service)) {
58+ client := &http.Client{Timeout: checkTimeout}
59+ ctx := context.Background()
60+ jobs := []func() Service{
61+ func() Service { return checkLLM(ctx, client, cfg.LLMBaseURL) },
62+ func() Service { return checkTTS(ctx, client, cfg.TTSBaseURL) },
63+ func() Service { return checkSTT(ctx, client, cfg.STTURL) },
64+ }
65+ for _, j := range jobs {
66+ go func(j func() Service) { fn(j()) }(j)
67+ }
68+}
69+
70 func checkLLM(ctx context.Context, client *http.Client, base string) Service {
71 s := Service{Name: "LLM", URL: base}
72 modelsURL := strings.TrimRight(base, "/") + "/models"
73diff --git a/internal/availability/availability_test.go b/internal/availability/availability_test.go
74index 5e1450eb0866b0d2e885cb82f47a7b9b84dbf3bf..f92d9c7a533dcb604b5d7ef89d1964f49baf59a7 100644
75--- a/internal/availability/availability_test.go
76+++ b/internal/availability/availability_test.go
77@@ -111,6 +111,55 @@ func TestCheckSTTDown(t *testing.T) {
78 }
79 }
80
81+func TestCheckAllAsyncPerServiceCompletion(t *testing.T) {
82+ llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
83+ if r.URL.Path == "/models" {
84+ w.Header().Set("Content-Type", "application/json")
85+ _, _ = w.Write([]byte(`{"data":[{"id":"jp"}]}`))
86+ return
87+ }
88+ http.NotFound(w, r)
89+ }))
90+ defer llmSrv.Close()
91+
92+ ttsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93+ http.NotFound(w, r) // /models 404: server is up, endpoint absent
94+ }))
95+ defer ttsSrv.Close()
96+
97+ cfg := config.Config{
98+ LLMBaseURL: llmSrv.URL,
99+ TTSBaseURL: ttsSrv.URL,
100+ STTURL: deadURL(t) + "/inference",
101+ }
102+
103+ ch := make(chan Service, 3)
104+ CheckAllAsync(cfg, func(s Service) { ch <- s })
105+
106+ byName := map[string]Service{}
107+ for i := 0; i < 3; i++ {
108+ select {
109+ case s := <-ch:
110+ byName[s.Name] = s
111+ case <-time.After(15 * time.Second):
112+ t.Fatal("a probe did not complete in time")
113+ }
114+ }
115+
116+ if !byName["LLM"].Up || !strings.Contains(byName["LLM"].Detail, "jp") {
117+ t.Errorf("LLM = %+v, want up with model id", byName["LLM"])
118+ }
119+ if !byName["TTS"].Up {
120+ t.Errorf("TTS = %+v, want up", byName["TTS"])
121+ }
122+ if byName["STT"].Up {
123+ t.Errorf("STT = %+v, want down against a dead URL", byName["STT"])
124+ }
125+ if !strings.Contains(byName["STT"].Detail, "127.0.0.1") {
126+ t.Errorf("STT detail should name the configured host: %q", byName["STT"].Detail)
127+ }
128+}
129+
130 func TestCheckAllNamesAndURLs(t *testing.T) {
131 cfg := config.Config{
132 LLMBaseURL: deadURL(t),
133diff --git a/internal/ui/app.go b/internal/ui/app.go
134index 6bd5d0c90a6f603dec7784b704a7663d8a2edbb4..c3ac6380ecc55c74729d24564656324e8170ccea 100644
135--- a/internal/ui/app.go
136+++ b/internal/ui/app.go
137@@ -30,10 +30,17 @@ type finishResultMsg struct{ res game.TurnResult }
138 type tickMsg struct{ t time.Time }
139 type playDoneMsg struct{}
140
141+// ProbeResultMsg carries one service's availability result as it arrives from
142+// the async startup probe, so the UI can update that service in place.
143+type ProbeResultMsg struct {
144+ Svc availability.Service
145+}
146+
147 type model struct {
148 state *game.State
149 orch *game.Orchestrator
150 services []availability.Service
151+ resolved map[string]bool
152 playDone chan struct{}
153 recordCapSec int
154
155@@ -55,6 +62,7 @@ func NewModel(state *game.State, orch *game.Orchestrator, services []availabilit
156 state: state,
157 orch: orch,
158 services: services,
159+ resolved: make(map[string]bool, len(services)),
160 playDone: playDone,
161 recordCapSec: recordCapSec,
162 }
163@@ -80,6 +88,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
164 if cmd := m.listenPlayDone(); cmd != nil {
165 cmds = append(cmds, cmd)
166 }
167+ case ProbeResultMsg:
168+ m.applyProbe(v.Svc)
169 case tickMsg:
170 if m.phase == phaseRecording {
171 cmds = append(cmds, nextTick())
172@@ -218,7 +228,7 @@ func (m *model) View() string {
173 judgeScore: m.judgeScore,
174 judgeFeedback: m.judgeFeedback,
175 judgeErr: m.judgeErr,
176- services: serviceLines(m.services),
177+ services: m.serviceLines(),
178 status: m.status,
179 }
180 if m.phase == phaseProcessing {
181@@ -319,10 +329,30 @@ func locLines(mp game.MapData) []locLine {
182 return out
183 }
184
185-func serviceLines(svcs []availability.Service) []serviceLine {
186- out := make([]serviceLine, 0, len(svcs))
187- for _, s := range svcs {
188- out = append(out, serviceLine{name: s.Name, up: s.Up, url: s.URL, detail: s.Detail})
189+// applyProbe records one arrived availability result in place, keyed by name.
190+func (m *model) applyProbe(svc availability.Service) {
191+ for i := range m.services {
192+ if m.services[i].Name == svc.Name {
193+ m.services[i] = svc
194+ break
195+ }
196+ }
197+ m.resolved[svc.Name] = true
198+}
199+
200+func (m *model) serviceLines() []serviceLine {
201+ out := make([]serviceLine, 0, len(m.services))
202+ for _, s := range m.services {
203+ sl := serviceLine{name: s.Name, url: s.URL, detail: s.Detail}
204+ switch {
205+ case !m.resolved[s.Name]:
206+ sl.state = "checking"
207+ case s.Up:
208+ sl.state = "up"
209+ default:
210+ sl.state = "down"
211+ }
212+ out = append(out, sl)
213 }
214 return out
215 }
216diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go
217index a8b19d25d1d5867b3839d3c2bb77882178dfe5ce..4f2c89bfedaadb793b72c19cea596252b174518e 100644
218--- a/internal/ui/app_test.go
219+++ b/internal/ui/app_test.go
220@@ -8,6 +8,7 @@ import (
221
222 "github.com/charmbracelet/bubbletea"
223
224+ "japanese/internal/availability"
225 "japanese/internal/game"
226 "japanese/internal/llm"
227 )
228@@ -146,6 +147,30 @@ func TestStopIgnoredWhileBeginInFlight(t *testing.T) {
229 }
230 }
231
232+func TestProbeCheckingThenTransitionsInPlace(t *testing.T) {
233+ st := testState(t)
234+ svcs := []availability.Service{
235+ {Name: "LLM", URL: "http://llm"},
236+ {Name: "TTS", URL: "http://tts"},
237+ {Name: "STT", URL: "http://stt"},
238+ }
239+ m := NewModel(st, nil, svcs, nil, 10)
240+
241+ v := m.View()
242+ if strings.Count(v, "checking") != 3 {
243+ t.Fatalf("want 3 checking services initially, view:\n%s", v)
244+ }
245+
246+ m.Update(ProbeResultMsg{Svc: availability.Service{Name: "LLM", URL: "http://llm", Up: true, Detail: "models: jp"}})
247+ v = m.View()
248+ if !strings.Contains(v, "LLM up http://llm") {
249+ t.Fatalf("LLM should show up in place:\n%s", v)
250+ }
251+ if strings.Count(v, "checking") != 2 {
252+ t.Fatalf("want 2 checking after LLM result, view:\n%s", v)
253+ }
254+}
255+
256 func TestNpcSpeakingTransitionsToDone(t *testing.T) {
257 m, _ := buildModel(t)
258 res := game.TurnResult{
259@@ -197,7 +222,7 @@ func TestRenderNeverShowsJapanese(t *testing.T) {
260 npcRomaji: "arigatou gozaimasu",
261 npcEnglish: "thank you very much",
262 hasJudge: true, judgeScore: 90, judgeFeedback: "good job desu",
263- services: []serviceLine{{name: "LLM", up: true, url: "http://127.0.0.1:8081/v1"}},
264+ services: []serviceLine{{name: "LLM", state: "up", url: "http://127.0.0.1:8081/v1"}},
265 }
266 got := render(good)
267 for _, want := range []string{"konnichiwa desu", "arigatou gozaimasu", "thank you very much", "good job desu"} {
268@@ -213,7 +238,7 @@ func TestRenderNeverShowsJapanese(t *testing.T) {
269 hostile.judgeFeedback = "よくできました"
270 hostile.locs[0].label = "ラーメン屋"
271 hostile.status = "エラー occurred"
272- hostile.services = []serviceLine{{name: "STT", up: false, url: "http://127.0.0.1:8178/inference", detail: "接続できない"}}
273+ hostile.services = []serviceLine{{name: "STT", state: "down", url: "http://127.0.0.1:8178/inference", detail: "接続できない"}}
274 assertNoJapanese(t, render(hostile))
275 }
276
277diff --git a/internal/ui/render.go b/internal/ui/render.go
278index c38561c785b8ef80e01b97397714aeb25f89f0b4..00b85408796e8ec0efff47f6896b9fc1392bd2f2 100644
279--- a/internal/ui/render.go
280+++ b/internal/ui/render.go
281@@ -16,7 +16,7 @@ type locLine struct {
282
283 type serviceLine struct {
284 name string
285- up bool
286+ state string // "checking", "up", or "down"
287 url string
288 detail string
289 }
290@@ -135,11 +135,7 @@ func learningBlock(hasJudge bool, score int, feedback, judgeErr string) string {
291 func serviceBlock(svcs []serviceLine) string {
292 var b strings.Builder
293 for _, s := range svcs {
294- state := "down"
295- if s.up {
296- state = "up"
297- }
298- fmt.Fprintf(&b, "%s %s %s", sanitizeRomajiOnly(s.name), state, sanitizeRomajiOnly(s.url))
299+ fmt.Fprintf(&b, "%s %s %s", sanitizeRomajiOnly(s.name), s.state, sanitizeRomajiOnly(s.url))
300 if s.detail != "" {
301 fmt.Fprintf(&b, " (%s)", sanitizeRomajiOnly(s.detail))
302 }