Fix local model setup UX and clean Rust warnings

This commit is contained in:
2026-04-11 21:03:05 -04:00
parent e31c7dd2e2
commit 27dfe629fe
14 changed files with 581 additions and 379 deletions

View File

@@ -12,7 +12,7 @@ use rig::{
use crate::agent::stream_events::AgentStreamEmitter;
use crate::agent::tools::terminal_command::{AgentCommandExecutor, RunTerminalCommandTool};
use crate::agent::AgentRuntimeConfig;
use crate::agent::{AgentProviderKind, AgentRuntimeConfig, DEFAULT_OLLAMA_COMPAT_API_KEY};
use crate::error::AppError;
use crate::state::PendingAgentToolApprovals;
@@ -120,8 +120,20 @@ impl ChatGateway for RigChatGateway {
}
}
fn build_openai_client(runtime: &AgentRuntimeConfig) -> Result<openai::CompletionsClient, AppError> {
let api_key = runtime.api_key.clone().unwrap_or_default();
fn build_openai_client(
runtime: &AgentRuntimeConfig,
) -> Result<openai::CompletionsClient, AppError> {
let api_key = match runtime.provider_kind {
AgentProviderKind::Remote => runtime
.api_key
.clone()
.filter(|value| !value.trim().is_empty())
.ok_or(AppError::RemoteApiKeyMissing)?,
AgentProviderKind::Ollama => runtime
.api_key
.clone()
.unwrap_or_else(|| DEFAULT_OLLAMA_COMPAT_API_KEY.to_string()),
};
openai::CompletionsClient::builder()
.api_key(api_key)
@@ -152,9 +164,9 @@ where
reply.push_str(&text.text);
tool_runtime.stream_emitter.text_delta(text.text)?;
}
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::Reasoning(reasoning),
)) => {
Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Reasoning(
reasoning,
))) => {
if saw_reasoning_delta {
continue;
}
@@ -172,9 +184,9 @@ where
saw_reasoning_delta = true;
tool_runtime.stream_emitter.reasoning_delta(reasoning)?;
}
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCall { .. },
)) => {}
Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::ToolCall {
..
})) => {}
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCallDelta { .. },
)) => {}

View File

