Fix local model setup UX and clean Rust warnings
This commit is contained in:
@@ -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 { .. },
|
||||
)) => {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
¬e.workspace_id,
|
||||
¬e.id,
|
||||
AuditEntityKind::Note,
|
||||
if request.archived {
|
||||
"note_archived"
|
||||
} else {
|
||||
"note_unarchived"
|
||||
AuditRecord {
|
||||
entity_id: ¬e.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(¬e);
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
AGENT_PROVIDER_LABELS,
|
||||
AgentConfigStatus,
|
||||
} from "../../types/agentSettings";
|
||||
import { AgentSettingsForm } from "./AgentSettingsForm";
|
||||
|
||||
Reference in New Issue
Block a user