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::stream_events::AgentStreamEmitter;
use crate::agent::tools::terminal_command::{AgentCommandExecutor, RunTerminalCommandTool}; 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::error::AppError;
use crate::state::PendingAgentToolApprovals; use crate::state::PendingAgentToolApprovals;
@@ -120,8 +120,20 @@ impl ChatGateway for RigChatGateway {
} }
} }
fn build_openai_client(runtime: &AgentRuntimeConfig) -> Result<openai::CompletionsClient, AppError> { fn build_openai_client(
let api_key = runtime.api_key.clone().unwrap_or_default(); 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() openai::CompletionsClient::builder()
.api_key(api_key) .api_key(api_key)
@@ -152,9 +164,9 @@ where
reply.push_str(&text.text); reply.push_str(&text.text);
tool_runtime.stream_emitter.text_delta(text.text)?; tool_runtime.stream_emitter.text_delta(text.text)?;
} }
Ok(MultiTurnStreamItem::StreamAssistantItem( Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Reasoning(
StreamedAssistantContent::Reasoning(reasoning), reasoning,
)) => { ))) => {
if saw_reasoning_delta { if saw_reasoning_delta {
continue; continue;
} }
@@ -172,9 +184,9 @@ where
saw_reasoning_delta = true; saw_reasoning_delta = true;
tool_runtime.stream_emitter.reasoning_delta(reasoning)?; tool_runtime.stream_emitter.reasoning_delta(reasoning)?;
} }
Ok(MultiTurnStreamItem::StreamAssistantItem( Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::ToolCall {
StreamedAssistantContent::ToolCall { .. }, ..
)) => {} })) => {}
Ok(MultiTurnStreamItem::StreamAssistantItem( Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCallDelta { .. }, StreamedAssistantContent::ToolCallDelta { .. },
)) => {} )) => {}

View File