@@ -16,8 +16,8 @@ pub use stream_events::AgentStreamEmitter;
pub use types::{
default_task_defaults, AgentConfigStatus, AgentProviderKind, AgentProviderStatuses,
AgentRuntimeConfig, AgentStoredSettings, AgentStreamItemEvent, AgentStreamItemKind,
AgentTaskRoute, ChatPanelContext, ChatPromptRequest, ChatStreamStart,
OllamaProviderSettings, PreparedChatTurn, ProviderConfigStatus, RemoteProviderSettings,
AgentTaskRoute, ChatPanelContext, ChatPromptRequest, ChatStreamStart, OllamaProviderSettings,
PreparedChatTurn, ProviderConfigStatus, RemoteProviderSettings,
ResolveAgentToolApprovalRequest, SaveAgentRuntimeConfigRequest, TaskProfile,
UpdateRemoteApiKeyRequest, AGENT_SETTINGS_STORE_PATH, DEFAULT_OLLAMA_BASE_URL,
DEFAULT_OLLAMA_COMPAT_API_KEY, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL,

View File

@@ -63,7 +63,8 @@ pub fn validate_settings(settings: &AgentStoredSettings) -> Result<(), AppError>
)));
}
if route.provider == AgentProviderKind::Remote && settings.remote.api_key.trim().is_empty() {
if route.provider == AgentProviderKind::Remote && settings.remote.api_key.trim().is_empty()
{
return Err(AppError::InvalidSettings(format!(
"{} is routed to Remote, but the Remote API key is missing.",
task_label(task)
@@ -100,14 +101,14 @@ pub fn normalize_routes(settings: &mut AgentStoredSettings) -> Result<(), AppErr
pub fn compute_provider_configured(
settings: &AgentStoredSettings,
provider: AgentProviderKind,
provider_kind: AgentProviderKind,
) -> bool {
let provider = provider_settings(settings, provider);
let provider = provider_settings(settings, provider_kind);
provider.enabled()
&& !provider.base_url().trim().is_empty()
&& !provider.default_model().trim().is_empty()
&& (!provider.uses_api_key() || !settings.remote.api_key.trim().is_empty())
&& (!provider_kind.uses_api_key() || !settings.remote.api_key.trim().is_empty())
}
pub fn compute_overall_configured(settings: &AgentStoredSettings) -> bool {
@@ -184,11 +185,6 @@ impl ProviderSettingsRef<'_> {
Self::Ollama(settings) => &settings.default_model,
}
}
fn uses_api_key(&self) -> bool {
matches!(self, Self::Remote(_))
}
fn label(&self) -> &'static str {
match self {
Self::Remote(_) => "Remote",

View File

@@ -365,6 +365,13 @@ impl<R: Runtime, G: ChatGateway> AgentService<R, G> {
task_profile.unwrap_or(TaskProfile::InteractiveChat),
model_override,
)
.map_err(|error| {
if error.is_configuration_issue() {
AppError::AgentNotConfigured
} else {
error
}
})
}
}
@@ -632,8 +639,8 @@ mod tests {
.save_runtime_config(SaveAgentRuntimeConfigRequest {
remote_enabled: true,
provider_kind: AgentProviderKind::OllamaOpenAI,
remote_base_url:
super::super::types::DEFAULT_OLLAMA_OPENAI_BASE_URL.to_string(),
remote_base_url: super::super::types::DEFAULT_OLLAMA_OPENAI_BASE_URL
.to_string(),
default_remote_model: "qwen3-coder".to_string(),
task_defaults: default_task_defaults("qwen3-coder"),
sec_edgar_user_agent: String::new(),
@@ -645,7 +652,10 @@ mod tests {
assert!(!saved.has_remote_api_key);
let prepared = prepare_turn(&mut service, request("hello")).unwrap();
assert_eq!(prepared.runtime.provider_kind, AgentProviderKind::OllamaOpenAI);
assert_eq!(
prepared.runtime.provider_kind,
AgentProviderKind::OllamaOpenAI
);
assert_eq!(
prepared.runtime.api_key.as_deref(),
Some(crate::agent::DEFAULT_OLLAMA_COMPAT_API_KEY)

View File

@@ -204,16 +204,31 @@ impl<R: Runtime> AgentSettingsService<R> {
.store(AGENT_SETTINGS_STORE_PATH)
.map_err(|error| AppError::SettingsStore(error.to_string()))?;
store.set(REMOTE_ENABLED_KEY.to_string(), json!(settings.remote.enabled));
store.set(REMOTE_BASE_URL_KEY.to_string(), json!(settings.remote.base_url));
store.set(REMOTE_API_KEY_KEY.to_string(), json!(settings.remote.api_key));
store.set(
REMOTE_ENABLED_KEY.to_string(),
json!(settings.remote.enabled),
);
store.set(
REMOTE_BASE_URL_KEY.to_string(),
json!(settings.remote.base_url),
);
store.set(
REMOTE_API_KEY_KEY.to_string(),
json!(settings.remote.api_key),
);
store.set(
REMOTE_DEFAULT_MODEL_KEY.to_string(),
json!(settings.remote.default_model),
);
store.set(OLLAMA_ENABLED_KEY.to_string(), json!(settings.ollama.enabled));
store.set(OLLAMA_BASE_URL_KEY.to_string(), json!(settings.ollama.base_url));
store.set(
OLLAMA_ENABLED_KEY.to_string(),
json!(settings.ollama.enabled),
);
store.set(
OLLAMA_BASE_URL_KEY.to_string(),
json!(settings.ollama.base_url),
);
store.set(
OLLAMA_DEFAULT_MODEL_KEY.to_string(),
json!(settings.ollama.default_model),

View File

@@ -4,7 +4,8 @@ use crate::research::{
GetWorkspaceProjectionRequest, GhostNote, ListNoteLinksRequest, ListResearchNotesRequest,
ListWorkspaceGhostNotesRequest, NoteAuditTrail, NoteCaptureResult, NoteLink, PipelineJob,
PromoteNoteToThesisRequest, ResearchBundleExport, ResearchNote, ResearchWorkspace,
RetryResearchJobsRequest, ReviewGhostNoteRequest, UpdateResearchNoteRequest, WorkspaceProjection,
RetryResearchJobsRequest, ReviewGhostNoteRequest, UpdateResearchNoteRequest,
WorkspaceProjection,
};
use crate::state::AppState;

View File

@@ -21,6 +21,19 @@ pub enum AppError {
ModelMissing(TaskProfile),
}
impl AppError {
pub const fn is_configuration_issue(&self) -> bool {
matches!(
self,
Self::AgentNotConfigured
| Self::RemoteApiKeyMissing
| Self::ProviderNotConfigured
| Self::TaskRouteMissing(_)
| Self::ModelMissing(_)
)
}
}
impl Display for AppError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {

View File

@@ -1,20 +1,15 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum NewsSentiment {
Bull,
Bear,
#[default]
Neutral,
}
impl Default for NewsSentiment {
fn default() -> Self {
Self::Neutral
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HighlightReason {

View File

@@ -77,7 +77,11 @@ fn build_annotation(note: &ResearchNote) -> String {
}
}
pub(crate) fn build_model_info(model: &str, task_profile: &str, provider: &str) -> Option<ModelInfo> {
pub(crate) fn build_model_info(
model: &str,
task_profile: &str,
provider: &str,
) -> Option<ModelInfo> {
if model.trim().is_empty() {
return None;
}

View File

@@ -27,6 +27,19 @@ pub(crate) fn generate_ghost_notes(
ghosts
}
struct GhostDraft {
ghost_class: GhostNoteClass,
supporting_note_ids: Vec<String>,
contradicting_note_ids: Vec<String>,
source_ids: Vec<String>,
headline: String,
body: String,
confidence: f32,
evidence_threshold_met: bool,
visibility_state: GhostVisibilityState,
memo_section_hint: Option<MemoSectionKind>,
}
fn generate_missing_evidence_prompts(
workspace: &ResearchWorkspace,
notes: &[ResearchNote],
@@ -39,21 +52,23 @@ fn generate_missing_evidence_prompts(
link.from_note_id == note.id && matches!(link.link_type, LinkType::SourcedBy | LinkType::Supports)
})
})
.map(|note| ghost(
workspace,
GhostNoteClass::MissingEvidencePrompt,
vec![note.id.clone()],
Vec::new(),
note.source_id.iter().cloned().collect(),
"Missing evidence for claim".to_string(),
format!(
"Observed: this claim is currently unsupported by a linked source note. Inference: the argument may be directionally useful but should not be treated as evidence yet. What would confirm/refute: add a filing, transcript, article, or model-backed source."
),
0.42,
false,
GhostVisibilityState::Collapsed,
None,
))
.map(|note| {
ghost(
workspace,
GhostDraft {
ghost_class: GhostNoteClass::MissingEvidencePrompt,
supporting_note_ids: vec![note.id.clone()],
contradicting_note_ids: Vec::new(),
source_ids: note.source_id.iter().cloned().collect(),
headline: "Missing evidence for claim".to_string(),
body: "Observed: this claim is currently unsupported by a linked source note. Inference: the argument may be directionally useful but should not be treated as evidence yet. What would confirm/refute: add a filing, transcript, article, or model-backed source.".to_string(),
confidence: 0.42,
evidence_threshold_met: false,
visibility_state: GhostVisibilityState::Collapsed,
memo_section_hint: None,
},
)
})
.collect()
}
@@ -73,19 +88,21 @@ fn generate_contradiction_alerts(
let right = notes_by_id.get(link.to_note_id.as_str())?;
Some(ghost(
workspace,
GhostNoteClass::ContradictionAlert,
vec![left.id.clone(), right.id.clone()],
vec![right.id.clone()],
collect_source_ids([*left, *right]),
"Contradiction alert".to_string(),
format!(
"Observed: {}. Inference: this conflicts with {}. What would confirm/refute: reconcile the newer datapoint, source freshness, and management framing before treating either statement as settled.",
left.cleaned_text, right.cleaned_text
),
0.86,
true,
GhostVisibilityState::Visible,
Some(MemoSectionKind::RiskRegister),
GhostDraft {
ghost_class: GhostNoteClass::ContradictionAlert,
supporting_note_ids: vec![left.id.clone(), right.id.clone()],
contradicting_note_ids: vec![right.id.clone()],
source_ids: collect_source_ids([*left, *right]),
headline: "Contradiction alert".to_string(),
body: format!(
"Observed: {}. Inference: this conflicts with {}. What would confirm/refute: reconcile the newer datapoint, source freshness, and management framing before treating either statement as settled.",
left.cleaned_text, right.cleaned_text
),
confidence: 0.86,
evidence_threshold_met: true,
visibility_state: GhostVisibilityState::Visible,
memo_section_hint: Some(MemoSectionKind::RiskRegister),
},
))
})
.collect()
@@ -114,16 +131,18 @@ fn generate_candidate_risks(
.collect::<Vec<_>>();
vec![ghost(
workspace,
GhostNoteClass::CandidateRisk,
supporting_ids,
Vec::new(),
collect_source_ids(risk_notes.into_iter()),
"Possible emerging risk cluster".to_string(),
"Observed: several notes point to downside pressure around the same operating area. Inference: this may represent an investable risk theme, but it should remain provisional until the source trail is tightened. What would confirm/refute: corroborate with fresh operating evidence or management disclosures.".to_string(),
0.73,
true,
GhostVisibilityState::Visible,
Some(MemoSectionKind::RiskRegister),
GhostDraft {
ghost_class: GhostNoteClass::CandidateRisk,
supporting_note_ids: supporting_ids,
contradicting_note_ids: Vec::new(),
source_ids: collect_source_ids(risk_notes.into_iter()),
headline: "Possible emerging risk cluster".to_string(),
body: "Observed: several notes point to downside pressure around the same operating area. Inference: this may represent an investable risk theme, but it should remain provisional until the source trail is tightened. What would confirm/refute: corroborate with fresh operating evidence or management disclosures.".to_string(),
confidence: 0.73,
evidence_threshold_met: true,
visibility_state: GhostVisibilityState::Visible,
memo_section_hint: Some(MemoSectionKind::RiskRegister),
},
)]
}
@@ -150,16 +169,18 @@ fn generate_candidate_catalysts(
.collect::<Vec<_>>();
vec![ghost(
workspace,
GhostNoteClass::CandidateCatalyst,
supporting_ids,
Vec::new(),
collect_source_ids(catalyst_notes.into_iter()),
"Possible catalyst cluster".to_string(),
"Observed: multiple notes point toward an identifiable event or operating trigger. Inference: this could matter for the next stock move if timing and evidence quality hold. What would confirm/refute: map the catalyst to dated milestones and an observable KPI.".to_string(),
0.7,
true,
GhostVisibilityState::Visible,
Some(MemoSectionKind::CatalystCalendar),
GhostDraft {
ghost_class: GhostNoteClass::CandidateCatalyst,
supporting_note_ids: supporting_ids,
contradicting_note_ids: Vec::new(),
source_ids: collect_source_ids(catalyst_notes.into_iter()),
headline: "Possible catalyst cluster".to_string(),
body: "Observed: multiple notes point toward an identifiable event or operating trigger. Inference: this could matter for the next stock move if timing and evidence quality hold. What would confirm/refute: map the catalyst to dated milestones and an observable KPI.".to_string(),
confidence: 0.7,
evidence_threshold_met: true,
visibility_state: GhostVisibilityState::Visible,
memo_section_hint: Some(MemoSectionKind::CatalystCalendar),
},
)]
}
@@ -195,16 +216,18 @@ fn generate_valuation_bridges(
support.extend(driver_notes.iter().take(2).map(|note| note.id.clone()));
vec![ghost(
workspace,
GhostNoteClass::ValuationBridge,
support,
Vec::new(),
collect_source_ids(valuation_notes.into_iter().chain(driver_notes.into_iter())),
"Possible valuation bridge".to_string(),
"Observed: valuation notes point to a discount while operating notes suggest a driver that could narrow that gap. Inference: there may be a rerating bridge if the operating evidence persists. What would confirm/refute: track the KPI that should transmit into multiple expansion.".to_string(),
0.76,
true,
GhostVisibilityState::Visible,
Some(MemoSectionKind::ValuationWriteUp),
GhostDraft {
ghost_class: GhostNoteClass::ValuationBridge,
supporting_note_ids: support,
contradicting_note_ids: Vec::new(),
source_ids: collect_source_ids(valuation_notes.into_iter().chain(driver_notes.into_iter())),
headline: "Possible valuation bridge".to_string(),
body: "Observed: valuation notes point to a discount while operating notes suggest a driver that could narrow that gap. Inference: there may be a rerating bridge if the operating evidence persists. What would confirm/refute: track the KPI that should transmit into multiple expansion.".to_string(),
confidence: 0.76,
evidence_threshold_met: true,
visibility_state: GhostVisibilityState::Visible,
memo_section_hint: Some(MemoSectionKind::ValuationWriteUp),
},
)]
}
@@ -270,20 +293,22 @@ fn generate_candidate_thesis(
Some(ghost(
workspace,
GhostNoteClass::CandidateThesis,
notes.iter().take(6).map(|note| note.id.clone()).collect(),
Vec::new(),
collect_source_ids(notes.iter()),
headline.to_string(),
body.to_string(),
if has_unresolved_contradiction {
0.68
} else {
0.82
GhostDraft {
ghost_class: GhostNoteClass::CandidateThesis,
supporting_note_ids: notes.iter().take(6).map(|note| note.id.clone()).collect(),
contradicting_note_ids: Vec::new(),
source_ids: collect_source_ids(notes.iter()),
headline: headline.to_string(),
body: body.to_string(),
confidence: if has_unresolved_contradiction {
0.68
} else {
0.82
},
evidence_threshold_met: !has_unresolved_contradiction,
visibility_state: GhostVisibilityState::Visible,
memo_section_hint: Some(MemoSectionKind::InvestmentMemo),
},
!has_unresolved_contradiction,
GhostVisibilityState::Visible,
Some(MemoSectionKind::InvestmentMemo),
))
}
@@ -306,47 +331,36 @@ fn rank_and_limit_visibility(ghosts: &mut [GhostNote]) {
}
}
fn ghost(
workspace: &ResearchWorkspace,
ghost_class: GhostNoteClass,
supporting_note_ids: Vec<String>,
contradicting_note_ids: Vec<String>,
source_ids: Vec<String>,
headline: String,
body: String,
confidence: f32,
evidence_threshold_met: bool,
visibility_state: GhostVisibilityState,
memo_section_hint: Option<MemoSectionKind>,
) -> GhostNote {
fn ghost(workspace: &ResearchWorkspace, draft: GhostDraft) -> GhostNote {
let now = now_rfc3339();
let mut key_parts = BTreeSet::new();
key_parts.extend(supporting_note_ids.iter().cloned());
key_parts.extend(contradicting_note_ids.iter().cloned());
key_parts.extend(draft.supporting_note_ids.iter().cloned());
key_parts.extend(draft.contradicting_note_ids.iter().cloned());
let ghost_key = format!(
"{ghost_class:?}-{}",
"{:?}-{}",
draft.ghost_class,
key_parts.into_iter().collect::<Vec<_>>().join(",")
);
GhostNote {
id: format!("ghost-{}", &sha256_hex(&ghost_key)[..16]),
workspace_id: workspace.id.clone(),
ghost_class,
headline,
body,
ghost_class: draft.ghost_class,
headline: draft.headline,
body: draft.body,
tone: GhostTone::Tentative,
confidence,
visibility_state,
confidence: draft.confidence,
visibility_state: draft.visibility_state,
state: GhostLifecycleState::Generated,
supporting_note_ids,
contradicting_note_ids,
source_ids,
evidence_threshold_met,
supporting_note_ids: draft.supporting_note_ids,
contradicting_note_ids: draft.contradicting_note_ids,
source_ids: draft.source_ids,
evidence_threshold_met: draft.evidence_threshold_met,
created_at: now.clone(),
updated_at: now,
superseded_by_ghost_id: None,
promoted_note_id: None,
memo_section_hint,
memo_section_hint: draft.memo_section_hint,
}
}

View File

@@ -25,12 +25,12 @@ use crate::research::types::{
ArchiveResearchNoteRequest, AuditActor, AuditEntityKind, AuditEvent, CaptureMethod,
CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, EvidenceStatus,
ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetNotesByTickerRequest,
GetWorkspaceProjectionRequest, GhostLifecycleState, GhostReviewAction, GhostStatus,
LinkOrigin, ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest,
NoteAuditTrail, NoteCaptureResult, NotePriority, NoteType, PromoteNoteToThesisRequest,
ProvenanceActor, ResearchBundleExport, ResearchNote, ResearchWorkspace, RetryResearchJobsRequest,
ReviewGhostNoteRequest, SourceKind, ThesisStatus, UpdateResearchNoteRequest, WorkspaceProjection,
WorkspaceScope,
GetWorkspaceProjectionRequest, GhostLifecycleState, GhostReviewAction, GhostStatus, LinkOrigin,
ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, NoteAuditTrail,
NoteCaptureResult, NotePriority, NoteType, PromoteNoteToThesisRequest, ProvenanceActor,
ResearchBundleExport, ResearchNote, ResearchWorkspace, RetryResearchJobsRequest,
ReviewGhostNoteRequest, SourceKind, ThesisStatus, UpdateResearchNoteRequest,
WorkspaceProjection, WorkspaceScope,
};
use crate::research::util::{generate_id, normalize_text, now_rfc3339, sha256_hex};
@@ -43,6 +43,17 @@ pub struct ResearchService<R: Runtime> {
job_processor_lock: Arc<tokio::sync::Mutex<()>>,
}
struct AuditRecord<'a> {
entity_id: &'a str,
entity_kind: AuditEntityKind,
action: &'a str,
actor: AuditActor,
prior_revision: Option<u32>,
new_revision: Option<u32>,
job_id: Option<String>,
source_ids: Vec<String>,
}
impl<R: Runtime> Clone for ResearchService<R> {
fn clone(&self) -> Self {
Self {
@@ -101,14 +112,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
let saved = self.repository.create_workspace(workspace.clone()).await?;
self.record_audit(
&saved.id,
&saved.id,
AuditEntityKind::Workspace,
"workspace_created",
AuditActor::Analyst,
None,
None,
None,
Vec::new(),
AuditRecord {
entity_id: &saved.id,
entity_kind: AuditEntityKind::Workspace,
action: "workspace_created",
actor: AuditActor::Analyst,
prior_revision: None,
new_revision: None,
job_id: None,
source_ids: Vec::new(),
},
)
.await?;
self.emitter.workspace_updated(&saved);
@@ -202,14 +215,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
self.repository.create_note(source_note.clone()).await?;
self.record_audit(
&workspace.id,
&source_note.id,
AuditEntityKind::Note,
"source_reference_note_created",
AuditActor::System,
None,
Some(source_note.revision),
None,
source_note.source_id.iter().cloned().collect(),
AuditRecord {
entity_id: &source_note.id,
entity_kind: AuditEntityKind::Note,
action: "source_reference_note_created",
actor: AuditActor::System,
prior_revision: None,
new_revision: Some(source_note.revision),
job_id: None,
source_ids: source_note.source_id.iter().cloned().collect(),
},
)
.await?;
self.emitter.note_updated(&source_note);
@@ -279,27 +294,31 @@ impl<R: Runtime + 'static> ResearchService<R> {
.await?;
self.record_audit(
&workspace.id,
&saved.id,
AuditEntityKind::Note,
"note_captured",
AuditActor::Analyst,
None,
Some(saved.revision),
None,
source_id.iter().cloned().collect(),
AuditRecord {
entity_id: &saved.id,
entity_kind: AuditEntityKind::Note,
action: "note_captured",
actor: AuditActor::Analyst,
prior_revision: None,
new_revision: Some(saved.revision),
job_id: None,
source_ids: source_id.iter().cloned().collect(),
},
)
.await?;
for job in &queued_jobs {
self.record_audit(
&workspace.id,
&job.id,
AuditEntityKind::Job,
"job_enqueued",
AuditActor::System,
None,
None,
Some(job.id.clone()),
Vec::new(),
AuditRecord {
entity_id: &job.id,
entity_kind: AuditEntityKind::Job,
action: "job_enqueued",
actor: AuditActor::System,
prior_revision: None,
new_revision: None,
job_id: Some(job.id.clone()),
source_ids: Vec::new(),
},
)
.await?;
self.emitter.job_updated(job);
@@ -377,14 +396,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
.await?;
self.record_audit(
&saved.workspace_id,
&saved.id,
AuditEntityKind::Note,
"note_updated",
AuditActor::Analyst,
Some(prior_revision),
Some(saved.revision),
None,
saved.source_id.iter().cloned().collect(),
AuditRecord {
entity_id: &saved.id,
entity_kind: AuditEntityKind::Note,
action: "note_updated",
actor: AuditActor::Analyst,
prior_revision: Some(prior_revision),
new_revision: Some(saved.revision),
job_id: None,
source_ids: saved.source_id.iter().cloned().collect(),
},
)
.await?;
self.emitter.note_updated(&saved);
@@ -402,18 +423,20 @@ impl<R: Runtime + 'static> ResearchService<R> {
.await?;
self.record_audit(
&note.workspace_id,
&note.id,
AuditEntityKind::Note,
if request.archived {
"note_archived"
} else {
"note_unarchived"
AuditRecord {
entity_id: &note.id,
entity_kind: AuditEntityKind::Note,
action: if request.archived {
"note_archived"
} else {
"note_unarchived"
},
actor: AuditActor::Analyst,
prior_revision: Some(note.revision),
new_revision: Some(note.revision),
job_id: None,
source_ids: note.source_id.iter().cloned().collect(),
},
AuditActor::Analyst,
Some(note.revision),
Some(note.revision),
None,
note.source_id.iter().cloned().collect(),
)
.await?;
self.emitter.note_updated(&note);
@@ -442,8 +465,7 @@ impl<R: Runtime + 'static> ResearchService<R> {
.into_iter()
.filter(|ws| {
ws.primary_ticker.to_uppercase() == normalized_ticker
|| ws.primary_ticker.to_uppercase()
== format!("{}-US", normalized_ticker)
|| ws.primary_ticker.to_uppercase() == format!("{}-US", normalized_ticker)
})
.collect();
@@ -547,14 +569,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
let saved = self.repository.save_ghost(ghost).await?;
self.record_audit(
&saved.workspace_id,
&saved.id,
AuditEntityKind::Ghost,
"ghost_reviewed",
AuditActor::Analyst,
None,
None,
None,
saved.source_ids.clone(),
AuditRecord {
entity_id: &saved.id,
entity_kind: AuditEntityKind::Ghost,
action: "ghost_reviewed",
actor: AuditActor::Analyst,
prior_revision: None,
new_revision: None,
job_id: None,
source_ids: saved.source_ids.clone(),
},
)
.await?;
self.emitter.ghost_updated(&saved);
@@ -575,14 +599,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
let saved = self.repository.save_note(note).await?;
self.record_audit(
&saved.workspace_id,
&saved.id,
AuditEntityKind::Note,
"note_promoted_to_thesis",
AuditActor::Analyst,
Some(prior_revision),
Some(saved.revision),
None,
saved.source_id.iter().cloned().collect(),
AuditRecord {
entity_id: &saved.id,
entity_kind: AuditEntityKind::Note,
action: "note_promoted_to_thesis",
actor: AuditActor::Analyst,
prior_revision: Some(prior_revision),
new_revision: Some(saved.revision),
job_id: None,
source_ids: saved.source_id.iter().cloned().collect(),
},
)
.await?;
self.emitter.note_updated(&saved);
@@ -767,14 +793,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
let saved = self.repository.save_note(note).await?;
self.record_audit(
&saved.workspace_id,
&saved.id,
AuditEntityKind::Note,
"note_enriched",
AuditActor::Ai,
Some(expected_revision),
Some(saved.revision),
Some(job.id.clone()),
saved.source_id.iter().cloned().collect(),
AuditRecord {
entity_id: &saved.id,
entity_kind: AuditEntityKind::Note,
action: "note_enriched",
actor: AuditActor::Ai,
prior_revision: Some(expected_revision),
new_revision: Some(saved.revision),
job_id: Some(job.id.clone()),
source_ids: saved.source_id.iter().cloned().collect(),
},
)
.await?;
self.emitter.note_updated(&saved);
@@ -821,17 +849,19 @@ impl<R: Runtime + 'static> ResearchService<R> {
for link in &saved_links {
self.record_audit(
&workspace_id,
&link.id,
AuditEntityKind::Link,
"link_inferred",
match link.created_by {
LinkOrigin::Ai => AuditActor::Ai,
_ => AuditActor::System,
AuditRecord {
entity_id: &link.id,
entity_kind: AuditEntityKind::Link,
action: "link_inferred",
actor: match link.created_by {
LinkOrigin::Ai => AuditActor::Ai,
_ => AuditActor::System,
},
prior_revision: None,
new_revision: None,
job_id: Some(job.id.clone()),
source_ids: Vec::new(),
},
None,
None,
Some(job.id.clone()),
Vec::new(),
)
.await?;
}
@@ -857,14 +887,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
for ghost in &saved_ghosts {
self.record_audit(
&workspace_id,
&ghost.id,
AuditEntityKind::Ghost,
"ghost_generated",
AuditActor::Ai,
None,
None,
Some(job.id.clone()),
ghost.source_ids.clone(),
AuditRecord {
entity_id: &ghost.id,
entity_kind: AuditEntityKind::Ghost,
action: "ghost_generated",
actor: AuditActor::Ai,
prior_revision: None,
new_revision: None,
job_id: Some(job.id.clone()),
source_ids: ghost.source_ids.clone(),
},
)
.await?;
self.emitter.ghost_updated(ghost);
@@ -889,14 +921,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
self.repository.save_source(refreshed.clone()).await?;
self.record_audit(
&refreshed.workspace_id,
&refreshed.id,
AuditEntityKind::Source,
"source_metadata_refreshed",
AuditActor::System,
None,
None,
Some(job.id.clone()),
vec![refreshed.id.clone()],
AuditRecord {
entity_id: &refreshed.id,
entity_kind: AuditEntityKind::Source,
action: "source_metadata_refreshed",
actor: AuditActor::System,
prior_revision: None,
new_revision: None,
job_id: Some(job.id.clone()),
source_ids: vec![refreshed.id.clone()],
},
)
.await?;
Ok(())
@@ -917,8 +951,12 @@ impl<R: Runtime + 'static> ResearchService<R> {
defaults.get(&task_profile).and_then(|route| {
let model = if route.model.trim().is_empty() {
match route.provider {
crate::agent::AgentProviderKind::Remote => settings.remote.default_model.as_str(),
crate::agent::AgentProviderKind::Ollama => settings.ollama.default_model.as_str(),
crate::agent::AgentProviderKind::Remote => {
settings.remote.default_model.as_str()
}
crate::agent::AgentProviderKind::Ollama => {
settings.ollama.default_model.as_str()
}
}
} else {
route.model.as_str()
@@ -932,30 +970,19 @@ impl<R: Runtime + 'static> ResearchService<R> {
})
}
async fn record_audit(
&self,
workspace_id: &str,
entity_id: &str,
entity_kind: AuditEntityKind,
action: &str,
actor: AuditActor,
prior_revision: Option<u32>,
new_revision: Option<u32>,
job_id: Option<String>,
source_ids: Vec<String>,
) -> Result<()> {
async fn record_audit(&self, workspace_id: &str, record: AuditRecord<'_>) -> Result<()> {
self.repository
.append_audit_event(AuditEvent {
id: generate_id("audit"),
workspace_id: workspace_id.to_string(),
entity_id: entity_id.to_string(),
entity_kind,
action: action.to_string(),
actor,
prior_revision,
new_revision,
job_id,
source_ids,
entity_id: record.entity_id.to_string(),
entity_kind: record.entity_kind,
action: record.action.to_string(),
actor: record.actor,
prior_revision: record.prior_revision,
new_revision: record.new_revision,
job_id: record.job_id,
source_ids: record.source_ids,
detail: None,
created_at: now_rfc3339(),
})

View File

@@ -61,6 +61,8 @@ const PROVIDER_MODEL_OPTIONS: Record<AgentProviderKind, typeof DEFAULT_MODEL_OPT
ollama: OLLAMA_MODEL_OPTIONS,
};
const OLLAMA_EXAMPLE_MODELS = ['llama3.2', 'qwen3-coder', 'deepseek-r1:14b', 'mistral:latest'];
const mergeTaskDefaults = (
taskDefaults: Partial<Record<TaskProfile, AgentTaskRoute>>,
): Record<TaskProfile, AgentTaskRoute> =>
@@ -336,6 +338,22 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
[],
);
const applyProviderToAllTasks = useCallback((provider: AgentProviderKind) => {
setFormState((current) => ({
...current,
taskDefaults: TASK_PROFILES.reduce(
(nextRoutes, task) => {
nextRoutes[task] = {
provider,
model: '',
};
return nextRoutes;
},
{} as Record<TaskProfile, AgentTaskRoute>,
),
}));
}, []);
if (!status) {
return (
<div className="rounded-lg border border-term-border bg-term-surface px-6 py-4 text-sm font-mono text-term-text-muted">
@@ -553,6 +571,31 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
</p>
</div>
<div className="mb-6 rounded-lg border border-term-border-subtle bg-term-bg px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h4 className="text-sm font-mono font-semibold text-term-text">How to add a local model</h4>
<p className="mt-1.5 text-xs font-mono leading-6 text-term-text-muted">
1. Run `ollama pull llama3.2` or another Ollama tag in your terminal.
</p>
<p className="text-xs font-mono leading-6 text-term-text-muted">
2. Enter the exact model name below, like `llama3.2`, `qwen3-coder`, or `deepseek-r1:14b`.
</p>
<p className="text-xs font-mono leading-6 text-term-text-muted">
3. Save settings, then switch task routing to Local if you want Ollama used everywhere.
</p>
</div>
<button
type="button"
onClick={() => applyProviderToAllTasks('ollama')}
disabled={isBusy}
className="rounded border border-info bg-[var(--term-status-info)] px-4 py-2 text-xs font-mono text-info transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
Route All Tasks To Local
</button>
</div>
</div>
<div className="grid gap-5 md:grid-cols-2">
<label className="flex items-center gap-2 text-xs font-mono text-term-text">
<input
@@ -587,7 +630,7 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
<div className="md:col-span-2">
<label className="mb-2 block text-xs font-mono text-term-text-muted">
Default Local Model
Default Local Model / Ollama Tag
</label>
<ModelSelector
value={formState.ollama.defaultModel}
@@ -598,12 +641,34 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
}))
}
options={OLLAMA_MODEL_OPTIONS}
placeholder="Select or enter an Ollama model"
placeholder="Type the exact Ollama model tag or choose a preset"
disabled={isBusy}
/>
<p className="mt-1.5 text-xs font-mono text-term-text-muted">
Used when a task route points to Local (Ollama) and leaves its model blank.
</p>
<div className="mt-3 flex flex-wrap gap-2">
{OLLAMA_EXAMPLE_MODELS.map((model) => (
<button
key={model}
type="button"
onClick={() =>
setFormState((current) => ({
...current,
ollama: { ...current.ollama, defaultModel: model },
}))
}
disabled={isBusy}
className={`rounded border px-2.5 py-1 text-xs font-mono transition-colors ${
formState.ollama.defaultModel === model
? 'border-info bg-[var(--term-status-info)] text-info'
: 'border-term-border bg-term-bg text-term-text-muted hover:border-term-border-focus hover:text-term-text'
} disabled:cursor-not-allowed disabled:opacity-50`}
>
{model}
</button>
))}
</div>
</div>
</div>
</section>
@@ -619,6 +684,29 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
</p>
</div>
<div className="mb-6 flex flex-wrap items-center gap-3 rounded-lg border border-term-border-subtle bg-term-bg px-4 py-3">
<span className="text-xs font-mono text-term-text-muted">Quick apply:</span>
<button
type="button"
onClick={() => applyProviderToAllTasks('remote')}
disabled={isBusy}
className="rounded border border-term-border bg-term-surface px-3 py-1.5 text-xs font-mono text-term-text transition-colors hover:border-term-border-focus disabled:cursor-not-allowed disabled:opacity-50"
>
All Remote
</button>
<button
type="button"
onClick={() => applyProviderToAllTasks('ollama')}
disabled={isBusy}
className="rounded border border-info bg-[var(--term-status-info)] px-3 py-1.5 text-xs font-mono text-info transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
All Local
</button>
<span className="text-xs font-mono text-term-text-muted">
Clears per-task model overrides and makes every flow inherit the chosen provider default.
</span>
</div>
<div className="space-y-4">
{TASK_PROFILES.map((task) => {
const route = formState.taskDefaults[task];

View File

@@ -1,5 +1,5 @@
import React, { KeyboardEvent, useRef, useState } from 'react';
import { ChevronUp, ChevronDown, Search } from 'lucide-react';
import React, { KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, ChevronUp, PencilLine, Search } from 'lucide-react';
export interface ModelOption {
value: string;
@@ -47,139 +47,172 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
disabled = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [isCustomMode, setIsCustomMode] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const filteredOptions = options.filter((option) => {
const query = searchQuery.toLowerCase();
return (
option.label.toLowerCase().includes(query) ||
option.value.toLowerCase().includes(query) ||
option.provider?.toLowerCase().includes(query)
);
});
const filteredOptions = useMemo(() => {
const query = value.trim().toLowerCase();
if (!query) {
return options;
}
return options.filter((option) => {
const provider = option.provider?.toLowerCase() ?? '';
return (
option.label.toLowerCase().includes(query) ||
option.value.toLowerCase().includes(query) ||
provider.includes(query)
);
});
}, [options, value]);
const selectedOption = options.find((opt) => opt.value === value);
const displayOption =
selectedOption ??
(value.trim()
? {
value,
label: value,
provider: 'Custom',
}
: null);
const trimmedValue = value.trim();
const hasExactMatch = options.some(
(option) => option.value.toLowerCase() === trimmedValue.toLowerCase(),
);
useEffect(() => {
if (!isOpen) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
}, [isOpen]);
const handleSelect = (optionValue: string) => {
onChange(optionValue);
setIsOpen(false);
setSearchQuery('');
setIsCustomMode(false);
};
const handleCustomMode = () => {
setIsCustomMode(true);
const handleUseCustomValue = () => {
if (!trimmedValue) {
return;
}
onChange(trimmedValue);
setIsOpen(false);
setTimeout(() => inputRef.current?.focus(), 0);
inputRef.current?.focus();
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && isCustomMode) {
if (e.key === 'Enter') {
e.preventDefault();
if (value.trim()) {
setIsCustomMode(false);
if (filteredOptions.length === 1 && trimmedValue) {
handleSelect(filteredOptions[0].value);
return;
}
if (allowCustom && trimmedValue) {
handleUseCustomValue();
return;
}
}
if (e.key === 'ArrowDown' && !isOpen) {
e.preventDefault();
setIsOpen(true);
}
if (e.key === 'Escape') {
setIsOpen(false);
setIsCustomMode(false);
}
};
const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
if (!containerRef.current?.contains(e.relatedTarget)) {
setIsOpen(false);
if (!value.trim()) {
setIsCustomMode(false);
}
}
};
return (
<div
ref={containerRef}
className={`relative ${className}`}
onBlur={handleBlur}
>
{!isCustomMode ? (
<button
id={id}
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
className="w-full border border-term-border bg-term-surface px-3 py-2 text-left text-sm font-mono text-term-text outline-none transition-all hover:border-term-border-focus focus:border-info disabled:cursor-not-allowed disabled:opacity-50 border-l-2 focus:border-l-info hover:border-l-term-border-focus"
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<div className="flex items-center justify-between gap-3">
<span className="flex-1 min-w-0 truncate">
{displayOption ? (
<span>
{displayOption.label}
{displayOption.provider && (
<span className="ml-2 text-term-text-tertiary">{displayOption.provider}</span>
)}
</span>
) : (
<span className="text-term-text-tertiary">{placeholder}</span>
)}
</span>
<span className="text-term-text-tertiary flex-shrink-0" aria-hidden="true">
{isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</span>
</div>
</button>
) : (
<div ref={containerRef} className={`relative ${className}`}>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-term-text-tertiary" />
<input
id={id}
ref={inputRef}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={(e) => {
if (!e.target.value.trim()) {
setIsCustomMode(false);
onChange={(event) => {
onChange(event.target.value);
if (!disabled) {
setIsOpen(true);
}
}}
onFocus={() => !disabled && setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder="Enter custom model name"
className="w-full border border-info bg-term-surface px-3 py-2 text-sm font-mono text-term-text outline-none focus:border-l-info border-l-2 transition-all"
placeholder={placeholder}
disabled={disabled}
className="w-full border border-term-border bg-term-surface py-2 pl-8 pr-11 text-sm font-mono text-term-text outline-none transition-all hover:border-term-border-focus focus:border-info disabled:cursor-not-allowed disabled:opacity-50 border-l-2 focus:border-l-info hover:border-l-term-border-focus"
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-autocomplete="list"
/>
)}
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
if (!disabled) {
setIsOpen((current) => !current);
inputRef.current?.focus();
}
}}
disabled={disabled}
className="absolute right-2 top-1/2 -translate-y-1/2 text-term-text-tertiary transition-colors hover:text-term-text disabled:cursor-not-allowed disabled:opacity-50"
aria-label={isOpen ? 'Hide model suggestions' : 'Show model suggestions'}
>
{isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
</div>
{isOpen && !isCustomMode && (
<div className="mt-1 flex items-center justify-between gap-3 text-[11px] font-mono text-term-text-tertiary">
<span className="truncate">
{selectedOption
? `${selectedOption.label}${selectedOption.provider ? ` · ${selectedOption.provider}` : ''}`
: trimmedValue
? 'Custom model name'
: 'Type an exact model name or pick a suggestion'}
</span>
{allowCustom && trimmedValue && !hasExactMatch && (
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={handleUseCustomValue}
className="inline-flex flex-shrink-0 items-center gap-1 text-info transition-colors hover:text-term-text"
>
<PencilLine className="h-3 w-3" />
Use custom
</button>
)}
</div>
{isOpen && (
<div className="absolute z-10 mt-1 max-h-60 w-full overflow-auto border border-term-border bg-term-surface shadow-lg">
<div className="border-b border-term-border-subtle p-3">
<div className="relative group">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-term-text-tertiary group-focus-within:text-info transition-colors" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search models..."
className="w-full border-l-2 border-term-border-subtle bg-transparent py-1.5 pl-8 pr-2 text-sm font-mono text-term-text outline-none focus:border-l-info transition-colors"
autoFocus
/>
</div>
</div>
<ul role="listbox" className="py-1">
{allowCustom && trimmedValue && !hasExactMatch && (
<>
<li
role="option"
aria-selected={false}
onMouseDown={(event) => event.preventDefault()}
onClick={handleUseCustomValue}
className="cursor-pointer px-3 py-2 text-sm font-mono text-info transition-colors hover:bg-term-highlight"
>
<div className="flex items-center justify-between gap-3">
<span className="truncate">Use "{trimmedValue}" as a custom model</span>
<PencilLine className="h-3.5 w-3.5 flex-shrink-0" />
</div>
</li>
<li className="my-1 border-t border-term-border-subtle" role="separator" />
</>
)}
{filteredOptions.length === 0 ? (
<li className="px-3 py-4 text-center">
<div className="flex items-center justify-center gap-2 text-xs font-mono text-term-text-tertiary">
<div className="h-px w-8 bg-term-border" />
<span>No models found</span>
<span>{trimmedValue ? 'No preset matches' : 'No models found'}</span>
<div className="h-px w-8 bg-term-border" />
</div>
</li>
@@ -189,6 +222,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
key={option.value}
role="option"
aria-selected={value === option.value}
onMouseDown={(event) => event.preventDefault()}
onClick={() => handleSelect(option.value)}
className={`cursor-pointer px-3 py-2 text-sm font-mono transition-colors border-l-2 ${
value === option.value
@@ -197,26 +231,20 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}`}
>
<div className="flex items-center justify-between">
<span className="truncate">{option.label}</span>
{option.provider && (
<span className="ml-2 text-xs text-term-text-tertiary">{option.provider}</span>
)}
<div className="min-w-0">
<div className="truncate">{option.label}</div>
<div className="truncate text-xs text-term-text-tertiary">{option.value}</div>
</div>
<div className="ml-2 flex items-center gap-2">
{option.provider && (
<span className="text-xs text-term-text-tertiary">{option.provider}</span>
)}
{value === option.value && <Check className="h-3.5 w-3.5 text-info" />}
</div>
</div>
</li>
))
)}
{allowCustom && (
<>
<li className="my-1 border-t border-term-border-subtle" role="separator" />
<li
role="option"
onClick={handleCustomMode}
className="cursor-pointer px-3 py-2 text-sm font-mono text-term-text-muted transition-colors hover:bg-term-highlight hover:text-term-text"
>
+ Custom model...
</li>
</>
)}
</ul>
</div>
)}

View File

@@ -24,7 +24,6 @@ import {
Clock,
} from "lucide-react";
import {
AGENT_PROVIDER_LABELS,
AgentConfigStatus,
} from "../../types/agentSettings";
import { AgentSettingsForm } from "./AgentSettingsForm";