feat: add ModelCatalogEntry type, enhance EffectiveServerUrl with host/port details, and refine project context logging.

This commit is contained in:
2026-01-19 02:15:39 +00:00
parent 0f92ef8053
commit 853bf7fe01
13 changed files with 65 additions and 61 deletions

View File

@@ -109,21 +109,28 @@ pub fn get_effective_server_url(state: State<'_, Arc<AppState>>) -> EffectiveSer
let prefs = state.preferences.read();
let cfg = config();
// Check if preferences override the default
let prefs_url = format!("{}:{}", prefs.server_host, prefs.server_port);
let default_url = &cfg.server.default_address;
// If preferences explicitly customized, use them
if prefs.server_address_customized && !prefs.server_host.is_empty() {
return EffectiveServerUrl {
url: prefs_url,
url: format!("{}:{}", prefs.server_host, prefs.server_port),
host: prefs.server_host.clone(),
port: prefs.server_port.clone(),
source: ServerAddressSource::Preferences,
};
}
// Otherwise, use config (which tracks env vs default)
let default_url = cfg.server.default_address.clone();
let (h, p) = if let Some(pos) = default_url.find(':') {
(&default_url[..pos], &default_url[pos + 1..])
} else {
(&default_url[..], "")
};
EffectiveServerUrl {
url: default_url.clone(),
host: h.to_string(),
port: p.to_string(),
source: cfg.server.address_source,
}
}

View File