@@ -16,8 +16,8 @@ pub use stream_events::AgentStreamEmitter;
pub use types::{ pub use types::{
default_task_defaults, AgentConfigStatus, AgentProviderKind, AgentProviderStatuses, default_task_defaults, AgentConfigStatus, AgentProviderKind, AgentProviderStatuses,
AgentRuntimeConfig, AgentStoredSettings, AgentStreamItemEvent, AgentStreamItemKind, AgentRuntimeConfig, AgentStoredSettings, AgentStreamItemEvent, AgentStreamItemKind,
AgentTaskRoute, ChatPanelContext, ChatPromptRequest, ChatStreamStart, AgentTaskRoute, ChatPanelContext, ChatPromptRequest, ChatStreamStart, OllamaProviderSettings,
OllamaProviderSettings, PreparedChatTurn, ProviderConfigStatus, RemoteProviderSettings, PreparedChatTurn, ProviderConfigStatus, RemoteProviderSettings,
ResolveAgentToolApprovalRequest, SaveAgentRuntimeConfigRequest, TaskProfile, ResolveAgentToolApprovalRequest, SaveAgentRuntimeConfigRequest, TaskProfile,
UpdateRemoteApiKeyRequest, AGENT_SETTINGS_STORE_PATH, DEFAULT_OLLAMA_BASE_URL, UpdateRemoteApiKeyRequest, AGENT_SETTINGS_STORE_PATH, DEFAULT_OLLAMA_BASE_URL,
DEFAULT_OLLAMA_COMPAT_API_KEY, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, 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!( return Err(AppError::InvalidSettings(format!(
"{} is routed to Remote, but the Remote API key is missing.", "{} is routed to Remote, but the Remote API key is missing.",
task_label(task) task_label(task)
@@ -100,14 +101,14 @@ pub fn normalize_routes(settings: &mut AgentStoredSettings) -> Result<(), AppErr
pub fn compute_provider_configured( pub fn compute_provider_configured(
settings: &AgentStoredSettings, settings: &AgentStoredSettings,
provider: AgentProviderKind, provider_kind: AgentProviderKind,
) -> bool { ) -> bool {
let provider = provider_settings(settings, provider); let provider = provider_settings(settings, provider_kind);
provider.enabled() provider.enabled()
&& !provider.base_url().trim().is_empty() && !provider.base_url().trim().is_empty()
&& !provider.default_model().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 { pub fn compute_overall_configured(settings: &AgentStoredSettings) -> bool {
@@ -184,11 +185,6 @@ impl ProviderSettingsRef<'_> {
Self::Ollama(settings) => &settings.default_model, Self::Ollama(settings) => &settings.default_model,
} }
} }
fn uses_api_key(&self) -> bool {
matches!(self, Self::Remote(_))
}
fn label(&self) -> &'static str { fn label(&self) -> &'static str {
match self { match self {
Self::Remote(_) => "Remote", Self::Remote(_) => "Remote",

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,19 @@ pub enum AppError {
ModelMissing(TaskProfile), 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 { impl Display for AppError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self { match self {

View File

@@ -1,20 +1,15 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum NewsSentiment { pub enum NewsSentiment {
Bull, Bull,
Bear, Bear,
#[default]
Neutral, Neutral,
} }
impl Default for NewsSentiment {
fn default() -> Self {
Self::Neutral
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum HighlightReason { 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() { if model.trim().is_empty() {
return None; return None;
} }

View File

@@ -27,6 +27,19 @@ pub(crate) fn generate_ghost_notes(
ghosts 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( fn generate_missing_evidence_prompts(
workspace: &ResearchWorkspace, workspace: &ResearchWorkspace,
notes: &[ResearchNote], 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) link.from_note_id == note.id && matches!(link.link_type, LinkType::SourcedBy | LinkType::Supports)
}) })
}) })
.map(|note| ghost( .map(|note| {
workspace, ghost(
GhostNoteClass::MissingEvidencePrompt, workspace,
vec![note.id.clone()], GhostDraft {
Vec::new(), ghost_class: GhostNoteClass::MissingEvidencePrompt,
note.source_id.iter().cloned().collect(), supporting_note_ids: vec![note.id.clone()],
"Missing evidence for claim".to_string(), contradicting_note_ids: Vec::new(),
format!( source_ids: note.source_id.iter().cloned().collect(),
"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." 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(),
0.42, confidence: 0.42,
false, evidence_threshold_met: false,
GhostVisibilityState::Collapsed, visibility_state: GhostVisibilityState::Collapsed,
None, memo_section_hint: None,
)) },
)
})
.collect() .collect()
} }
@@ -73,19 +88,21 @@ fn generate_contradiction_alerts(
let right = notes_by_id.get(link.to_note_id.as_str())?; let right = notes_by_id.get(link.to_note_id.as_str())?;
Some(ghost( Some(ghost(
workspace, workspace,
GhostNoteClass::ContradictionAlert, GhostDraft {
vec![left.id.clone(), right.id.clone()], ghost_class: GhostNoteClass::ContradictionAlert,
vec![right.id.clone()], supporting_note_ids: vec![left.id.clone(), right.id.clone()],
collect_source_ids([*left, *right]), contradicting_note_ids: vec![right.id.clone()],
"Contradiction alert".to_string(), source_ids: collect_source_ids([*left, *right]),
format!( headline: "Contradiction alert".to_string(),
"Observed: {}. Inference: this conflicts with {}. What would confirm/refute: reconcile the newer datapoint, source freshness, and management framing before treating either statement as settled.", body: format!(
left.cleaned_text, right.cleaned_text "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, confidence: 0.86,
GhostVisibilityState::Visible, evidence_threshold_met: true,
Some(MemoSectionKind::RiskRegister), visibility_state: GhostVisibilityState::Visible,
memo_section_hint: Some(MemoSectionKind::RiskRegister),
},
)) ))
}) })
.collect() .collect()
@@ -114,16 +131,18 @@ fn generate_candidate_risks(
.collect::<Vec<_>>(); .collect::<Vec<_>>();
vec![ghost( vec![ghost(
workspace, workspace,
GhostNoteClass::CandidateRisk, GhostDraft {
supporting_ids, ghost_class: GhostNoteClass::CandidateRisk,
Vec::new(), supporting_note_ids: supporting_ids,
collect_source_ids(risk_notes.into_iter()), contradicting_note_ids: Vec::new(),
"Possible emerging risk cluster".to_string(), source_ids: collect_source_ids(risk_notes.into_iter()),
"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(), headline: "Possible emerging risk cluster".to_string(),
0.73, 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(),
true, confidence: 0.73,
GhostVisibilityState::Visible, evidence_threshold_met: true,
Some(MemoSectionKind::RiskRegister), visibility_state: GhostVisibilityState::Visible,
memo_section_hint: Some(MemoSectionKind::RiskRegister),
},
)] )]
} }
@@ -150,16 +169,18 @@ fn generate_candidate_catalysts(
.collect::<Vec<_>>(); .collect::<Vec<_>>();
vec![ghost( vec![ghost(
workspace, workspace,
GhostNoteClass::CandidateCatalyst, GhostDraft {
supporting_ids, ghost_class: GhostNoteClass::CandidateCatalyst,
Vec::new(), supporting_note_ids: supporting_ids,
collect_source_ids(catalyst_notes.into_iter()), contradicting_note_ids: Vec::new(),
"Possible catalyst cluster".to_string(), source_ids: collect_source_ids(catalyst_notes.into_iter()),
"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(), headline: "Possible catalyst cluster".to_string(),
0.7, 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(),
true, confidence: 0.7,
GhostVisibilityState::Visible, evidence_threshold_met: true,
Some(MemoSectionKind::CatalystCalendar), 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())); support.extend(driver_notes.iter().take(2).map(|note| note.id.clone()));
vec![ghost( vec![ghost(
workspace, workspace,
GhostNoteClass::ValuationBridge, GhostDraft {
support, ghost_class: GhostNoteClass::ValuationBridge,
Vec::new(), supporting_note_ids: support,
collect_source_ids(valuation_notes.into_iter().chain(driver_notes.into_iter())), contradicting_note_ids: Vec::new(),
"Possible valuation bridge".to_string(), source_ids: collect_source_ids(valuation_notes.into_iter().chain(driver_notes.into_iter())),
"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(), headline: "Possible valuation bridge".to_string(),
0.76, 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(),
true, confidence: 0.76,
GhostVisibilityState::Visible, evidence_threshold_met: true,
Some(MemoSectionKind::ValuationWriteUp), visibility_state: GhostVisibilityState::Visible,
memo_section_hint: Some(MemoSectionKind::ValuationWriteUp),
},
)] )]
} }
@@ -270,20 +293,22 @@ fn generate_candidate_thesis(
Some(ghost( Some(ghost(
workspace, workspace,
GhostNoteClass::CandidateThesis, GhostDraft {
notes.iter().take(6).map(|note| note.id.clone()).collect(), ghost_class: GhostNoteClass::CandidateThesis,
Vec::new(), supporting_note_ids: notes.iter().take(6).map(|note| note.id.clone()).collect(),
collect_source_ids(notes.iter()), contradicting_note_ids: Vec::new(),
headline.to_string(), source_ids: collect_source_ids(notes.iter()),
body.to_string(), headline: headline.to_string(),
if has_unresolved_contradiction { body: body.to_string(),
0.68 confidence: if has_unresolved_contradiction {
} else { 0.68
0.82 } 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( fn ghost(workspace: &ResearchWorkspace, draft: GhostDraft) -> GhostNote {
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 {
let now = now_rfc3339(); let now = now_rfc3339();
let mut key_parts = BTreeSet::new(); let mut key_parts = BTreeSet::new();
key_parts.extend(supporting_note_ids.iter().cloned()); key_parts.extend(draft.supporting_note_ids.iter().cloned());
key_parts.extend(contradicting_note_ids.iter().cloned()); key_parts.extend(draft.contradicting_note_ids.iter().cloned());
let ghost_key = format!( let ghost_key = format!(
"{ghost_class:?}-{}", "{:?}-{}",
draft.ghost_class,
key_parts.into_iter().collect::<Vec<_>>().join(",") key_parts.into_iter().collect::<Vec<_>>().join(",")
); );
GhostNote { GhostNote {
id: format!("ghost-{}", &sha256_hex(&ghost_key)[..16]), id: format!("ghost-{}", &sha256_hex(&ghost_key)[..16]),
workspace_id: workspace.id.clone(), workspace_id: workspace.id.clone(),
ghost_class, ghost_class: draft.ghost_class,
headline, headline: draft.headline,
body, body: draft.body,
tone: GhostTone::Tentative, tone: GhostTone::Tentative,
confidence, confidence: draft.confidence,
visibility_state, visibility_state: draft.visibility_state,
state: GhostLifecycleState::Generated, state: GhostLifecycleState::Generated,
supporting_note_ids, supporting_note_ids: draft.supporting_note_ids,
contradicting_note_ids, contradicting_note_ids: draft.contradicting_note_ids,
source_ids, source_ids: draft.source_ids,
evidence_threshold_met, evidence_threshold_met: draft.evidence_threshold_met,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
superseded_by_ghost_id: None, superseded_by_ghost_id: None,
promoted_note_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, ArchiveResearchNoteRequest, AuditActor, AuditEntityKind, AuditEvent, CaptureMethod,
CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, EvidenceStatus, CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, EvidenceStatus,
ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetNotesByTickerRequest, ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetNotesByTickerRequest,
GetWorkspaceProjectionRequest, GhostLifecycleState, GhostReviewAction, GhostStatus, GetWorkspaceProjectionRequest, GhostLifecycleState, GhostReviewAction, GhostStatus, LinkOrigin,
LinkOrigin, ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, NoteAuditTrail,
NoteAuditTrail, NoteCaptureResult, NotePriority, NoteType, PromoteNoteToThesisRequest, NoteCaptureResult, NotePriority, NoteType, PromoteNoteToThesisRequest, ProvenanceActor,
ProvenanceActor, ResearchBundleExport, ResearchNote, ResearchWorkspace, RetryResearchJobsRequest, ResearchBundleExport, ResearchNote, ResearchWorkspace, RetryResearchJobsRequest,
ReviewGhostNoteRequest, SourceKind, ThesisStatus, UpdateResearchNoteRequest, WorkspaceProjection, ReviewGhostNoteRequest, SourceKind, ThesisStatus, UpdateResearchNoteRequest,
WorkspaceScope, WorkspaceProjection, WorkspaceScope,
}; };
use crate::research::util::{generate_id, normalize_text, now_rfc3339, sha256_hex}; 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<()>>, 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> { impl<R: Runtime> Clone for ResearchService<R> {
fn clone(&self) -> Self { fn clone(&self) -> Self {
Self { Self {
@@ -101,14 +112,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
let saved = self.repository.create_workspace(workspace.clone()).await?; let saved = self.repository.create_workspace(workspace.clone()).await?;
self.record_audit( self.record_audit(
&saved.id, &saved.id,
&saved.id, AuditRecord {
AuditEntityKind::Workspace, entity_id: &saved.id,
"workspace_created", entity_kind: AuditEntityKind::Workspace,
AuditActor::Analyst, action: "workspace_created",
None, actor: AuditActor::Analyst,
None, prior_revision: None,
None, new_revision: None,
Vec::new(), job_id: None,
source_ids: Vec::new(),
},
) )
.await?; .await?;
self.emitter.workspace_updated(&saved); self.emitter.workspace_updated(&saved);
@@ -202,14 +215,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
self.repository.create_note(source_note.clone()).await?; self.repository.create_note(source_note.clone()).await?;
self.record_audit( self.record_audit(
&workspace.id, &workspace.id,
&source_note.id, AuditRecord {
AuditEntityKind::Note, entity_id: &source_note.id,
"source_reference_note_created", entity_kind: AuditEntityKind::Note,
AuditActor::System, action: "source_reference_note_created",
None, actor: AuditActor::System,
Some(source_note.revision), prior_revision: None,
None, new_revision: Some(source_note.revision),
source_note.source_id.iter().cloned().collect(), job_id: None,
source_ids: source_note.source_id.iter().cloned().collect(),
},
) )
.await?; .await?;
self.emitter.note_updated(&source_note); self.emitter.note_updated(&source_note);
@@ -279,27 +294,31 @@ impl<R: Runtime + 'static> ResearchService<R> {
.await?; .await?;
self.record_audit( self.record_audit(
&workspace.id, &workspace.id,
&saved.id, AuditRecord {
AuditEntityKind::Note, entity_id: &saved.id,
"note_captured", entity_kind: AuditEntityKind::Note,
AuditActor::Analyst, action: "note_captured",
None, actor: AuditActor::Analyst,
Some(saved.revision), prior_revision: None,
None, new_revision: Some(saved.revision),
source_id.iter().cloned().collect(), job_id: None,
source_ids: source_id.iter().cloned().collect(),
},
) )
.await?; .await?;
for job in &queued_jobs { for job in &queued_jobs {
self.record_audit( self.record_audit(
&workspace.id, &workspace.id,
&job.id, AuditRecord {
AuditEntityKind::Job, entity_id: &job.id,
"job_enqueued", entity_kind: AuditEntityKind::Job,
AuditActor::System, action: "job_enqueued",
None, actor: AuditActor::System,
None, prior_revision: None,
Some(job.id.clone()), new_revision: None,
Vec::new(), job_id: Some(job.id.clone()),
source_ids: Vec::new(),
},
) )
.await?; .await?;
self.emitter.job_updated(job); self.emitter.job_updated(job);
@@ -377,14 +396,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
.await?; .await?;
self.record_audit( self.record_audit(
&saved.workspace_id, &saved.workspace_id,
&saved.id, AuditRecord {
AuditEntityKind::Note, entity_id: &saved.id,
"note_updated", entity_kind: AuditEntityKind::Note,
AuditActor::Analyst, action: "note_updated",
Some(prior_revision), actor: AuditActor::Analyst,
Some(saved.revision), prior_revision: Some(prior_revision),
None, new_revision: Some(saved.revision),
saved.source_id.iter().cloned().collect(), job_id: None,
source_ids: saved.source_id.iter().cloned().collect(),
},
) )
.await?; .await?;
self.emitter.note_updated(&saved); self.emitter.note_updated(&saved);
@@ -402,18 +423,20 @@ impl<R: Runtime + 'static> ResearchService<R> {
.await?; .await?;
self.record_audit( self.record_audit(
&note.workspace_id, &note.workspace_id,
&note.id, AuditRecord {
AuditEntityKind::Note, entity_id: &note.id,
if request.archived { entity_kind: AuditEntityKind::Note,
"note_archived" action: if request.archived {
} else { "note_archived"
"note_unarchived" } 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?; .await?;
self.emitter.note_updated(&note); self.emitter.note_updated(&note);
@@ -442,8 +465,7 @@ impl<R: Runtime + 'static> ResearchService<R> {
.into_iter() .into_iter()
.filter(|ws| { .filter(|ws| {
ws.primary_ticker.to_uppercase() == normalized_ticker ws.primary_ticker.to_uppercase() == normalized_ticker
|| ws.primary_ticker.to_uppercase() || ws.primary_ticker.to_uppercase() == format!("{}-US", normalized_ticker)
== format!("{}-US", normalized_ticker)
}) })
.collect(); .collect();
@@ -547,14 +569,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
let saved = self.repository.save_ghost(ghost).await?; let saved = self.repository.save_ghost(ghost).await?;
self.record_audit( self.record_audit(
&saved.workspace_id, &saved.workspace_id,
&saved.id, AuditRecord {
AuditEntityKind::Ghost, entity_id: &saved.id,
"ghost_reviewed", entity_kind: AuditEntityKind::Ghost,
AuditActor::Analyst, action: "ghost_reviewed",
None, actor: AuditActor::Analyst,
None, prior_revision: None,
None, new_revision: None,
saved.source_ids.clone(), job_id: None,
source_ids: saved.source_ids.clone(),
},
) )
.await?; .await?;
self.emitter.ghost_updated(&saved); self.emitter.ghost_updated(&saved);
@@ -575,14 +599,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
let saved = self.repository.save_note(note).await?; let saved = self.repository.save_note(note).await?;
self.record_audit( self.record_audit(
&saved.workspace_id, &saved.workspace_id,
&saved.id, AuditRecord {
AuditEntityKind::Note, entity_id: &saved.id,
"note_promoted_to_thesis", entity_kind: AuditEntityKind::Note,
AuditActor::Analyst, action: "note_promoted_to_thesis",
Some(prior_revision), actor: AuditActor::Analyst,
Some(saved.revision), prior_revision: Some(prior_revision),
None, new_revision: Some(saved.revision),
saved.source_id.iter().cloned().collect(), job_id: None,
source_ids: saved.source_id.iter().cloned().collect(),
},
) )
.await?; .await?;
self.emitter.note_updated(&saved); self.emitter.note_updated(&saved);
@@ -767,14 +793,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
let saved = self.repository.save_note(note).await?; let saved = self.repository.save_note(note).await?;
self.record_audit( self.record_audit(
&saved.workspace_id, &saved.workspace_id,
&saved.id, AuditRecord {
AuditEntityKind::Note, entity_id: &saved.id,
"note_enriched", entity_kind: AuditEntityKind::Note,
AuditActor::Ai, action: "note_enriched",
Some(expected_revision), actor: AuditActor::Ai,
Some(saved.revision), prior_revision: Some(expected_revision),
Some(job.id.clone()), new_revision: Some(saved.revision),
saved.source_id.iter().cloned().collect(), job_id: Some(job.id.clone()),
source_ids: saved.source_id.iter().cloned().collect(),
},
) )
.await?; .await?;
self.emitter.note_updated(&saved); self.emitter.note_updated(&saved);
@@ -821,17 +849,19 @@ impl<R: Runtime + 'static> ResearchService<R> {
for link in &saved_links { for link in &saved_links {
self.record_audit( self.record_audit(
&workspace_id, &workspace_id,
&link.id, AuditRecord {
AuditEntityKind::Link, entity_id: &link.id,
"link_inferred", entity_kind: AuditEntityKind::Link,
match link.created_by { action: "link_inferred",
LinkOrigin::Ai => AuditActor::Ai, actor: match link.created_by {
_ => AuditActor::System, 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?; .await?;
} }
@@ -857,14 +887,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
for ghost in &saved_ghosts { for ghost in &saved_ghosts {
self.record_audit( self.record_audit(
&workspace_id, &workspace_id,
&ghost.id, AuditRecord {
AuditEntityKind::Ghost, entity_id: &ghost.id,
"ghost_generated", entity_kind: AuditEntityKind::Ghost,
AuditActor::Ai, action: "ghost_generated",
None, actor: AuditActor::Ai,
None, prior_revision: None,
Some(job.id.clone()), new_revision: None,
ghost.source_ids.clone(), job_id: Some(job.id.clone()),
source_ids: ghost.source_ids.clone(),
},
) )
.await?; .await?;
self.emitter.ghost_updated(ghost); self.emitter.ghost_updated(ghost);
@@ -889,14 +921,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
self.repository.save_source(refreshed.clone()).await?; self.repository.save_source(refreshed.clone()).await?;
self.record_audit( self.record_audit(
&refreshed.workspace_id, &refreshed.workspace_id,
&refreshed.id, AuditRecord {
AuditEntityKind::Source, entity_id: &refreshed.id,
"source_metadata_refreshed", entity_kind: AuditEntityKind::Source,
AuditActor::System, action: "source_metadata_refreshed",
None, actor: AuditActor::System,
None, prior_revision: None,
Some(job.id.clone()), new_revision: None,
vec![refreshed.id.clone()], job_id: Some(job.id.clone()),
source_ids: vec![refreshed.id.clone()],
},
) )
.await?; .await?;
Ok(()) Ok(())
@@ -917,8 +951,12 @@ impl<R: Runtime + 'static> ResearchService<R> {
defaults.get(&task_profile).and_then(|route| { defaults.get(&task_profile).and_then(|route| {
let model = if route.model.trim().is_empty() { let model = if route.model.trim().is_empty() {
match route.provider { match route.provider {
crate::agent::AgentProviderKind::Remote => settings.remote.default_model.as_str(), crate::agent::AgentProviderKind::Remote => {
crate::agent::AgentProviderKind::Ollama => settings.ollama.default_model.as_str(), settings.remote.default_model.as_str()
}
crate::agent::AgentProviderKind::Ollama => {
settings.ollama.default_model.as_str()
}
} }
} else { } else {
route.model.as_str() route.model.as_str()
@@ -932,30 +970,19 @@ impl<R: Runtime + 'static> ResearchService<R> {
}) })
} }
async fn record_audit( async fn record_audit(&self, workspace_id: &str, record: AuditRecord<'_>) -> Result<()> {
&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<()> {
self.repository self.repository
.append_audit_event(AuditEvent { .append_audit_event(AuditEvent {
id: generate_id("audit"), id: generate_id("audit"),
workspace_id: workspace_id.to_string(), workspace_id: workspace_id.to_string(),
entity_id: entity_id.to_string(), entity_id: record.entity_id.to_string(),
entity_kind, entity_kind: record.entity_kind,
action: action.to_string(), action: record.action.to_string(),
actor, actor: record.actor,
prior_revision, prior_revision: record.prior_revision,
new_revision, new_revision: record.new_revision,
job_id, job_id: record.job_id,
source_ids, source_ids: record.source_ids,
detail: None, detail: None,
created_at: now_rfc3339(), created_at: now_rfc3339(),
}) })

View File

@@ -61,6 +61,8 @@ const PROVIDER_MODEL_OPTIONS: Record<AgentProviderKind, typeof DEFAULT_MODEL_OPT
ollama: OLLAMA_MODEL_OPTIONS, ollama: OLLAMA_MODEL_OPTIONS,
}; };
const OLLAMA_EXAMPLE_MODELS = ['llama3.2', 'qwen3-coder', 'deepseek-r1:14b', 'mistral:latest'];
const mergeTaskDefaults = ( const mergeTaskDefaults = (
taskDefaults: Partial<Record<TaskProfile, AgentTaskRoute>>, taskDefaults: Partial<Record<TaskProfile, AgentTaskRoute>>,
): 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) { if (!status) {
return ( return (
<div className="rounded-lg border border-term-border bg-term-surface px-6 py-4 text-sm font-mono text-term-text-muted"> <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> </p>
</div> </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"> <div className="grid gap-5 md:grid-cols-2">
<label className="flex items-center gap-2 text-xs font-mono text-term-text"> <label className="flex items-center gap-2 text-xs font-mono text-term-text">
<input <input
@@ -587,7 +630,7 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="mb-2 block text-xs font-mono text-term-text-muted"> <label className="mb-2 block text-xs font-mono text-term-text-muted">
Default Local Model Default Local Model / Ollama Tag
</label> </label>
<ModelSelector <ModelSelector
value={formState.ollama.defaultModel} value={formState.ollama.defaultModel}
@@ -598,12 +641,34 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
})) }))
} }
options={OLLAMA_MODEL_OPTIONS} options={OLLAMA_MODEL_OPTIONS}
placeholder="Select or enter an Ollama model" placeholder="Type the exact Ollama model tag or choose a preset"
disabled={isBusy} disabled={isBusy}
/> />
<p className="mt-1.5 text-xs font-mono text-term-text-muted"> <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. Used when a task route points to Local (Ollama) and leaves its model blank.
</p> </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>
</div> </div>
</section> </section>
@@ -619,6 +684,29 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
</p> </p>
</div> </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"> <div className="space-y-4">
{TASK_PROFILES.map((task) => { {TASK_PROFILES.map((task) => {
const route = formState.taskDefaults[task]; const route = formState.taskDefaults[task];

View File

@@ -1,5 +1,5 @@
import React, { KeyboardEvent, useRef, useState } from 'react'; import React, { KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronUp, ChevronDown, Search } from 'lucide-react'; import { Check, ChevronDown, ChevronUp, PencilLine, Search } from 'lucide-react';
export interface ModelOption { export interface ModelOption {
value: string; value: string;
@@ -47,139 +47,172 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
disabled = false, disabled = false,
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [isCustomMode, setIsCustomMode] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const filteredOptions = options.filter((option) => { const filteredOptions = useMemo(() => {
const query = searchQuery.toLowerCase(); const query = value.trim().toLowerCase();
return ( if (!query) {
option.label.toLowerCase().includes(query) || return options;
option.value.toLowerCase().includes(query) || }
option.provider?.toLowerCase().includes(query)
); 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 selectedOption = options.find((opt) => opt.value === value);
const displayOption = const trimmedValue = value.trim();
selectedOption ?? const hasExactMatch = options.some(
(value.trim() (option) => option.value.toLowerCase() === trimmedValue.toLowerCase(),
? { );
value,
label: value, useEffect(() => {
provider: 'Custom', if (!isOpen) {
} return;
: null); }
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) => { const handleSelect = (optionValue: string) => {
onChange(optionValue); onChange(optionValue);
setIsOpen(false); setIsOpen(false);
setSearchQuery('');
setIsCustomMode(false);
}; };
const handleCustomMode = () => { const handleUseCustomValue = () => {
setIsCustomMode(true); if (!trimmedValue) {
return;
}
onChange(trimmedValue);
setIsOpen(false); setIsOpen(false);
setTimeout(() => inputRef.current?.focus(), 0); inputRef.current?.focus();
}; };
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && isCustomMode) { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
if (value.trim()) { if (filteredOptions.length === 1 && trimmedValue) {
setIsCustomMode(false); handleSelect(filteredOptions[0].value);
return;
}
if (allowCustom && trimmedValue) {
handleUseCustomValue();
return;
} }
} }
if (e.key === 'ArrowDown' && !isOpen) {
e.preventDefault();
setIsOpen(true);
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
setIsOpen(false); setIsOpen(false);
setIsCustomMode(false);
}
};
const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
if (!containerRef.current?.contains(e.relatedTarget)) {
setIsOpen(false);
if (!value.trim()) {
setIsCustomMode(false);
}
} }
}; };
return ( return (
<div <div ref={containerRef} className={`relative ${className}`}>
ref={containerRef} <div className="relative">
className={`relative ${className}`} <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" />
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>
) : (
<input <input
id={id} id={id}
ref={inputRef} ref={inputRef}
type="text" type="text"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(event) => {
onBlur={(e) => { onChange(event.target.value);
if (!e.target.value.trim()) { if (!disabled) {
setIsCustomMode(false); setIsOpen(true);
} }
}} }}
onFocus={() => !disabled && setIsOpen(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Enter custom model name" placeholder={placeholder}
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" 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="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"> <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 ? ( {filteredOptions.length === 0 ? (
<li className="px-3 py-4 text-center"> <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="flex items-center justify-center gap-2 text-xs font-mono text-term-text-tertiary">
<div className="h-px w-8 bg-term-border" /> <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 className="h-px w-8 bg-term-border" />
</div> </div>
</li> </li>
@@ -189,6 +222,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
key={option.value} key={option.value}
role="option" role="option"
aria-selected={value === option.value} aria-selected={value === option.value}
onMouseDown={(event) => event.preventDefault()}
onClick={() => handleSelect(option.value)} onClick={() => handleSelect(option.value)}
className={`cursor-pointer px-3 py-2 text-sm font-mono transition-colors border-l-2 ${ className={`cursor-pointer px-3 py-2 text-sm font-mono transition-colors border-l-2 ${
value === option.value value === option.value
@@ -197,26 +231,20 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}`} }`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="truncate">{option.label}</span> <div className="min-w-0">
{option.provider && ( <div className="truncate">{option.label}</div>
<span className="ml-2 text-xs text-term-text-tertiary">{option.provider}</span> <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> </div>
</li> </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> </ul>
</div> </div>
)} )}

View File

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