From 83654e6453c9fb624e539d7610f18c5e172335ab Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 14 Nov 2025 13:13:09 +0900 Subject: [PATCH 1/7] refator segmenting logic into multi-pass-file --- apps/desktop/src/utils/index.ts | 1 + apps/desktop/src/utils/segment.ts | 428 ------------------ apps/desktop/src/utils/segment/index.ts | 146 ++++++ .../src/utils/segment/pass-build-segments.ts | 138 ++++++ .../src/utils/segment/pass-merge-segments.ts | 58 +++ .../src/utils/segment/pass-normalize-words.ts | 43 ++ .../utils/segment/pass-propagate-identity.ts | 61 +++ .../utils/segment/pass-resolve-speakers.ts | 130 ++++++ .../src/utils/{ => segment}/segment.test.ts | 2 +- apps/desktop/src/utils/segment/shared.ts | 123 +++++ 10 files changed, 701 insertions(+), 429 deletions(-) delete mode 100644 apps/desktop/src/utils/segment.ts create mode 100644 apps/desktop/src/utils/segment/index.ts create mode 100644 apps/desktop/src/utils/segment/pass-build-segments.ts create mode 100644 apps/desktop/src/utils/segment/pass-merge-segments.ts create mode 100644 apps/desktop/src/utils/segment/pass-normalize-words.ts create mode 100644 apps/desktop/src/utils/segment/pass-propagate-identity.ts create mode 100644 apps/desktop/src/utils/segment/pass-resolve-speakers.ts rename apps/desktop/src/utils/{ => segment}/segment.test.ts (99%) create mode 100644 apps/desktop/src/utils/segment/shared.ts diff --git a/apps/desktop/src/utils/index.ts b/apps/desktop/src/utils/index.ts index 8e5a77a364..97b950c4aa 100644 --- a/apps/desktop/src/utils/index.ts +++ b/apps/desktop/src/utils/index.ts @@ -1,4 +1,5 @@ export * from "./timeline"; +export * from "./segment"; export const id = () => crypto.randomUUID() as string; diff --git a/apps/desktop/src/utils/segment.ts b/apps/desktop/src/utils/segment.ts deleted file mode 100644 index b33c8b2aaa..0000000000 --- a/apps/desktop/src/utils/segment.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { Data, Schema } from "effect"; - -export enum ChannelProfile { - DirectMic = 0, - RemoteParty = 1, - MixedCapture = 2, -} - -export const ChannelProfileSchema = Schema.Enums(ChannelProfile); - -export type WordLike = { - text: string; - start_ms: number; - end_ms: number; - channel: ChannelProfile; -}; - -export type PartialWord = WordLike; - -export type SegmentWord = WordLike & { isFinal: boolean; id?: string }; - -type SpeakerHintData = - | { - type: "provider_speaker_index"; - speaker_index: number; - provider?: string; - channel?: number; - } - | { type: "user_speaker_assignment"; human_id: string }; - -export type RuntimeSpeakerHint = { - wordIndex: number; - data: SpeakerHintData; -}; - -export type Segment = { - key: SegmentKey; - words: TWord[]; -}; - -export type SegmentKey = { - readonly channel: ChannelProfile; - readonly speaker_index?: number; - readonly speaker_human_id?: string; -}; - -export const SegmentKey = { - make: ( - params: { channel: ChannelProfile } & Partial<{ - speaker_index: number; - speaker_human_id: string; - }>, - ): SegmentKey => Data.struct(params), -}; - -type SpeakerIdentity = { - speaker_index?: number; - human_id?: string; -}; - -type SpeakerState = { - assignmentByWordIndex: Map; - humanIdBySpeakerIndex: Map; - humanIdByChannel: Map; - lastSpeakerByChannel: Map; - completeChannels: Set; -}; - -export function buildSegments< - TFinal extends WordLike, - TPartial extends WordLike, ->( - finalWords: readonly TFinal[], - partialWords: readonly TPartial[], - speakerHints: readonly RuntimeSpeakerHint[] = [], - options?: { maxGapMs?: number; numSpeakers?: number }, -): Segment[] { - const words = normalizeWords(finalWords, partialWords); - return segmentWords(words, speakerHints, options); -} - -function segmentWords( - words: readonly TWord[], - speakerHints: readonly RuntimeSpeakerHint[], - options?: { maxGapMs?: number; numSpeakers?: number }, -): Segment[] { - if (words.length === 0) { - return []; - } - - const state = createSpeakerState(speakerHints, options); - const segments: Segment[] = []; - const activeSegments = new Map>(); - - words.forEach((word, index) => { - const key = resolveSegmentKey(index, word, state); - placeWordInSegment(word, key, segments, activeSegments, options); - }); - - propagateCompleteChannelIdentities(segments, state); - - return mergeAdjacentSegments(segments); -} - -function createSpeakerState( - speakerHints: readonly RuntimeSpeakerHint[], - options?: { numSpeakers?: number }, -): SpeakerState { - const assignmentByWordIndex = new Map(); - const humanIdBySpeakerIndex = new Map(); - const humanIdByChannel = new Map(); - const lastSpeakerByChannel = new Map(); - const completeChannels = new Set([ChannelProfile.DirectMic]); - - if (options?.numSpeakers === 2) { - completeChannels.add(ChannelProfile.RemoteParty); - } - - for (const hint of speakerHints) { - const current = assignmentByWordIndex.get(hint.wordIndex) ?? {}; - if (hint.data.type === "provider_speaker_index") { - current.speaker_index = hint.data.speaker_index; - } else { - current.human_id = hint.data.human_id; - } - assignmentByWordIndex.set(hint.wordIndex, { ...current }); - - if (current.speaker_index !== undefined && current.human_id !== undefined) { - humanIdBySpeakerIndex.set(current.speaker_index, current.human_id); - } - } - - return { - assignmentByWordIndex, - humanIdBySpeakerIndex, - humanIdByChannel, - lastSpeakerByChannel, - completeChannels, - }; -} - -function resolveSegmentKey( - wordIndex: number, - word: TWord, - state: SpeakerState, -): SegmentKey { - const assignment = state.assignmentByWordIndex.get(wordIndex); - const identity = resolveSpeakerIdentity(word, assignment, state); - rememberIdentity(word, assignment, identity, state); - - const params: { - channel: ChannelProfile; - speaker_index?: number; - speaker_human_id?: string; - } = { channel: word.channel }; - - if (identity.speaker_index !== undefined) { - params.speaker_index = identity.speaker_index; - } - - if (identity.human_id !== undefined) { - params.speaker_human_id = identity.human_id; - } - - return SegmentKey.make(params); -} - -function resolveSpeakerIdentity( - word: TWord, - assignment: SpeakerIdentity | undefined, - state: SpeakerState, -): SpeakerIdentity { - const identity: SpeakerIdentity = { - speaker_index: assignment?.speaker_index, - human_id: assignment?.human_id, - }; - - if (identity.speaker_index !== undefined && identity.human_id === undefined) { - identity.human_id = state.humanIdBySpeakerIndex.get(identity.speaker_index); - } - - if ( - identity.human_id === undefined && - state.completeChannels.has(word.channel) - ) { - const channelHumanId = state.humanIdByChannel.get(word.channel); - if (channelHumanId !== undefined) { - identity.human_id = channelHumanId; - } - } - - if ( - !word.isFinal && - (identity.speaker_index === undefined || identity.human_id === undefined) - ) { - const last = state.lastSpeakerByChannel.get(word.channel); - if (last) { - if (identity.speaker_index === undefined) { - identity.speaker_index = last.speaker_index; - } - if (identity.human_id === undefined) { - identity.human_id = last.human_id; - } - } - } - - return identity; -} - -function rememberIdentity( - word: TWord, - assignment: SpeakerIdentity | undefined, - identity: SpeakerIdentity, - state: SpeakerState, -): void { - const hasExplicitAssignment = - assignment !== undefined && - (assignment.speaker_index !== undefined || - assignment.human_id !== undefined); - - if (identity.speaker_index !== undefined && identity.human_id !== undefined) { - state.humanIdBySpeakerIndex.set(identity.speaker_index, identity.human_id); - } - - if ( - state.completeChannels.has(word.channel) && - identity.human_id !== undefined && - identity.speaker_index === undefined - ) { - state.humanIdByChannel.set(word.channel, identity.human_id); - } - - if ( - !word.isFinal || - identity.speaker_index !== undefined || - hasExplicitAssignment - ) { - if ( - identity.speaker_index !== undefined || - identity.human_id !== undefined - ) { - state.lastSpeakerByChannel.set(word.channel, { ...identity }); - } - } -} - -function placeWordInSegment( - word: TWord, - key: SegmentKey, - segments: Segment[], - activeSegments: Map>, - options?: { maxGapMs?: number }, -): void { - const segmentId = segmentKeyId(key); - const existing = activeSegments.get(segmentId); - - if (existing && canExtend(existing, key, word, segments, options)) { - existing.words.push(word); - return; - } - - if (word.isFinal && !hasSpeakerIdentity(key)) { - for (const [id, segment] of activeSegments) { - if ( - !hasSpeakerIdentity(segment.key) && - segment.key.channel === key.channel - ) { - if (canExtend(segment, segment.key, word, segments, options)) { - segment.words.push(word); - activeSegments.set(segmentId, segment); - activeSegments.set(id, segment); - return; - } - } - } - } - - const newSegment: Segment = { key, words: [word] }; - segments.push(newSegment); - activeSegments.set(segmentId, newSegment); -} - -function canExtend( - existingSegment: Segment, - candidateKey: SegmentKey, - word: TWord, - segments: Segment[], - options?: { maxGapMs?: number }, -): boolean { - if (hasSpeakerIdentity(candidateKey)) { - const lastSegment = segments[segments.length - 1]; - if (!lastSegment || !sameKey(lastSegment.key, candidateKey)) { - return false; - } - } - - if (!word.isFinal && existingSegment !== segments[segments.length - 1]) { - const allWordsArePartial = existingSegment.words.every((w) => !w.isFinal); - if (!allWordsArePartial) { - return false; - } - } - - const maxGapMs = options?.maxGapMs ?? 2000; - const lastWord = existingSegment.words[existingSegment.words.length - 1]; - return word.start_ms - lastWord.end_ms <= maxGapMs; -} - -function hasSpeakerIdentity(key: SegmentKey): boolean { - return key.speaker_index !== undefined || key.speaker_human_id !== undefined; -} - -function sameKey(a: SegmentKey, b: SegmentKey): boolean { - return ( - a.channel === b.channel && - a.speaker_index === b.speaker_index && - a.speaker_human_id === b.speaker_human_id - ); -} - -function segmentKeyId(key: SegmentKey): string { - return JSON.stringify([ - key.channel, - key.speaker_index ?? null, - key.speaker_human_id ?? null, - ]); -} - -function propagateCompleteChannelIdentities( - segments: Segment[], - state: SpeakerState, -): void { - state.completeChannels.forEach((channel) => { - const humanId = state.humanIdByChannel.get(channel); - if (!humanId) { - return; - } - - segments.forEach((segment) => { - if ( - segment.key.channel !== channel || - segment.key.speaker_human_id !== undefined - ) { - return; - } - - const params: { - channel: ChannelProfile; - speaker_index?: number; - speaker_human_id: string; - } = { - channel, - speaker_human_id: humanId, - }; - - if (segment.key.speaker_index !== undefined) { - params.speaker_index = segment.key.speaker_index; - } - - segment.key = SegmentKey.make(params); - }); - }); -} - -function mergeAdjacentSegments( - segments: Segment[], -): Segment[] { - if (segments.length <= 1) { - return segments; - } - - const merged: Segment[] = []; - - segments.forEach((segment) => { - const last = merged[merged.length - 1]; - - if ( - last && - sameKey(last.key, segment.key) && - canMergeSegments(last, segment) - ) { - last.words.push(...segment.words); - return; - } - - merged.push(segment); - }); - - return merged; -} - -function canMergeSegments( - seg1: Segment, - seg2: Segment, -): boolean { - if (!hasSpeakerIdentity(seg1.key) && !hasSpeakerIdentity(seg2.key)) { - return false; - } - - return true; -} - -function normalizeWords( - finalWords: readonly TFinal[], - partialWords: readonly TPartial[], -): SegmentWord[] { - const finalNormalized = finalWords.map((word) => ({ - text: word.text, - start_ms: word.start_ms, - end_ms: word.end_ms, - channel: word.channel, - isFinal: true, - ...("id" in word && word.id ? { id: word.id as string } : {}), - })); - - const partialNormalized = partialWords.map((word) => ({ - text: word.text, - start_ms: word.start_ms, - end_ms: word.end_ms, - channel: word.channel, - isFinal: false, - ...("id" in word && word.id ? { id: word.id as string } : {}), - })); - - return [...finalNormalized, ...partialNormalized].sort( - (a, b) => a.start_ms - b.start_ms, - ); -} diff --git a/apps/desktop/src/utils/segment/index.ts b/apps/desktop/src/utils/segment/index.ts new file mode 100644 index 0000000000..a50561ee89 --- /dev/null +++ b/apps/desktop/src/utils/segment/index.ts @@ -0,0 +1,146 @@ +import { segmentationPass } from "./pass-build-segments"; +import { mergeSegmentsPass } from "./pass-merge-segments"; +import { normalizeWordsPass } from "./pass-normalize-words"; +import { identityPropagationPass } from "./pass-propagate-identity"; +import { resolveIdentitiesPass } from "./pass-resolve-speakers"; +import type { + ChannelProfile, + ProtoSegment, + RuntimeSpeakerHint, + Segment, + SegmentBuilderOptions, + SegmentGraph, + SegmentPass, + SegmentPassContext, + SegmentWord, + SpeakerIdentity, + SpeakerState, + WordLike, +} from "./shared"; + +export { + ChannelProfile, + ChannelProfileSchema, + SegmentKey, + type PartialWord, + type RuntimeSpeakerHint, + type Segment, + type SegmentBuilderOptions, + type SegmentWord, + type WordLike, +} from "./shared"; + +export function buildSegments< + TFinal extends WordLike, + TPartial extends WordLike, +>( + finalWords: readonly TFinal[], + partialWords: readonly TPartial[], + speakerHints: readonly RuntimeSpeakerHint[] = [], + options?: SegmentBuilderOptions, +): Segment[] { + if (finalWords.length === 0 && partialWords.length === 0) { + return []; + } + + const context = createSegmentPassContext(speakerHints, options); + const initialGraph: SegmentGraph = { + finalWords, + partialWords, + }; + + const graph = runSegmentPipeline(defaultSegmentPasses, initialGraph, context); + return finalizeSegments(graph.segments ?? []); +} + +const defaultSegmentPasses: readonly SegmentPass[] = [ + normalizeWordsPass, + resolveIdentitiesPass, + segmentationPass, + identityPropagationPass, + mergeSegmentsPass, +]; + +function createSpeakerState( + speakerHints: readonly RuntimeSpeakerHint[], + options?: SegmentBuilderOptions, +): SpeakerState { + const assignmentByWordIndex = new Map(); + const humanIdBySpeakerIndex = new Map(); + const humanIdByChannel = new Map(); + const lastSpeakerByChannel = new Map(); + const completeChannels = new Set(); + completeChannels.add(0); + + if (options?.numSpeakers === 2) { + completeChannels.add(1); + } + + for (const hint of speakerHints) { + const current = assignmentByWordIndex.get(hint.wordIndex) ?? {}; + if (hint.data.type === "provider_speaker_index") { + current.speaker_index = hint.data.speaker_index; + } else { + current.human_id = hint.data.human_id; + } + assignmentByWordIndex.set(hint.wordIndex, { ...current }); + + if (current.speaker_index !== undefined && current.human_id !== undefined) { + humanIdBySpeakerIndex.set(current.speaker_index, current.human_id); + } + } + + return { + assignmentByWordIndex, + humanIdBySpeakerIndex, + humanIdByChannel, + lastSpeakerByChannel, + completeChannels, + }; +} + +function createSegmentPassContext( + speakerHints: readonly RuntimeSpeakerHint[], + options?: SegmentBuilderOptions, +): SegmentPassContext { + const resolvedOptions: SegmentBuilderOptions = options ? { ...options } : {}; + return { + speakerHints, + options: resolvedOptions, + speakerState: createSpeakerState(speakerHints, resolvedOptions), + }; +} + +function ensurePassRequirements(pass: SegmentPass, graph: SegmentGraph) { + if (!pass.needs || pass.needs.length === 0) { + return; + } + + const missing = pass.needs.filter((key) => graph[key] === undefined); + if (missing.length > 0) { + throw new Error( + `Segment pass "${pass.id}" missing required graph keys: ${missing.join(", ")}`, + ); + } +} + +function runSegmentPipeline( + passes: readonly SegmentPass[], + initialGraph: SegmentGraph, + ctx: SegmentPassContext, +): SegmentGraph { + return passes.reduce((graph, pass) => { + ensurePassRequirements(pass, graph); + return pass.run(graph, ctx); + }, initialGraph); +} + +function finalizeSegments(segments: ProtoSegment[]): Segment[] { + return segments.map((segment) => ({ + key: segment.key, + words: segment.words.map(({ word }) => { + const { order: _order, ...rest } = word; + return rest as SegmentWord; + }), + })); +} diff --git a/apps/desktop/src/utils/segment/pass-build-segments.ts b/apps/desktop/src/utils/segment/pass-build-segments.ts new file mode 100644 index 0000000000..9394a43120 --- /dev/null +++ b/apps/desktop/src/utils/segment/pass-build-segments.ts @@ -0,0 +1,138 @@ +import type { + ChannelProfile, + ProtoSegment, + ResolvedWordFrame, + SegmentBuilderOptions, + SegmentKey, + SegmentPass, + SpeakerIdentity, +} from "./shared"; +import { SegmentKey as SegmentKeyModule } from "./shared"; + +export const segmentationPass: SegmentPass = { + id: "build_segments", + needs: ["frames"], + run(graph, ctx) { + const frames = graph.frames ?? []; + const segments: ProtoSegment[] = []; + const activeSegments = new Map(); + + frames.forEach((frame) => { + const key = createSegmentKeyFromIdentity( + frame.word.channel, + frame.identity, + ); + placeFrameInSegment(frame, key, segments, activeSegments, ctx.options); + }); + + return { ...graph, segments }; + }, +}; + +function createSegmentKeyFromIdentity( + channel: ChannelProfile, + identity?: SpeakerIdentity, +): SegmentKey { + const params: { + channel: ChannelProfile; + speaker_index?: number; + speaker_human_id?: string; + } = { channel }; + + if (identity?.speaker_index !== undefined) { + params.speaker_index = identity.speaker_index; + } + + if (identity?.human_id !== undefined) { + params.speaker_human_id = identity.human_id; + } + + return SegmentKeyModule.make(params); +} + +function hasSpeakerIdentity(key: SegmentKey): boolean { + return key.speaker_index !== undefined || key.speaker_human_id !== undefined; +} + +function sameKey(a: SegmentKey, b: SegmentKey): boolean { + return ( + a.channel === b.channel && + a.speaker_index === b.speaker_index && + a.speaker_human_id === b.speaker_human_id + ); +} + +function segmentKeyId(key: SegmentKey): string { + return JSON.stringify([ + key.channel, + key.speaker_index ?? null, + key.speaker_human_id ?? null, + ]); +} + +function canExtendSegment( + existingSegment: ProtoSegment, + candidateKey: SegmentKey, + frame: ResolvedWordFrame, + segments: ProtoSegment[], + options?: SegmentBuilderOptions, +): boolean { + if (hasSpeakerIdentity(candidateKey)) { + const lastSegment = segments[segments.length - 1]; + if (!lastSegment || !sameKey(lastSegment.key, candidateKey)) { + return false; + } + } + + if ( + !frame.word.isFinal && + existingSegment !== segments[segments.length - 1] + ) { + const allWordsArePartial = existingSegment.words.every( + (w) => !w.word.isFinal, + ); + if (!allWordsArePartial) { + return false; + } + } + + const maxGapMs = options?.maxGapMs ?? 2000; + const lastWord = existingSegment.words[existingSegment.words.length - 1].word; + return frame.word.start_ms - lastWord.end_ms <= maxGapMs; +} + +function placeFrameInSegment( + frame: ResolvedWordFrame, + key: SegmentKey, + segments: ProtoSegment[], + activeSegments: Map, + options?: SegmentBuilderOptions, +): void { + const segmentId = segmentKeyId(key); + const existing = activeSegments.get(segmentId); + + if (existing && canExtendSegment(existing, key, frame, segments, options)) { + existing.words.push(frame); + return; + } + + if (frame.word.isFinal && !hasSpeakerIdentity(key)) { + for (const [id, segment] of activeSegments) { + if ( + !hasSpeakerIdentity(segment.key) && + segment.key.channel === key.channel + ) { + if (canExtendSegment(segment, segment.key, frame, segments, options)) { + segment.words.push(frame); + activeSegments.set(segmentId, segment); + activeSegments.set(id, segment); + return; + } + } + } + } + + const newSegment: ProtoSegment = { key, words: [frame] }; + segments.push(newSegment); + activeSegments.set(segmentId, newSegment); +} diff --git a/apps/desktop/src/utils/segment/pass-merge-segments.ts b/apps/desktop/src/utils/segment/pass-merge-segments.ts new file mode 100644 index 0000000000..7f843756ce --- /dev/null +++ b/apps/desktop/src/utils/segment/pass-merge-segments.ts @@ -0,0 +1,58 @@ +import type { ProtoSegment, SegmentKey, SegmentPass } from "./shared"; + +export const mergeSegmentsPass: SegmentPass = { + id: "merge_segments", + needs: ["segments"], + run(graph) { + if (!graph.segments) { + return graph; + } + + return { ...graph, segments: mergeAdjacentSegments(graph.segments) }; + }, +}; + +function hasSpeakerIdentity(key: SegmentKey): boolean { + return key.speaker_index !== undefined || key.speaker_human_id !== undefined; +} + +function sameKey(a: SegmentKey, b: SegmentKey): boolean { + return ( + a.channel === b.channel && + a.speaker_index === b.speaker_index && + a.speaker_human_id === b.speaker_human_id + ); +} + +function canMergeSegments(seg1: ProtoSegment, seg2: ProtoSegment): boolean { + if (!hasSpeakerIdentity(seg1.key) && !hasSpeakerIdentity(seg2.key)) { + return false; + } + + return true; +} + +function mergeAdjacentSegments(segments: ProtoSegment[]): ProtoSegment[] { + if (segments.length <= 1) { + return segments; + } + + const merged: ProtoSegment[] = []; + + segments.forEach((segment) => { + const last = merged[merged.length - 1]; + + if ( + last && + sameKey(last.key, segment.key) && + canMergeSegments(last, segment) + ) { + last.words.push(...segment.words); + return; + } + + merged.push(segment); + }); + + return merged; +} diff --git a/apps/desktop/src/utils/segment/pass-normalize-words.ts b/apps/desktop/src/utils/segment/pass-normalize-words.ts new file mode 100644 index 0000000000..ef3652653f --- /dev/null +++ b/apps/desktop/src/utils/segment/pass-normalize-words.ts @@ -0,0 +1,43 @@ +import type { SegmentPass, SegmentWord, WordLike } from "./shared"; + +export function normalizeWords< + TFinal extends WordLike, + TPartial extends WordLike, +>( + finalWords: readonly TFinal[], + partialWords: readonly TPartial[], +): SegmentWord[] { + const finalNormalized = finalWords.map((word) => ({ + text: word.text, + start_ms: word.start_ms, + end_ms: word.end_ms, + channel: word.channel, + isFinal: true, + ...("id" in word && word.id ? { id: word.id as string } : {}), + })); + + const partialNormalized = partialWords.map((word) => ({ + text: word.text, + start_ms: word.start_ms, + end_ms: word.end_ms, + channel: word.channel, + isFinal: false, + ...("id" in word && word.id ? { id: word.id as string } : {}), + })); + + return [...finalNormalized, ...partialNormalized].sort( + (a, b) => a.start_ms - b.start_ms, + ); +} + +export const normalizeWordsPass: SegmentPass = { + id: "normalize_words", + run(graph) { + const normalized = normalizeWords( + graph.finalWords ?? [], + graph.partialWords ?? [], + ).map((word, order) => ({ ...word, order })); + + return { ...graph, words: normalized }; + }, +}; diff --git a/apps/desktop/src/utils/segment/pass-propagate-identity.ts b/apps/desktop/src/utils/segment/pass-propagate-identity.ts new file mode 100644 index 0000000000..65c66a873b --- /dev/null +++ b/apps/desktop/src/utils/segment/pass-propagate-identity.ts @@ -0,0 +1,61 @@ +import type { + ChannelProfile, + ProtoSegment, + SegmentPass, + SpeakerState, +} from "./shared"; +import { SegmentKey as SegmentKeyModule } from "./shared"; + +export function propagateCompleteChannelIdentities( + segments: ProtoSegment[], + state: SpeakerState, +): void { + state.completeChannels.forEach((channel) => { + const humanId = state.humanIdByChannel.get(channel); + if (!humanId) { + return; + } + + segments.forEach((segment) => { + if ( + segment.key.channel !== channel || + segment.key.speaker_human_id !== undefined + ) { + return; + } + + const params: { + channel: ChannelProfile; + speaker_index?: number; + speaker_human_id: string; + } = { + channel, + speaker_human_id: humanId, + }; + + if (segment.key.speaker_index !== undefined) { + params.speaker_index = segment.key.speaker_index; + } + + segment.key = SegmentKeyModule.make(params); + }); + }); +} + +export const identityPropagationPass: SegmentPass = { + id: "propagate_identity", + needs: ["segments"], + run(graph, ctx) { + if (!graph.segments) { + return graph; + } + + const segments = graph.segments.map((segment) => ({ + ...segment, + words: [...segment.words], + })); + + propagateCompleteChannelIdentities(segments, ctx.speakerState); + return { ...graph, segments }; + }, +}; diff --git a/apps/desktop/src/utils/segment/pass-resolve-speakers.ts b/apps/desktop/src/utils/segment/pass-resolve-speakers.ts new file mode 100644 index 0000000000..01e9c6fc0f --- /dev/null +++ b/apps/desktop/src/utils/segment/pass-resolve-speakers.ts @@ -0,0 +1,130 @@ +import type { + IdentityProvenance, + SegmentPass, + SegmentWord, + SpeakerIdentity, + SpeakerIdentityResolution, + SpeakerState, +} from "./shared"; + +export function resolveSpeakerIdentity( + word: SegmentWord, + assignment: SpeakerIdentity | undefined, + state: SpeakerState, +): SpeakerIdentityResolution { + const provenance: IdentityProvenance[] = []; + const identity: SpeakerIdentity = {}; + + if (assignment) { + if (assignment.speaker_index !== undefined) { + identity.speaker_index = assignment.speaker_index; + } + if (assignment.human_id !== undefined) { + identity.human_id = assignment.human_id; + } + provenance.push("explicit_assignment"); + } + + if (identity.speaker_index !== undefined && identity.human_id === undefined) { + const humanId = state.humanIdBySpeakerIndex.get(identity.speaker_index); + if (humanId !== undefined) { + identity.human_id = humanId; + provenance.push("speaker_index_lookup"); + } + } + + if ( + identity.human_id === undefined && + state.completeChannels.has(word.channel) + ) { + const channelHumanId = state.humanIdByChannel.get(word.channel); + if (channelHumanId !== undefined) { + identity.human_id = channelHumanId; + provenance.push("channel_completion"); + } + } + + if ( + !word.isFinal && + (identity.speaker_index === undefined || identity.human_id === undefined) + ) { + const last = state.lastSpeakerByChannel.get(word.channel); + if (last) { + if ( + identity.speaker_index === undefined && + last.speaker_index !== undefined + ) { + identity.speaker_index = last.speaker_index; + provenance.push("last_speaker"); + } + if (identity.human_id === undefined && last.human_id !== undefined) { + identity.human_id = last.human_id; + provenance.push("last_speaker"); + } + } + } + + return { identity, provenance }; +} + +export function rememberIdentity( + word: SegmentWord, + assignment: SpeakerIdentity | undefined, + identity: SpeakerIdentity, + state: SpeakerState, +): void { + const hasExplicitAssignment = + assignment !== undefined && + (assignment.speaker_index !== undefined || + assignment.human_id !== undefined); + + if (identity.speaker_index !== undefined && identity.human_id !== undefined) { + state.humanIdBySpeakerIndex.set(identity.speaker_index, identity.human_id); + } + + if ( + state.completeChannels.has(word.channel) && + identity.human_id !== undefined && + identity.speaker_index === undefined + ) { + state.humanIdByChannel.set(word.channel, identity.human_id); + } + + if ( + !word.isFinal || + identity.speaker_index !== undefined || + hasExplicitAssignment + ) { + if ( + identity.speaker_index !== undefined || + identity.human_id !== undefined + ) { + state.lastSpeakerByChannel.set(word.channel, { ...identity }); + } + } +} + +export const resolveIdentitiesPass: SegmentPass = { + id: "resolve_speakers", + needs: ["words"], + run(graph, ctx) { + const words = graph.words ?? []; + const frames = words.map((word, index) => { + const assignment = ctx.speakerState.assignmentByWordIndex.get(index); + const resolution = resolveSpeakerIdentity( + word, + assignment, + ctx.speakerState, + ); + rememberIdentity(word, assignment, resolution.identity, ctx.speakerState); + + return { + word, + identity: resolution.identity, + provenance: resolution.provenance, + }; + }); + + return { ...graph, frames }; + }, +}; diff --git a/apps/desktop/src/utils/segment.test.ts b/apps/desktop/src/utils/segment/segment.test.ts similarity index 99% rename from apps/desktop/src/utils/segment.test.ts rename to apps/desktop/src/utils/segment/segment.test.ts index eb9f5f2cfc..b53556b39d 100644 --- a/apps/desktop/src/utils/segment.test.ts +++ b/apps/desktop/src/utils/segment/segment.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { buildSegments, SegmentKey, WordLike } from "./segment"; +import { buildSegments, SegmentKey, type WordLike } from "."; describe("buildSegments", () => { const testCases = [ diff --git a/apps/desktop/src/utils/segment/shared.ts b/apps/desktop/src/utils/segment/shared.ts new file mode 100644 index 0000000000..8e162a9e51 --- /dev/null +++ b/apps/desktop/src/utils/segment/shared.ts @@ -0,0 +1,123 @@ +import { Data, Schema } from "effect"; + +export enum ChannelProfile { + DirectMic = 0, + RemoteParty = 1, + MixedCapture = 2, +} + +export const ChannelProfileSchema = Schema.Enums(ChannelProfile); + +export type WordLike = { + text: string; + start_ms: number; + end_ms: number; + channel: ChannelProfile; +}; + +export type PartialWord = WordLike; + +export type SegmentWord = WordLike & { isFinal: boolean; id?: string }; + +type SpeakerHintData = + | { + type: "provider_speaker_index"; + speaker_index: number; + provider?: string; + channel?: number; + } + | { type: "user_speaker_assignment"; human_id: string }; + +export type RuntimeSpeakerHint = { + wordIndex: number; + data: SpeakerHintData; +}; + +export type Segment = { + key: SegmentKey; + words: TWord[]; +}; + +export type SegmentKey = { + readonly channel: ChannelProfile; + readonly speaker_index?: number; + readonly speaker_human_id?: string; +}; + +export const SegmentKey = { + make: ( + params: { channel: ChannelProfile } & Partial<{ + speaker_index: number; + speaker_human_id: string; + }>, + ): SegmentKey => Data.struct(params), +}; + +export type SegmentBuilderOptions = { + maxGapMs?: number; + numSpeakers?: number; +}; + +export type StageId = + | "normalize_words" + | "resolve_speakers" + | "build_segments" + | "propagate_identity" + | "merge_segments"; + +export type SpeakerIdentity = { + speaker_index?: number; + human_id?: string; +}; + +export type IdentityProvenance = + | "explicit_assignment" + | "speaker_index_lookup" + | "channel_completion" + | "last_speaker"; + +export type NormalizedWord = SegmentWord & { order: number }; + +export type ResolvedWordFrame = { + word: NormalizedWord; + identity?: SpeakerIdentity; + provenance: IdentityProvenance[]; +}; + +export type SpeakerIdentityResolution = { + identity: SpeakerIdentity; + provenance: IdentityProvenance[]; +}; + +export type ProtoSegment = { + key: SegmentKey; + words: ResolvedWordFrame[]; +}; + +export type SegmentGraph = { + finalWords?: readonly WordLike[]; + partialWords?: readonly WordLike[]; + words?: NormalizedWord[]; + frames?: ResolvedWordFrame[]; + segments?: ProtoSegment[]; +}; + +export type SegmentPass = { + id: StageId; + needs?: (keyof SegmentGraph)[]; + run: (graph: SegmentGraph, ctx: SegmentPassContext) => SegmentGraph; +}; + +export type SegmentPassContext = { + speakerHints: readonly RuntimeSpeakerHint[]; + options: SegmentBuilderOptions; + speakerState: SpeakerState; +}; + +export type SpeakerState = { + assignmentByWordIndex: Map; + humanIdBySpeakerIndex: Map; + humanIdByChannel: Map; + lastSpeakerByChannel: Map; + completeChannels: Set; +}; From 9546e1bbc0ef3f6dec731fa3633325f8b616a221 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 14 Nov 2025 14:05:59 +0900 Subject: [PATCH 2/7] wip --- .../src/components/settings/ai/llm/banner.tsx | 72 --------- .../src/components/settings/ai/llm/health.tsx | 150 ++++++++++++++++++ .../src/components/settings/ai/llm/index.tsx | 4 +- .../src/components/settings/ai/llm/select.tsx | 103 +----------- .../components/settings/ai/shared/health.tsx | 62 ++++++++ .../components/settings/ai/shared/index.tsx | 17 -- .../settings/ai/shared/list-common.ts | 1 + .../ai/stt/{banner.tsx => health.tsx} | 89 ++++++++++- .../src/components/settings/ai/stt/index.tsx | 4 +- .../src/components/settings/ai/stt/select.tsx | 116 +------------- 10 files changed, 311 insertions(+), 307 deletions(-) delete mode 100644 apps/desktop/src/components/settings/ai/llm/banner.tsx create mode 100644 apps/desktop/src/components/settings/ai/llm/health.tsx create mode 100644 apps/desktop/src/components/settings/ai/shared/health.tsx rename apps/desktop/src/components/settings/ai/stt/{banner.tsx => health.tsx} (54%) diff --git a/apps/desktop/src/components/settings/ai/llm/banner.tsx b/apps/desktop/src/components/settings/ai/llm/banner.tsx deleted file mode 100644 index 36c067d4fa..0000000000 --- a/apps/desktop/src/components/settings/ai/llm/banner.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useMemo } from "react"; - -import { useAuth } from "../../../../auth"; -import { useConfigValues } from "../../../../config/use-config"; -import * as main from "../../../../store/tinybase/main"; -import { Banner } from "../shared"; -import { PROVIDERS } from "./shared"; - -export function BannerForLLM() { - const { hasModel, message } = useHasLLMModel(); - - if (hasModel) { - return null; - } - - return ; -} - -function useHasLLMModel(): { hasModel: boolean; message: string } { - const auth = useAuth(); - const { current_llm_provider, current_llm_model } = useConfigValues([ - "current_llm_provider", - "current_llm_model", - ] as const); - const configuredProviders = main.UI.useResultTable( - main.QUERIES.llmProviders, - main.STORE_ID, - ); - - const result = useMemo(() => { - if (!current_llm_provider || !current_llm_model) { - return { hasModel: false, message: "Please select a provider and model" }; - } - - const providerId = current_llm_provider as string; - - const provider = PROVIDERS.find((p) => p.id === providerId); - if (!provider) { - return { hasModel: false, message: "Selected provider not found" }; - } - - if (providerId === "hyprnote") { - if (!auth?.session) { - return { - hasModel: false, - message: "Please sign in to use Hyprnote LLM", - }; - } - return { hasModel: true, message: "" }; - } - - const config = configuredProviders[providerId]; - if (!config || !config.base_url) { - return { - hasModel: false, - message: - "Provider not configured. Please configure the provider below.", - }; - } - - if (provider.apiKey && !config.api_key) { - return { - hasModel: false, - message: "API key required. Please add your API key below.", - }; - } - - return { hasModel: true, message: "" }; - }, [current_llm_provider, current_llm_model, configuredProviders, auth]); - - return result; -} diff --git a/apps/desktop/src/components/settings/ai/llm/health.tsx b/apps/desktop/src/components/settings/ai/llm/health.tsx new file mode 100644 index 0000000000..3160232060 --- /dev/null +++ b/apps/desktop/src/components/settings/ai/llm/health.tsx @@ -0,0 +1,150 @@ +import { useQuery } from "@tanstack/react-query"; +import { generateText } from "ai"; +import { useEffect, useMemo } from "react"; + +import { useAuth } from "../../../../auth"; +import { useConfigValues } from "../../../../config/use-config"; +import { useLanguageModel } from "../../../../hooks/useLLMConnection"; +import * as main from "../../../../store/tinybase/main"; +import { AvailabilityHealth, ConnectionHealth } from "../shared/health"; +import { PROVIDERS } from "./shared"; + +export function HealthCheckForConnection() { + const health = useConnectionHealth(); + + const { status, tooltip } = useMemo(() => { + if (!health) { + return { + status: null, + tooltip: "No local model selected", + }; + } + + if (health === "pending") { + return { + status: "loading", + tooltip: "Checking LLM connection...", + }; + } + + if (health === "error") { + return { + status: "error", + tooltip: "LLM connection failed. Please check your configuration.", + }; + } + + if (health === "success") { + return { + status: "success", + tooltip: "LLM connection ready", + }; + } + + return { + status: "error", + tooltip: "Connection not available", + }; + }, [health]) satisfies Parameters[0]; + + return ; +} + +function useConnectionHealth() { + const model = useLanguageModel(); + + useEffect(() => { + if (model) { + text.refetch(); + } + }, [model]); + + if (!model) { + return null; + } + + const text = useQuery({ + enabled: !!model, + queryKey: ["llm-health-check", model], + staleTime: 0, + retry: 5, + retryDelay: 200, + queryFn: () => + generateText({ + model: model!, + system: "If user says hi, respond with hello, without any other text.", + prompt: "Hi", + maxOutputTokens: 1, + }), + }); + + return text.status; +} + +export function HealthCheckForAvailability() { + const { hasModel, message } = useLLMModelAvailability(); + + if (hasModel) { + return null; + } + + return ; +} + +function useLLMModelAvailability(): { + hasModel: boolean; + message: string; +} { + const auth = useAuth(); + const { current_llm_provider, current_llm_model } = useConfigValues([ + "current_llm_provider", + "current_llm_model", + ] as const); + const configuredProviders = main.UI.useResultTable( + main.QUERIES.llmProviders, + main.STORE_ID, + ); + + const result = useMemo(() => { + if (!current_llm_provider || !current_llm_model) { + return { hasModel: false, message: "Please select a provider and model" }; + } + + const providerId = current_llm_provider as string; + + const provider = PROVIDERS.find((p) => p.id === providerId); + if (!provider) { + return { hasModel: false, message: "Selected provider not found" }; + } + + if (providerId === "hyprnote") { + if (!auth?.session) { + return { + hasModel: false, + message: "Please sign in to use Hyprnote LLM", + }; + } + return { hasModel: true, message: "" }; + } + + const config = configuredProviders[providerId]; + if (!config || !config.base_url) { + return { + hasModel: false, + message: + "Provider not configured. Please configure the provider below.", + }; + } + + if (provider.apiKey && !config.api_key) { + return { + hasModel: false, + message: "API key required. Please add your API key below.", + }; + } + + return { hasModel: true, message: "" }; + }, [current_llm_provider, current_llm_model, configuredProviders, auth]); + + return result; +} diff --git a/apps/desktop/src/components/settings/ai/llm/index.tsx b/apps/desktop/src/components/settings/ai/llm/index.tsx index 688e850125..7b166c3d82 100644 --- a/apps/desktop/src/components/settings/ai/llm/index.tsx +++ b/apps/desktop/src/components/settings/ai/llm/index.tsx @@ -1,11 +1,11 @@ -import { BannerForLLM } from "./banner"; import { ConfigureProviders } from "./configure"; +import { HealthCheckForAvailability } from "./health"; import { SelectProviderAndModel } from "./select"; export function LLM() { return (
- +
diff --git a/apps/desktop/src/components/settings/ai/llm/select.tsx b/apps/desktop/src/components/settings/ai/llm/select.tsx index d17915ed6f..44949f6add 100644 --- a/apps/desktop/src/components/settings/ai/llm/select.tsx +++ b/apps/desktop/src/components/settings/ai/llm/select.tsx @@ -1,6 +1,4 @@ import { useForm } from "@tanstack/react-form"; -import { useQuery } from "@tanstack/react-query"; -import { generateText } from "ai"; import { useMemo } from "react"; import { @@ -10,16 +8,10 @@ import { SelectTrigger, SelectValue, } from "@hypr/ui/components/ui/select"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@hypr/ui/components/ui/tooltip"; import { cn } from "@hypr/utils"; import { useAuth } from "../../../../auth"; import { useConfigValues } from "../../../../config/use-config"; -import { useLanguageModel } from "../../../../hooks/useLLMConnection"; import * as main from "../../../../store/tinybase/main"; import type { ListModelsResult } from "../shared/list-common"; import { listLMStudioModels } from "../shared/list-lmstudio"; @@ -31,6 +23,7 @@ import { } from "../shared/list-openai"; import { listOpenRouterModels } from "../shared/list-openrouter"; import { ModelCombobox } from "../shared/model-combobox"; +import { HealthCheckForConnection } from "./health"; import { PROVIDERS } from "./shared"; export function SelectProviderAndModel() { @@ -153,8 +146,11 @@ export function SelectProviderAndModel() { ); }} + + {current_llm_provider && current_llm_model && ( + + )} - {current_llm_provider && current_llm_model && } ); @@ -227,92 +223,3 @@ function useConfiguredMapping(): Record< return mapping; } - -function HealthCheck() { - const model = useLanguageModel(); - - const text = useQuery({ - enabled: !!model, - queryKey: ["model-health-check", model], - queryFn: () => - generateText({ - model: model!, - system: "If user says hi, respond with hello, without any other text.", - prompt: "Hi", - }), - }); - - const { status, message, textColor } = (() => { - if (!model) { - return { - status: "No model configured", - message: "Please configure a provider and model", - textColor: "text-red-600", - }; - } - - if (text.isPending) { - return { - status: "Checking connection", - message: "Testing model connection", - textColor: "text-yellow-600", - }; - } - - if (text.isError) { - return { - status: "Connection failed", - message: text.error?.message || "Unable to connect to the model", - textColor: "text-red-600", - }; - } - - if (text.isSuccess) { - return { - status: "Connected!", - message: "Model is ready to use", - textColor: "text-green-600", - }; - } - - return { - status: "Unknown status", - message: "Connection status unknown", - textColor: "text-red-600", - }; - })(); - - const isLoading = text.isPending; - - return ( -
- - - - {status} - {isLoading && ( - - . - - . - - - . - - - )} - - - -

{message}

-
-
-
- ); -} diff --git a/apps/desktop/src/components/settings/ai/shared/health.tsx b/apps/desktop/src/components/settings/ai/shared/health.tsx new file mode 100644 index 0000000000..1763e62257 --- /dev/null +++ b/apps/desktop/src/components/settings/ai/shared/health.tsx @@ -0,0 +1,62 @@ +import { AlertCircleIcon, CheckCircleIcon, Loader2Icon } from "lucide-react"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@hypr/ui/components/ui/tooltip"; +import { cn } from "@hypr/utils"; + +export function ConnectionHealth({ + status, + tooltip, +}: { + status?: "loading" | "error" | "success" | null; + tooltip: string; +}) { + if (!status) { + return null; + } + + const color = + status === "loading" + ? "text-yellow-500" + : status === "error" + ? "text-red-500" + : "text-green-500"; + + return ( + + +
+ {status === "loading" ? ( + + ) : status === "error" ? ( + + ) : status === "success" ? ( + + ) : null} +
+
+ +

{tooltip}

+
+
+ ); +} + +export function AvailabilityHealth({ message }: { message: string }) { + return ( +
+ + {message} +
+ ); +} diff --git a/apps/desktop/src/components/settings/ai/shared/index.tsx b/apps/desktop/src/components/settings/ai/shared/index.tsx index ed84d8a53f..773eab7afa 100644 --- a/apps/desktop/src/components/settings/ai/shared/index.tsx +++ b/apps/desktop/src/components/settings/ai/shared/index.tsx @@ -1,6 +1,5 @@ import { Icon } from "@iconify-icon/react"; import { type AnyFieldApi } from "@tanstack/react-form"; -import { AlertCircleIcon } from "lucide-react"; import { Streamdown } from "streamdown"; import { @@ -121,19 +120,3 @@ export function FormField({ ); } - -export function Banner({ message }: { message: string }) { - return ( -
- - {message} -
- ); -} diff --git a/apps/desktop/src/components/settings/ai/shared/list-common.ts b/apps/desktop/src/components/settings/ai/shared/list-common.ts index 08120128c9..77377684cc 100644 --- a/apps/desktop/src/components/settings/ai/shared/list-common.ts +++ b/apps/desktop/src/components/settings/ai/shared/list-common.ts @@ -20,6 +20,7 @@ export const REQUEST_TIMEOUT = "5 seconds"; export const commonIgnoreKeywords = [ "embed", + "sora", "tts", "whisper", "dall-e", diff --git a/apps/desktop/src/components/settings/ai/stt/banner.tsx b/apps/desktop/src/components/settings/ai/stt/health.tsx similarity index 54% rename from apps/desktop/src/components/settings/ai/stt/banner.tsx rename to apps/desktop/src/components/settings/ai/stt/health.tsx index 9b6acddb7c..ce0c725a38 100644 --- a/apps/desktop/src/components/settings/ai/stt/banner.tsx +++ b/apps/desktop/src/components/settings/ai/stt/health.tsx @@ -1,21 +1,100 @@ import { useQueries } from "@tanstack/react-query"; +import { useMemo } from "react"; import { useConfigValues } from "../../../../config/use-config"; +import { useSTTConnection } from "../../../../hooks/useSTTConnection"; import * as main from "../../../../store/tinybase/main"; -import { Banner } from "../shared"; +import { AvailabilityHealth, ConnectionHealth } from "../shared/health"; import { type ProviderId, PROVIDERS, sttModelQueries } from "./shared"; -export function BannerForSTT() { - const { hasModel, message } = useHasSTTModel(); +export function HealthCheckForConnection() { + const health = useConnectionHealth(); + + const { status, tooltip } = useMemo(() => { + if (!health) { + return { + status: null, + tooltip: "No local model selected", + }; + } + + if (health === "no-connection") { + return { + status: "error", + tooltip: "No STT connection. Please configure a provider and model.", + }; + } + + if (health === "server-not-ready") { + return { + status: "error", + tooltip: "Local server not ready. Click to restart.", + }; + } + + if (health === "connected") { + return { + status: "success", + tooltip: "STT connection ready", + }; + } + + return { + status: "error", + tooltip: "Connection not available", + }; + }, [health]) satisfies Parameters[0]; + + return ; +} + +function useConnectionHealth() { + const configs = useConfigValues([ + "current_stt_provider", + "current_stt_model", + ] as const); + const current_stt_provider = configs.current_stt_provider as + | string + | undefined; + const current_stt_model = configs.current_stt_model as string | undefined; + + const conn = useSTTConnection(); + + const isLocalModel = + current_stt_provider === "hyprnote" && + current_stt_model && + (current_stt_model.startsWith("am-") || + current_stt_model.startsWith("Quantized")); + + if (!isLocalModel) { + return null; + } + + if (!conn) { + return "no-connection"; + } + + if (!conn.baseUrl) { + return "server-not-ready"; + } + + return "connected"; +} + +export function HealthCheckForAvailability() { + const { hasModel, message } = useSTTModelAvailability(); if (hasModel) { return null; } - return ; + return ; } -function useHasSTTModel(): { hasModel: boolean; message: string } { +function useSTTModelAvailability(): { + hasModel: boolean; + message: string; +} { const { current_stt_provider, current_stt_model } = useConfigValues([ "current_stt_provider", "current_stt_model", diff --git a/apps/desktop/src/components/settings/ai/stt/index.tsx b/apps/desktop/src/components/settings/ai/stt/index.tsx index b39d1b24f5..837e8e2ac2 100644 --- a/apps/desktop/src/components/settings/ai/stt/index.tsx +++ b/apps/desktop/src/components/settings/ai/stt/index.tsx @@ -1,11 +1,11 @@ -import { BannerForSTT } from "./banner"; import { ConfigureProviders } from "./configure"; +import { HealthCheckForAvailability } from "./health"; import { SelectProviderAndModel } from "./select"; export function STT() { return (
- +
diff --git a/apps/desktop/src/components/settings/ai/stt/select.tsx b/apps/desktop/src/components/settings/ai/stt/select.tsx index caeea16a2a..ed530cc7f4 100644 --- a/apps/desktop/src/components/settings/ai/stt/select.tsx +++ b/apps/desktop/src/components/settings/ai/stt/select.tsx @@ -1,13 +1,6 @@ import { useForm } from "@tanstack/react-form"; import { useQueries } from "@tanstack/react-query"; -import { RefreshCwIcon } from "lucide-react"; -import { useCallback } from "react"; -import { - commands as localSttCommands, - type SupportedSttModel, -} from "@hypr/plugin-local-stt"; -import { Button } from "@hypr/ui/components/ui/button"; import { Input } from "@hypr/ui/components/ui/input"; import { Select, @@ -16,16 +9,11 @@ import { SelectTrigger, SelectValue, } from "@hypr/ui/components/ui/select"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@hypr/ui/components/ui/tooltip"; import { cn } from "@hypr/utils"; import { useConfigValues } from "../../../../config/use-config"; -import { useSTTConnection } from "../../../../hooks/useSTTConnection"; import * as main from "../../../../store/tinybase/main"; +import { HealthCheckForConnection } from "./health"; import { displayModelId, type ProviderId, @@ -190,8 +178,11 @@ export function SelectProviderAndModel() { ); }} + + {current_stt_provider && current_stt_model && ( + + )} - {current_stt_provider && current_stt_model && } ); @@ -266,100 +257,3 @@ function useConfiguredMapping(): Record< } >; } - -function HealthCheck() { - const configs = useConfigValues([ - "current_stt_provider", - "current_stt_model", - "spoken_languages", - ] as const); - const current_stt_provider = configs.current_stt_provider as - | string - | undefined; - const current_stt_model = configs.current_stt_model as string | undefined; - - const experimental_handleServer = useCallback(() => { - if ( - current_stt_provider === "hyprnote" && - current_stt_model && - (current_stt_model.startsWith("am-") || - current_stt_model.startsWith("Quantized")) - ) { - localSttCommands - .stopServer(null) - .then(() => new Promise((resolve) => setTimeout(resolve, 500))) - .then(() => - localSttCommands.startServer(current_stt_model as SupportedSttModel), - ) - .then(console.log) - .catch(console.error); - } - }, [current_stt_provider, current_stt_model]); - - const conn = useSTTConnection(); - - const isLocalModel = - current_stt_provider === "hyprnote" && - current_stt_model && - (current_stt_model.startsWith("am-") || - current_stt_model.startsWith("Quantized")); - - if (!isLocalModel) { - return null; - } - - const hasServerIssue = !conn?.baseUrl; - - const { status, message, textColor } = (() => { - if (!conn) { - return { - status: "No STT connection. Please configure a provider and model.", - message: "No STT connection. Please configure a provider and model.", - textColor: "text-red-600", - }; - } - - if (hasServerIssue) { - return { - status: "Local server not ready. Click to restart.", - message: "Local server not ready. Click to restart.", - textColor: "text-red-600", - }; - } - - if (conn.baseUrl) { - return { - status: "Connected!", - message: "STT connection ready", - textColor: "text-green-600", - }; - } - - return { - status: "Connection not available", - message: "Connection not available", - textColor: "text-red-600", - }; - })(); - - return ( -
- - - - {status} - - - -

{message}

-
-
- {hasServerIssue && ( - - )} -
- ); -} From 3f6c6d4526235a634edea1c28c5c9c3bce78839e Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 14 Nov 2025 14:49:06 +0900 Subject: [PATCH 3/7] wip --- .../components/main/body/sessions/shared.tsx | 2 +- .../src/components/settings/ai/llm/health.tsx | 81 ++++--------- .../components/settings/ai/shared/health.tsx | 32 ++--- .../src/components/settings/ai/stt/health.tsx | 111 ++++++------------ apps/desktop/src/hooks/useRunBatch.ts | 2 +- apps/desktop/src/hooks/useSTTConnection.ts | 87 +++++++++++--- apps/desktop/src/hooks/useStartListening.ts | 2 +- 7 files changed, 154 insertions(+), 163 deletions(-) diff --git a/apps/desktop/src/components/main/body/sessions/shared.tsx b/apps/desktop/src/components/main/body/sessions/shared.tsx index b313c52177..c221d32a73 100644 --- a/apps/desktop/src/components/main/body/sessions/shared.tsx +++ b/apps/desktop/src/components/main/body/sessions/shared.tsx @@ -65,7 +65,7 @@ export function useListenButtonState(sessionId: string) { const taskId = createTaskId(sessionId, "enhance"); const { status } = useAITaskTask(taskId, "enhance"); const generating = status === "generating"; - const sttConnection = useSTTConnection(); + const { conn: sttConnection } = useSTTConnection(); const shouldRender = !active && !generating; const isDisabled = !sttConnection || batching; diff --git a/apps/desktop/src/components/settings/ai/llm/health.tsx b/apps/desktop/src/components/settings/ai/llm/health.tsx index 3160232060..6c0abb4db5 100644 --- a/apps/desktop/src/components/settings/ai/llm/health.tsx +++ b/apps/desktop/src/components/settings/ai/llm/health.tsx @@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query"; import { generateText } from "ai"; import { useEffect, useMemo } from "react"; -import { useAuth } from "../../../../auth"; import { useConfigValues } from "../../../../config/use-config"; import { useLanguageModel } from "../../../../hooks/useLLMConnection"; import * as main from "../../../../store/tinybase/main"; @@ -12,42 +11,32 @@ import { PROVIDERS } from "./shared"; export function HealthCheckForConnection() { const health = useConnectionHealth(); - const { status, tooltip } = useMemo(() => { - if (!health) { - return { - status: null, - tooltip: "No local model selected", - }; - } - + const props = useMemo(() => { if (health === "pending") { return { - status: "loading", - tooltip: "Checking LLM connection...", + status: "pending", + tooltip: "Checking connection...", }; } if (health === "error") { return { status: "error", - tooltip: "LLM connection failed. Please check your configuration.", + tooltip: "Connection failed.", }; } if (health === "success") { return { status: "success", - tooltip: "LLM connection ready", + tooltip: "Connection ready", }; } - return { - status: "error", - tooltip: "Connection not available", - }; + return { status: null }; }, [health]) satisfies Parameters[0]; - return ; + return ; } function useConnectionHealth() { @@ -82,24 +71,21 @@ function useConnectionHealth() { } export function HealthCheckForAvailability() { - const { hasModel, message } = useLLMModelAvailability(); + const result = useAvailability(); - if (hasModel) { + if (result.available) { return null; } - return ; + return ; } -function useLLMModelAvailability(): { - hasModel: boolean; - message: string; -} { - const auth = useAuth(); +function useAvailability() { const { current_llm_provider, current_llm_model } = useConfigValues([ "current_llm_provider", "current_llm_model", ] as const); + const configuredProviders = main.UI.useResultTable( main.QUERIES.llmProviders, main.STORE_ID, @@ -107,44 +93,29 @@ function useLLMModelAvailability(): { const result = useMemo(() => { if (!current_llm_provider || !current_llm_model) { - return { hasModel: false, message: "Please select a provider and model" }; - } - - const providerId = current_llm_provider as string; - - const provider = PROVIDERS.find((p) => p.id === providerId); - if (!provider) { - return { hasModel: false, message: "Selected provider not found" }; - } - - if (providerId === "hyprnote") { - if (!auth?.session) { - return { - hasModel: false, - message: "Please sign in to use Hyprnote LLM", - }; - } - return { hasModel: true, message: "" }; + return { + available: false, + message: "Please select a provider and model.", + }; } - const config = configuredProviders[providerId]; - if (!config || !config.base_url) { + if (PROVIDERS.find((p) => p.id === current_llm_provider)) { return { - hasModel: false, - message: - "Provider not configured. Please configure the provider below.", + available: false, + message: "Provider not found. Please select a valid provider.", }; } - if (provider.apiKey && !config.api_key) { + if (configuredProviders[current_llm_provider]?.base_url) { return { - hasModel: false, - message: "API key required. Please add your API key below.", + available: false, + message: + "Provider not configured. Please configure the provider below.", }; } - return { hasModel: true, message: "" }; - }, [current_llm_provider, current_llm_model, configuredProviders, auth]); + return { available: true }; + }, [current_llm_provider, current_llm_model, configuredProviders]); - return result; + return result as { available: true } | { available: false; message: string }; } diff --git a/apps/desktop/src/components/settings/ai/shared/health.tsx b/apps/desktop/src/components/settings/ai/shared/health.tsx index 1763e62257..4e4371803b 100644 --- a/apps/desktop/src/components/settings/ai/shared/health.tsx +++ b/apps/desktop/src/components/settings/ai/shared/health.tsx @@ -7,21 +7,19 @@ import { } from "@hypr/ui/components/ui/tooltip"; import { cn } from "@hypr/utils"; -export function ConnectionHealth({ - status, - tooltip, -}: { - status?: "loading" | "error" | "success" | null; - tooltip: string; -}) { - if (!status) { +type Props = + | { status?: "success" | null } + | { status: "pending" | "error"; tooltip: string }; + +export function ConnectionHealth(props: Props) { + if (!props.status) { return null; } const color = - status === "loading" + props.status === "pending" ? "text-yellow-500" - : status === "error" + : props.status === "error" ? "text-red-500" : "text-green-500"; @@ -29,17 +27,23 @@ export function ConnectionHealth({
- {status === "loading" ? ( + {props.status === "pending" ? ( - ) : status === "error" ? ( + ) : props.status === "error" ? ( - ) : status === "success" ? ( + ) : props.status === "success" ? ( ) : null}
-

{tooltip}

+

+ {props.status === "success" + ? "Connection ready" + : "tooltip" in props + ? props.tooltip + : null} +

); diff --git a/apps/desktop/src/components/settings/ai/stt/health.tsx b/apps/desktop/src/components/settings/ai/stt/health.tsx index ce0c725a38..d2b1b43d41 100644 --- a/apps/desktop/src/components/settings/ai/stt/health.tsx +++ b/apps/desktop/src/components/settings/ai/stt/health.tsx @@ -1,5 +1,4 @@ import { useQueries } from "@tanstack/react-query"; -import { useMemo } from "react"; import { useConfigValues } from "../../../../config/use-config"; import { useSTTConnection } from "../../../../hooks/useSTTConnection"; @@ -8,93 +7,51 @@ import { AvailabilityHealth, ConnectionHealth } from "../shared/health"; import { type ProviderId, PROVIDERS, sttModelQueries } from "./shared"; export function HealthCheckForConnection() { - const health = useConnectionHealth(); - - const { status, tooltip } = useMemo(() => { - if (!health) { - return { - status: null, - tooltip: "No local model selected", - }; - } - - if (health === "no-connection") { - return { - status: "error", - tooltip: "No STT connection. Please configure a provider and model.", - }; - } + const props = useConnectionHealth(); + return ; +} - if (health === "server-not-ready") { - return { - status: "error", - tooltip: "Local server not ready. Click to restart.", - }; - } +function useConnectionHealth(): Parameters[0] { + const { conn, local } = useSTTConnection(); - if (health === "connected") { - return { - status: "success", - tooltip: "STT connection ready", - }; - } + if (!local) { + return { status: "success" }; + } + if (local.isPending || local.fetchStatus === "fetching") { return { - status: "error", - tooltip: "Connection not available", + status: "pending", + tooltip: "Checking local STT server…", }; - }, [health]) satisfies Parameters[0]; - - return ; -} - -function useConnectionHealth() { - const configs = useConfigValues([ - "current_stt_provider", - "current_stt_model", - ] as const); - const current_stt_provider = configs.current_stt_provider as - | string - | undefined; - const current_stt_model = configs.current_stt_model as string | undefined; - - const conn = useSTTConnection(); - - const isLocalModel = - current_stt_provider === "hyprnote" && - current_stt_model && - (current_stt_model.startsWith("am-") || - current_stt_model.startsWith("Quantized")); - - if (!isLocalModel) { - return null; } - if (!conn) { - return "no-connection"; + if (conn) { + return { + status: "success" as const, + }; } - if (!conn.baseUrl) { - return "server-not-ready"; - } + const serverStatus = local.snapshot?.serverStatus ?? "unavailable"; - return "connected"; + return { + status: "error", + tooltip: `Local server status: ${serverStatus}. Click to restart.`, + }; } export function HealthCheckForAvailability() { - const { hasModel, message } = useSTTModelAvailability(); + const result = useAvailability(); - if (hasModel) { + if (result.available) { return null; } - return ; + return ; } -function useSTTModelAvailability(): { - hasModel: boolean; - message: string; -} { +function useAvailability(): + | { available: true } + | { available: false; message: string } { const { current_stt_provider, current_stt_model } = useConfigValues([ "current_stt_provider", "current_stt_model", @@ -115,14 +72,14 @@ function useSTTModelAvailability(): { }); if (!current_stt_provider || !current_stt_model) { - return { hasModel: false, message: "Please select a provider and model" }; + return { available: false, message: "Please select a provider and model." }; } const providerId = current_stt_provider as ProviderId; const provider = PROVIDERS.find((p) => p.id === providerId); if (!provider) { - return { hasModel: false, message: "Selected provider not found" }; + return { available: false, message: "Selected provider not found." }; } if (providerId === "hyprnote") { @@ -138,12 +95,12 @@ function useSTTModelAvailability(): { ); if (!hasAvailableModel) { return { - hasModel: false, + available: false, message: "No Hyprnote models downloaded. Please download a model below.", }; } - return { hasModel: true, message: "" }; + return { available: true }; } const config = configuredProviders[providerId] as @@ -151,22 +108,22 @@ function useSTTModelAvailability(): { | undefined; if (!config) { return { - hasModel: false, + available: false, message: "Provider not configured. Please configure the provider below.", }; } if (providerId === "custom") { - return { hasModel: true, message: "" }; + return { available: true }; } const hasModels = provider.models && provider.models.length > 0; if (!hasModels) { return { - hasModel: false, + available: false, message: "No models available for this provider", }; } - return { hasModel: true, message: "" }; + return { available: true }; } diff --git a/apps/desktop/src/hooks/useRunBatch.ts b/apps/desktop/src/hooks/useRunBatch.ts index 1bb331dc68..1277ae0d4b 100644 --- a/apps/desktop/src/hooks/useRunBatch.ts +++ b/apps/desktop/src/hooks/useRunBatch.ts @@ -34,7 +34,7 @@ export const useRunBatch = (sessionId: string) => { }); const updateSessionTabState = useTabs((state) => state.updateSessionTabState); - const conn = useSTTConnection(); + const { conn } = useSTTConnection(); const keywords = useKeywords(sessionId); const languages = useConfigValue("spoken_languages"); diff --git a/apps/desktop/src/hooks/useSTTConnection.ts b/apps/desktop/src/hooks/useSTTConnection.ts index 23eae805c4..871cf28b77 100644 --- a/apps/desktop/src/hooks/useSTTConnection.ts +++ b/apps/desktop/src/hooks/useSTTConnection.ts @@ -1,4 +1,8 @@ -import { useQuery } from "@tanstack/react-query"; +import { + type FetchStatus, + type QueryStatus, + useQuery, +} from "@tanstack/react-query"; import { useMemo } from "react"; import { commands as localSttCommands } from "@hypr/plugin-local-stt"; @@ -13,7 +17,25 @@ type Connection = { apiKey: string; }; -export const useSTTConnection = (): Connection | null => { +type LocalConnectionSnapshot = { + serverStatus: string | null; + connection: Connection | null; +}; + +type LocalConnectionMeta = { + snapshot: LocalConnectionSnapshot | null; + status: QueryStatus; + fetchStatus: FetchStatus; + isFetching: boolean; + isPending: boolean; +}; + +export type STTConnectionResult = { + conn: Connection | null; + local: LocalConnectionMeta | null; +}; + +export const useSTTConnection = (): STTConnectionResult => { const { current_stt_provider, current_stt_model } = main.UI.useValues( main.STORE_ID, ) as { @@ -29,10 +51,17 @@ export const useSTTConnection = (): Connection | null => { const isLocalModel = current_stt_provider === "hyprnote" && - (current_stt_model?.startsWith("am-") || - current_stt_model?.startsWith("Quantized")); - - const { data: localConnection } = useQuery({ + !!current_stt_model && + (current_stt_model.startsWith("am-") || + current_stt_model.startsWith("Quantized")); + + const { + data: localSnapshot, + status: localStatus, + fetchStatus: localFetchStatus, + isFetching: localIsFetching, + isPending: localIsPending, + } = useQuery({ enabled: current_stt_provider === "hyprnote", queryKey: ["stt-connection", isLocalModel, current_stt_model], refetchInterval: 1000, @@ -54,27 +83,33 @@ export const useSTTConnection = (): Connection | null => { if (server?.status === "ready" && server.url) { return { - provider: current_stt_provider!, - model: current_stt_model, - baseUrl: server.url, - apiKey: "", + serverStatus: server.status ?? "ready", + connection: { + provider: current_stt_provider!, + model: current_stt_model, + baseUrl: server.url, + apiKey: "", + }, }; } - return null; + return { + serverStatus: server?.status ?? "unknown", + connection: null, + }; }, }); const baseUrl = providerConfig?.base_url?.trim(); const apiKey = providerConfig?.api_key?.trim(); - return useMemo(() => { + const connection = useMemo(() => { if (!current_stt_provider || !current_stt_model) { return null; } if (isLocalModel) { - return localConnection ?? null; + return localSnapshot?.connection ?? null; } if (!baseUrl || !apiKey) { @@ -91,8 +126,32 @@ export const useSTTConnection = (): Connection | null => { current_stt_provider, current_stt_model, isLocalModel, - localConnection, + localSnapshot, baseUrl, apiKey, ]); + + return useMemo( + () => ({ + conn: connection, + local: isLocalModel + ? { + snapshot: localSnapshot ?? null, + status: localStatus, + fetchStatus: localFetchStatus, + isFetching: localIsFetching, + isPending: localIsPending, + } + : null, + }), + [ + connection, + isLocalModel, + localSnapshot, + localStatus, + localFetchStatus, + localIsFetching, + localIsPending, + ], + ); }; diff --git a/apps/desktop/src/hooks/useStartListening.ts b/apps/desktop/src/hooks/useStartListening.ts index 3df48120d1..32acd32da2 100644 --- a/apps/desktop/src/hooks/useStartListening.ts +++ b/apps/desktop/src/hooks/useStartListening.ts @@ -15,7 +15,7 @@ export function useStartListening(sessionId: string) { const record_enabled = useConfigValue("save_recordings"); const start = useListener((state) => state.start); - const conn = useSTTConnection(); + const { conn } = useSTTConnection(); const keywords = useKeywords(sessionId); From e2c980feeb72116a11edff2f517e8f050fcd01f2 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 14 Nov 2025 15:27:23 +0900 Subject: [PATCH 4/7] fix external server heakth report --- .../src/components/settings/ai/stt/health.tsx | 13 +++++++++---- plugins/local-stt/src/server/external.rs | 6 +++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/components/settings/ai/stt/health.tsx b/apps/desktop/src/components/settings/ai/stt/health.tsx index d2b1b43d41..8dde0764ca 100644 --- a/apps/desktop/src/components/settings/ai/stt/health.tsx +++ b/apps/desktop/src/components/settings/ai/stt/health.tsx @@ -25,17 +25,22 @@ function useConnectionHealth(): Parameters[0] { }; } - if (conn) { + const serverStatus = local.snapshot?.serverStatus ?? "unavailable"; + + if (serverStatus === "loading") { return { - status: "success" as const, + status: "pending", + tooltip: "Local STT server is starting up…", }; } - const serverStatus = local.snapshot?.serverStatus ?? "unavailable"; + if (conn) { + return { status: "success" }; + } return { status: "error", - tooltip: `Local server status: ${serverStatus}. Click to restart.`, + tooltip: `Local server status: ${serverStatus}.`, }; } diff --git a/plugins/local-stt/src/server/external.rs b/plugins/local-stt/src/server/external.rs index e008c2218b..58ddf9b230 100644 --- a/plugins/local-stt/src/server/external.rs +++ b/plugins/local-stt/src/server/external.rs @@ -231,9 +231,9 @@ impl Actor for ExternalSTTActor { } ExternalSTTMessage::GetHealth(reply_port) => { let status = match state.client.status().await { - Ok(r) => match r.model_state { - hypr_am::ModelState::Loading => ServerStatus::Loading, - hypr_am::ModelState::Loaded => ServerStatus::Ready, + Ok(r) => match r.status { + hypr_am::ServerStatusType::Ready => ServerStatus::Ready, + hypr_am::ServerStatusType::Initializing => ServerStatus::Loading, _ => ServerStatus::Unreachable, }, Err(e) => { From 1c081b81bef0141cf8fd9e4b2d1fc47cb3a70228 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 14 Nov 2025 15:35:45 +0900 Subject: [PATCH 5/7] wip --- .../src/components/settings/ai/stt/health.tsx | 16 +--- apps/desktop/src/hooks/useSTTConnection.ts | 76 +++---------------- 2 files changed, 14 insertions(+), 78 deletions(-) diff --git a/apps/desktop/src/components/settings/ai/stt/health.tsx b/apps/desktop/src/components/settings/ai/stt/health.tsx index 8dde0764ca..a5e5f451fe 100644 --- a/apps/desktop/src/components/settings/ai/stt/health.tsx +++ b/apps/desktop/src/components/settings/ai/stt/health.tsx @@ -18,14 +18,7 @@ function useConnectionHealth(): Parameters[0] { return { status: "success" }; } - if (local.isPending || local.fetchStatus === "fetching") { - return { - status: "pending", - tooltip: "Checking local STT server…", - }; - } - - const serverStatus = local.snapshot?.serverStatus ?? "unavailable"; + const serverStatus = local.data?.status ?? "unavailable"; if (serverStatus === "loading") { return { @@ -34,14 +27,11 @@ function useConnectionHealth(): Parameters[0] { }; } - if (conn) { + if (serverStatus === "ready" && conn) { return { status: "success" }; } - return { - status: "error", - tooltip: `Local server status: ${serverStatus}.`, - }; + return { status: "error", tooltip: `Local server status: ${serverStatus}.` }; } export function HealthCheckForAvailability() { diff --git a/apps/desktop/src/hooks/useSTTConnection.ts b/apps/desktop/src/hooks/useSTTConnection.ts index 871cf28b77..9a2e0d0ed9 100644 --- a/apps/desktop/src/hooks/useSTTConnection.ts +++ b/apps/desktop/src/hooks/useSTTConnection.ts @@ -1,8 +1,4 @@ -import { - type FetchStatus, - type QueryStatus, - useQuery, -} from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { commands as localSttCommands } from "@hypr/plugin-local-stt"; @@ -10,32 +6,7 @@ import { commands as localSttCommands } from "@hypr/plugin-local-stt"; import { ProviderId } from "../components/settings/ai/stt/shared"; import * as main from "../store/tinybase/main"; -type Connection = { - provider: ProviderId; - model: string; - baseUrl: string; - apiKey: string; -}; - -type LocalConnectionSnapshot = { - serverStatus: string | null; - connection: Connection | null; -}; - -type LocalConnectionMeta = { - snapshot: LocalConnectionSnapshot | null; - status: QueryStatus; - fetchStatus: FetchStatus; - isFetching: boolean; - isPending: boolean; -}; - -export type STTConnectionResult = { - conn: Connection | null; - local: LocalConnectionMeta | null; -}; - -export const useSTTConnection = (): STTConnectionResult => { +export const useSTTConnection = () => { const { current_stt_provider, current_stt_model } = main.UI.useValues( main.STORE_ID, ) as { @@ -55,13 +26,7 @@ export const useSTTConnection = (): STTConnectionResult => { (current_stt_model.startsWith("am-") || current_stt_model.startsWith("Quantized")); - const { - data: localSnapshot, - status: localStatus, - fetchStatus: localFetchStatus, - isFetching: localIsFetching, - isPending: localIsPending, - } = useQuery({ + const local = useQuery({ enabled: current_stt_provider === "hyprnote", queryKey: ["stt-connection", isLocalModel, current_stt_model], refetchInterval: 1000, @@ -83,7 +48,7 @@ export const useSTTConnection = (): STTConnectionResult => { if (server?.status === "ready" && server.url) { return { - serverStatus: server.status ?? "ready", + status: "ready", connection: { provider: current_stt_provider!, model: current_stt_model, @@ -94,7 +59,7 @@ export const useSTTConnection = (): STTConnectionResult => { } return { - serverStatus: server?.status ?? "unknown", + status: server?.status, connection: null, }; }, @@ -109,7 +74,7 @@ export const useSTTConnection = (): STTConnectionResult => { } if (isLocalModel) { - return localSnapshot?.connection ?? null; + return local.data?.connection ?? null; } if (!baseUrl || !apiKey) { @@ -126,32 +91,13 @@ export const useSTTConnection = (): STTConnectionResult => { current_stt_provider, current_stt_model, isLocalModel, - localSnapshot, + local.data, baseUrl, apiKey, ]); - return useMemo( - () => ({ - conn: connection, - local: isLocalModel - ? { - snapshot: localSnapshot ?? null, - status: localStatus, - fetchStatus: localFetchStatus, - isFetching: localIsFetching, - isPending: localIsPending, - } - : null, - }), - [ - connection, - isLocalModel, - localSnapshot, - localStatus, - localFetchStatus, - localIsFetching, - localIsPending, - ], - ); + return { + conn: connection, + local, + }; }; From 01a04fae18c2a9c9c75e8ff1f99bdbc825bbc17b Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 14 Nov 2025 15:45:16 +0900 Subject: [PATCH 6/7] more guards --- apps/desktop/src/components/settings/ai/llm/health.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/settings/ai/llm/health.tsx b/apps/desktop/src/components/settings/ai/llm/health.tsx index 6c0abb4db5..c645dbf007 100644 --- a/apps/desktop/src/components/settings/ai/llm/health.tsx +++ b/apps/desktop/src/components/settings/ai/llm/health.tsx @@ -99,14 +99,14 @@ function useAvailability() { }; } - if (PROVIDERS.find((p) => p.id === current_llm_provider)) { + if (!PROVIDERS.find((p) => p.id === current_llm_provider)) { return { available: false, message: "Provider not found. Please select a valid provider.", }; } - if (configuredProviders[current_llm_provider]?.base_url) { + if (!configuredProviders[current_llm_provider]?.base_url) { return { available: false, message: From 794dfcf52080936f34b79d4de22e97a2a93cad69 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 14 Nov 2025 16:50:23 +0900 Subject: [PATCH 7/7] chores --- .../src/components/settings/ai/llm/health.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/components/settings/ai/llm/health.tsx b/apps/desktop/src/components/settings/ai/llm/health.tsx index c645dbf007..69a7c4b6c5 100644 --- a/apps/desktop/src/components/settings/ai/llm/health.tsx +++ b/apps/desktop/src/components/settings/ai/llm/health.tsx @@ -42,16 +42,6 @@ export function HealthCheckForConnection() { function useConnectionHealth() { const model = useLanguageModel(); - useEffect(() => { - if (model) { - text.refetch(); - } - }, [model]); - - if (!model) { - return null; - } - const text = useQuery({ enabled: !!model, queryKey: ["llm-health-check", model], @@ -67,6 +57,16 @@ function useConnectionHealth() { }), }); + useEffect(() => { + if (model) { + text.refetch(); + } + }, [model]); + + if (!model) { + return null; + } + return text.status; }