@@ -94,6 +94,10 @@ pub enum ServerAddressSource {
pub struct EffectiveServerUrl {
/// The server URL
pub url: String,
/// Parsed host part
pub host: String,
/// Parsed port part
pub port: String,
/// Source of the URL
pub source: ServerAddressSource,
}

View File

@@ -19,14 +19,10 @@ describe('TauriTranscriptionStream', () => {
let stream: TauriTranscriptionStream;
beforeEach(() => {
const invokeMock = vi
.fn<Parameters<TauriInvoke>, ReturnType<TauriInvoke>>()
.mockResolvedValue(undefined);
const listenMock = vi
.fn<Parameters<TauriListen>, ReturnType<TauriListen>>()
.mockResolvedValue(() => {});
mockInvoke = invokeMock;
mockListen = listenMock;
const invokeMock = vi.fn(async () => undefined);
const listenMock = vi.fn(async () => () => {});
mockInvoke = invokeMock as unknown as TauriInvoke;
mockListen = listenMock as unknown as TauriListen;
stream = new TauriTranscriptionStream('meeting-123', mockInvoke, mockListen);
});
@@ -44,7 +40,7 @@ describe('TauriTranscriptionStream', () => {
const expectedPayload: Record<string, unknown> = {
meeting_id: 'meeting-123',
audio_data: expect.arrayContaining([expect.any(Number), expect.any(Number)]),
audio_data: expect.arrayContaining([expect.any(Number), expect.any(Number)]) as unknown,
timestamp: 1.5,
sample_rate: 48000,
channels: 2,
@@ -57,10 +53,8 @@ describe('TauriTranscriptionStream', () => {
it('resets consecutive failures on successful send', async () => {
const errorCallback = vi.fn();
const failingInvoke = vi
.fn<Parameters<TauriInvoke>, ReturnType<TauriInvoke>>()
.mockRejectedValue(new Error('Network error'));
const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke, mockListen);
const failingInvoke = vi.fn(async () => { throw new Error('Network error'); });
const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke as unknown as TauriInvoke, mockListen);
failingStream.onError(errorCallback);
// Send twice (below threshold of 3)
@@ -86,9 +80,8 @@ describe('TauriTranscriptionStream', () => {
it('emits error after threshold consecutive failures', async () => {
const errorCallback = vi.fn();
const failingInvoke = vi
.fn<Parameters<TauriInvoke>, ReturnType<TauriInvoke>>()
.mockRejectedValue(new Error('Connection lost'));
const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke, mockListen);
.fn(async () => { throw new Error('Connection lost'); });
const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke as unknown as TauriInvoke, mockListen);
failingStream.onError(errorCallback);
// Send enough chunks to exceed threshold
@@ -107,17 +100,15 @@ describe('TauriTranscriptionStream', () => {
// The StreamingQueue reports failures with its own message format
const expectedError: Record<string, unknown> = {
code: 'stream_send_failed',
message: expect.stringContaining('consecutive failures'),
message: expect.stringContaining('consecutive failures') as unknown as string,
};
expect(errorCallback).toHaveBeenCalledWith(expectedError);
});
it('only emits error once even with more failures', async () => {
const errorCallback = vi.fn();
const failingInvoke = vi
.fn<Parameters<TauriInvoke>, ReturnType<TauriInvoke>>()
.mockRejectedValue(new Error('Network error'));
const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke, mockListen);
const failingInvoke = vi.fn(async () => { throw new Error('Network error'); });
const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke as unknown as TauriInvoke, mockListen);
failingStream.onError(errorCallback);
// Send many chunks
@@ -157,7 +148,7 @@ describe('TauriTranscriptionStream', () => {
expect(mockAddClientLog).toHaveBeenCalledWith(
expect.objectContaining({
level: 'error',
message: expect.stringContaining('operation failed'),
message: expect.stringContaining('operation failed') as unknown as string,
})
);
});
@@ -177,8 +168,8 @@ describe('TauriTranscriptionStream', () => {
it('emits error on close failure', async () => {
const errorCallback = vi.fn();
const failingInvoke = vi.fn().mockRejectedValue(new Error('Failed to stop'));
const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke, mockListen);
const failingInvoke = vi.fn(async () => { throw new Error('Failed to stop'); });
const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke as unknown as TauriInvoke, mockListen);
failingStream.onError(errorCallback);
// close() re-throws errors, so we need to catch it
@@ -188,7 +179,7 @@ describe('TauriTranscriptionStream', () => {
const expectedError: Record<string, unknown> = {
code: 'stream_close_failed',
message: expect.stringContaining('Failed to stop'),
message: expect.stringContaining('Failed to stop') as unknown as string,
};
expect(errorCallback).toHaveBeenCalledWith(expectedError);
});
@@ -196,8 +187,8 @@ describe('TauriTranscriptionStream', () => {
it('logs close errors to clientLog', async () => {
const mockAddClientLog = vi.mocked(addClientLog);
mockAddClientLog.mockClear();
const failingInvoke = vi.fn().mockRejectedValue(new Error('Stop failed'));
const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke, mockListen);
const failingInvoke = vi.fn(async () => { throw new Error('Stop failed'); });
const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke as unknown as TauriInvoke, mockListen);
// close() re-throws errors, so we need to catch it
await failingStream.close().catch(() => {
@@ -207,9 +198,9 @@ describe('TauriTranscriptionStream', () => {
expect(mockAddClientLog).toHaveBeenCalledWith(
expect.objectContaining({
level: 'error',
source: 'api',
source: 'api' as const,
message: 'Tauri stream stop_recording failed',
})
}) as unknown as Parameters<typeof addClientLog>[0]
);
});
});

View File

@@ -263,6 +263,10 @@ export type ServerAddressSource = 'environment' | 'preferences' | 'default';
export interface EffectiveServerUrl {
/** The server URL (e.g., "127.0.0.1:<port>") */
url: string;
/** Parsed host part */
host: string;
/** Parsed port part */
port: string;
/** Source of the URL configuration */
source: ServerAddressSource;
}

View File

@@ -26,6 +26,7 @@ export type {
AITemplate,
AITone,
AIVerbosity,
ModelCatalogEntry,
SummarizationOptions,
TranscriptionProviderConfig,
TranscriptionProviderType,

View File

@@ -15,6 +15,7 @@ import { CustomIntegrationDialog, TestAllButton } from './custom-integration-dia
import { groupIntegrationsByType } from './helpers';
import { IntegrationSettingsProvider } from './integration-settings-context';
import { IntegrationItem } from './integration-item';
import type { Integration } from '@/api/types';
import type { IntegrationsSectionProps } from './types';
import { useIntegrationHandlers } from './use-integration-handlers';
@@ -134,7 +135,7 @@ export function IntegrationsSection({
</TabsTrigger>
</TabsList>
{Object.entries(groupedIntegrations).map(([type, items]) => (
{(Object.entries(groupedIntegrations) as [string, Integration[]][]).map(([type, items]) => (
<TabsContent key={type} value={type} className="space-y-3 mt-4">
{items.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">

View File

@@ -8,10 +8,10 @@ import type { CreateProjectRequest, Project, UpdateProjectRequest } from '@/api/
import { ProjectContext, type ProjectContextValue } from '@/contexts/project-state';
import { projectStorageKey } from '@/contexts/storage';
import { useWorkspace } from '@/contexts/workspace-state';
import { debug } from '@/lib/debug';
import { errorLog } from '@/lib/debug';
import { readStorageRaw, writeStorageRaw } from '@/lib/storage-utils';
const log = debug('project-context');
const logError = errorLog('project-context');
function readStoredProjectId(workspaceId: string): string | null {
const value = readStorageRaw(projectStorageKey(workspaceId), '');
@@ -128,7 +128,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }) {
.catch((err) => {
// Failed to persist active project - context state already updated
// Log for debugging but don't fail the operation
log('Failed to persist active project to server:', err);
logError('Failed to persist active project to server:', err);
});
},
[currentWorkspace]

View File

@@ -207,8 +207,8 @@ describe('AnthropicStrategy', () => {
expect.objectContaining({
headers: expect.objectContaining({
'x-api-key': 'sk-ant-test',
'anthropic-version': expect.any(String),
}),
'anthropic-version': expect.any(String) as unknown,
}) as unknown,
})
);
});
@@ -266,7 +266,7 @@ describe('OllamaStrategy', () => {
'http://localhost:11434/api/generate',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('llama2'),
body: expect.stringContaining('llama2') as unknown,
})
);
});

View File

@@ -318,7 +318,7 @@ function cleanupTechnicalMessage(message: string): string {
// e.g., "getPreferences: received" → "Preferences received"
cleaned = cleaned.replace(
/^(get|set|load|save|fetch|create|delete|update)([A-Z][a-zA-Z]*):\s*/i,
(_, _verb, noun) => {
(_: string, _verb: string, noun: string) => {
const readableNoun = noun.replace(/([A-Z])/g, ' $1').trim();
return `${readableNoun} `;
}
@@ -334,8 +334,8 @@ function cleanupTechnicalMessage(message: string): string {
// Convert snake_case segments to readable text
// Only for isolated snake_case words, not in the middle of sentences
cleaned = cleaned.replace(/\b([a-z]+)_([a-z]+)(?:_([a-z]+))?\b/gi, (_match, p1, p2, p3) => {
const parts = [p1, p2, p3].filter(Boolean);
cleaned = cleaned.replace(/\b([a-z]+)_([a-z]+)(?:_([a-z]+))?\b/gi, (_match: string, p1: string, p2: string, p3: string | undefined) => {
const parts = [p1, p2, p3].filter((p): p is string => !!p);
return parts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join(' ');
});

View File

@@ -105,7 +105,7 @@ vi.mock('@/hooks/use-guarded-mutation', () => ({
useGuardedMutation: () => ({ guard }),
}));
const toast = vi.fn<unknown[], void>();
const toast = vi.fn<(...args: unknown[]) => void>();
vi.mock('@/hooks/use-toast', () => ({
toast: (...args: unknown[]) => toast(...args),
}));
@@ -206,7 +206,7 @@ vi.mock('@/components/recording', () => ({
{showPanel ? (
'visible'
) : (
<button type="button" title="Expand notes panel" onClick={() => setShowNotesPanel(true)}>
<button type="button" title="Expand notes panel" onClick={() => { setShowNotesPanel(true); }}>
Expand Notes
</button>
)}
@@ -217,7 +217,7 @@ vi.mock('@/components/recording', () => ({
{showPanel ? (
'visible'
) : (
<button type="button" title="Expand stats panel" onClick={() => setShowStatsPanel(true)}>
<button type="button" title="Expand stats panel" onClick={() => { setShowStatsPanel(true); }}>
Expand Stats
</button>
)}

View File

@@ -4,14 +4,14 @@
import { Loader2, Tags } from 'lucide-react';
import type { NamedEntity } from '@/api/types';
import type { ExtractedEntity } from '@/api/types';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { iconWithMargin } from '@/lib/styles';
import { ENTITY_CATEGORY_COLORS } from '@/types/entity';
interface EntitiesPanelProps {
entities: NamedEntity[];
entities: ExtractedEntity[];
isExtracting: boolean;
meetingState: string;
onExtract: (force: boolean) => void;
@@ -60,7 +60,7 @@ export function EntitiesPanel({
>
{entity.category}
</span>
{entity.isPinned && <span className="text-xs text-amber-500"></span>}
{entity.is_pinned && <span className="text-xs text-amber-500"></span>}
</div>
<p className="text-sm font-medium">{entity.text}</p>
{entity.confidence !== undefined && (

View File

@@ -1,4 +1,4 @@
import type { AIFormat, AITone, AIVerbosity, ExportFormat, ServerInfo } from '@/api/types';
import type { AIConfig, AIFormat, AITone, AIVerbosity, ExportFormat, ServerInfo } from '@/api/types';
import {
AdvancedLocalAISettings,
AIConfigSection,
@@ -7,7 +7,7 @@ import {
SummarizationSettingsPanel,
} from '@/components/settings';
import { PROVIDER_ENDPOINTS } from '@/lib/config/provider-endpoints';
import { preferences, type AIConfig } from '@/lib/preferences';
import { preferences } from '@/lib/preferences';
interface AITabProps {
defaultExportFormat: ExportFormat;
@@ -25,9 +25,6 @@ interface AITabProps {
/** Get Ollama base URL from whichever provider is using it. */
function getOllamaBaseUrl(aiConfig: AIConfig): string {
if (aiConfig.transcription.provider === 'ollama') {
return aiConfig.transcription.base_url;
}
if (aiConfig.summary.provider === 'ollama') {
return aiConfig.summary.base_url;
}
@@ -53,9 +50,7 @@ export function AITab({
// Check if Ollama is selected for one of the provider types
const aiConfig = preferences.get().ai_config;
const isOllamaSelected =
aiConfig.transcription.provider === 'ollama' ||
aiConfig.summary.provider === 'ollama' ||
aiConfig.embedding.provider === 'ollama';
aiConfig.summary.provider === 'ollama' || aiConfig.embedding.provider === 'ollama';
const ollamaBaseUrl = getOllamaBaseUrl(aiConfig);

View File

@@ -10,13 +10,14 @@ const srcDir = path.resolve(rootDir, 'src');
const noteflowAlias = () => ({
name: 'noteflow-alias',
enforce: 'pre',
async resolveId(source: string, importer: string | undefined) {
enforce: 'pre' as const,
async resolveId(this: unknown, source: string, importer: string | undefined) {
if (!source.startsWith('@/')) {
return null;
}
const target = path.resolve(srcDir, source.slice(2));
return (await this.resolve(target, importer, { skipSelf: true })) ?? target;
const resolved = await (this as { resolve: (...args: unknown[]) => Promise<unknown> }).resolve(target, importer, { skipSelf: true });
return (resolved as string | null) ?? target;
},
});