Merge branch 'worktree-agent-af5a53986b9441608' into feature_chore_bug_005
# Conflicts: # src-tauri/src/llm/mod.rs # src/lib/views/Settings.svelte # src/lib/views/SummaryPanel.svelte
This commit is contained in:
commit
e966181ab8
9 files changed
+1207
-72
No files matched your search
@@ -320,11 +320,12 @@ segment (the M1 grounding invariant, asserted by the golden-transcript test).
|
|||||||
"consent_acknowledged": false, // set true after the one-time consent notice (FR-REC-2)
|
"consent_acknowledged": false, // set true after the one-time consent notice (FR-REC-2)
|
||||||
},
|
},
|
||||||
"llm": {
|
"llm": {
|
||||||
"provider": "ollama", // ollama|custom|anthropic|openai|off (ADR-0007/0011)
|
"provider": "ollama", // ollama|custom|anthropic|openai|off (ADR-0007/0011; "openai" not yet wired)
|
||||||
"endpoint": "http://localhost:11434",
|
"endpoint": "http://localhost:11434",
|
||||||
"model": "llama3",
|
"model": "llama3",
|
||||||
"stream": true,
|
"stream": true,
|
||||||
// API keys for hosted providers (anthropic|openai) live in the OS credential store, not here.
|
// API keys for hosted providers (anthropic|openai) live in the OS credential store, not here.
|
||||||
|
"hosted_ai_acknowledged": false, // one-time "data leaves your device" notice ack (T10.3, ADR-0011)
|
||||||
},
|
},
|
||||||
// Sync target rows live in wa.db (sync_targets); secrets live in the OS credential store.
|
// Sync target rows live in wa.db (sync_targets); secrets live in the OS credential store.
|
||||||
// settings.json only holds the global default. No credentials here (FR-SYNC-6).
|
// settings.json only holds the global default. No credentials here (FR-SYNC-6).
|
||||||
|
|||||||
+158
-19
@@ -57,6 +57,7 @@ fn default_settings() -> Settings {
|
|||||||
low_overhead: false,
|
low_overhead: false,
|
||||||
default_record: false,
|
default_record: false,
|
||||||
consent_acknowledged: false,
|
consent_acknowledged: false,
|
||||||
|
hosted_ai_acknowledged: false,
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
retention_max_age_days: None,
|
retention_max_age_days: None,
|
||||||
retention_max_size_gb: None,
|
retention_max_size_gb: None,
|
||||||
@@ -1972,6 +1973,18 @@ fn llm_provider_from_settings(settings: &Settings) -> Option<Box<dyn crate::llm:
|
|||||||
model: settings.llm_model.clone(),
|
model: settings.llm_model.clone(),
|
||||||
credential_ref: None, // local, unauthenticated custom endpoint (ADR-0007); Phase 10a sets this for hosted gateways
|
credential_ref: None, // local, unauthenticated custom endpoint (ADR-0007); Phase 10a sets this for hosted gateways
|
||||||
})),
|
})),
|
||||||
|
// Hosted (ADR-0011, M3.1/M3.2): the key is never read from settings —
|
||||||
|
// only `set_llm_provider` writes it, straight to the OS credential
|
||||||
|
// store via `ANTHROPIC_CREDENTIAL_REF`. Selecting "anthropic" here is
|
||||||
|
// itself the "configured" signal that puts api.anthropic.com on the
|
||||||
|
// egress allowlist (see `privacy_self_check_json` below, which reuses
|
||||||
|
// this same `is_local()` check unconditionally for every provider —
|
||||||
|
// no anthropic-specific branch needed there).
|
||||||
|
"anthropic" => Some(Box::new(crate::llm::AnthropicProvider {
|
||||||
|
model: settings.llm_model.clone(),
|
||||||
|
credential_ref: crate::llm::ANTHROPIC_CREDENTIAL_REF.to_string(),
|
||||||
|
endpoint: settings.llm_endpoint.clone(),
|
||||||
|
})),
|
||||||
_ => None, // "off"
|
_ => None, // "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1994,34 +2007,87 @@ pub async fn llm_status() -> WaResult<serde_json::Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SetLlmProviderArgs {
|
pub struct SetLlmProviderArgs {
|
||||||
pub provider: String, // "ollama" | "custom" | "off" (Phase 10a adds "anthropic" | "openai")
|
pub provider: String, // "ollama" | "custom" | "anthropic" | "off" ("openai" not yet wired — M3 scope is Anthropic)
|
||||||
pub endpoint: Option<String>,
|
pub endpoint: Option<String>,
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
|
/// Hosted providers only (Anthropic, M3.2). Consumed here to write the
|
||||||
|
/// OS credential store and then dropped — never assigned into `Settings`,
|
||||||
|
/// so it structurally cannot reach settings.json/wa.db (ADR-0011,
|
||||||
|
/// FR-SEC-1). Optional on every call so re-saving the model/endpoint
|
||||||
|
/// doesn't force re-entering a key that's already stored.
|
||||||
|
pub api_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select/configure the LLM provider (T5.2, FR-LLM-1). Hosted providers
|
/// Pure settings mutation for `set_llm_provider` — everything except the
|
||||||
/// (`apiKey`, stored only in the OS credential store — never settings/DB)
|
/// credential-store write, so it's unit-testable without touching a real
|
||||||
/// land in Phase 10a.
|
/// keyring and so the "the API key never reaches `Settings`" guarantee is
|
||||||
#[tauri::command]
|
/// checkable structurally (this function's signature has no key parameter
|
||||||
pub async fn set_llm_provider(args: SetLlmProviderArgs) -> WaResult<()> {
|
/// at all; see `set_llm_provider_never_writes_the_api_key_into_settings`).
|
||||||
if !matches!(args.provider.as_str(), "ollama" | "custom" | "off") {
|
/// Anthropic's endpoint is fixed, not user-configurable (ADR-0011) — any
|
||||||
return Err(WaError::new(
|
/// `endpoint` argument is ignored for that provider so a stale endpoint left
|
||||||
"llm",
|
/// over from a previous "custom"/"ollama" selection can't leak through.
|
||||||
format!(
|
/// Symmetrically, switching *away* from Anthropic without supplying a new
|
||||||
"provider '{}' is not available yet — hosted providers land in Phase 10a",
|
/// endpoint resets to Ollama's own local default rather than silently
|
||||||
args.provider
|
/// keeping its fixed hosted URL around — otherwise a quick per-use provider
|
||||||
),
|
/// switch (SummaryPanel, M3.3) that only sends `provider` could leave
|
||||||
));
|
/// `llm_endpoint` pointed at a third party for a provider that has no
|
||||||
}
|
/// business talking to it.
|
||||||
let mut settings = load_settings();
|
fn apply_llm_provider_args(
|
||||||
settings.llm_provider = args.provider;
|
settings: &mut Settings,
|
||||||
if let Some(endpoint) = args.endpoint {
|
provider: &str,
|
||||||
|
endpoint: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
|
) {
|
||||||
|
settings.llm_provider = provider.to_string();
|
||||||
|
if provider == "anthropic" {
|
||||||
|
settings.llm_endpoint = "https://api.anthropic.com".to_string();
|
||||||
|
} else if let Some(endpoint) = endpoint {
|
||||||
settings.llm_endpoint = endpoint;
|
settings.llm_endpoint = endpoint;
|
||||||
|
} else if settings.llm_endpoint == "https://api.anthropic.com" {
|
||||||
|
settings.llm_endpoint = "http://localhost:11434".to_string();
|
||||||
}
|
}
|
||||||
if let Some(model) = args.model {
|
if let Some(model) = model {
|
||||||
settings.llm_model = model;
|
settings.llm_model = model;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select/configure the LLM provider (T5.2/T10.2, FR-LLM-1, ADR-0011).
|
||||||
|
/// Hosted providers (`apiKey`) are stored only in the OS credential store —
|
||||||
|
/// never settings/DB (FR-SEC-1); selecting "anthropic" here is itself what
|
||||||
|
/// puts `api.anthropic.com` on the settings-derived egress allowlist (see
|
||||||
|
/// `privacy_self_check_json`), so it must never happen implicitly.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_llm_provider(args: SetLlmProviderArgs) -> WaResult<()> {
|
||||||
|
if !matches!(
|
||||||
|
args.provider.as_str(),
|
||||||
|
"ollama" | "custom" | "anthropic" | "off"
|
||||||
|
) {
|
||||||
|
return Err(WaError::new(
|
||||||
|
"llm",
|
||||||
|
format!("provider '{}' is not available yet", args.provider),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if args.provider == "anthropic" {
|
||||||
|
match args.api_key.as_deref().map(str::trim) {
|
||||||
|
Some(key) if !key.is_empty() => {
|
||||||
|
crate::llm::credentials::set(crate::llm::ANTHROPIC_CREDENTIAL_REF, key)
|
||||||
|
.map_err(|e| WaError::new("llm", e.to_string()))?;
|
||||||
|
}
|
||||||
|
// No new key supplied — fine only if one was already stored from
|
||||||
|
// a previous call (e.g. the user is just changing the model).
|
||||||
|
_ if crate::llm::credentials::get(crate::llm::ANTHROPIC_CREDENTIAL_REF).is_err() => {
|
||||||
|
return Err(WaError::new(
|
||||||
|
"llm",
|
||||||
|
"an Anthropic API key is required".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut settings = load_settings();
|
||||||
|
apply_llm_provider_args(&mut settings, &args.provider, args.endpoint, args.model);
|
||||||
save_settings(&settings)
|
save_settings(&settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3725,6 +3791,79 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn privacy_self_check_anthropic_host_joins_the_allowlist_only_once_selected() {
|
||||||
|
// Off by default (ADR-0011) — merely *knowing about* Anthropic (the
|
||||||
|
// provider code exists) must not put its host on the allowlist.
|
||||||
|
let result = privacy_self_check_json(&default_settings(), Vec::new());
|
||||||
|
assert_eq!(result["allowlistedHosts"], serde_json::json!([]));
|
||||||
|
|
||||||
|
// Only once the user has actually selected it (mirrors what
|
||||||
|
// set_llm_provider/apply_llm_provider_args writes) does the same
|
||||||
|
// generic llm_provider_from_settings + is_local() path used for every
|
||||||
|
// other provider pick it up — no anthropic-specific allowlist code.
|
||||||
|
let mut settings = default_settings();
|
||||||
|
settings.llm_provider = "anthropic".to_string();
|
||||||
|
settings.llm_endpoint = "https://api.anthropic.com".to_string();
|
||||||
|
let result = privacy_self_check_json(&settings, Vec::new());
|
||||||
|
assert_eq!(result["llmIsLocal"], false);
|
||||||
|
assert_eq!(
|
||||||
|
result["allowlistedHosts"],
|
||||||
|
serde_json::json!(["api.anthropic.com"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_llm_provider_never_writes_the_api_key_into_settings() {
|
||||||
|
// apply_llm_provider_args has no key parameter at all — this proves
|
||||||
|
// structurally (not just by inspection) that a supplied apiKey can
|
||||||
|
// never end up in the Settings struct that gets serialized to
|
||||||
|
// settings.json (FR-SEC-1, ADR-0011). The real key only ever reaches
|
||||||
|
// crate::llm::credentials::set, called separately in
|
||||||
|
// set_llm_provider before this function runs.
|
||||||
|
let mut settings = default_settings();
|
||||||
|
apply_llm_provider_args(
|
||||||
|
&mut settings,
|
||||||
|
"anthropic",
|
||||||
|
Some("https://attacker-controlled.example/ignored".to_string()),
|
||||||
|
Some("claude-3-5-sonnet-latest".to_string()),
|
||||||
|
);
|
||||||
|
assert_eq!(settings.llm_provider, "anthropic");
|
||||||
|
// The fixed hosted endpoint wins over any caller-supplied one.
|
||||||
|
assert_eq!(settings.llm_endpoint, "https://api.anthropic.com");
|
||||||
|
assert_eq!(settings.llm_model, "claude-3-5-sonnet-latest");
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&settings).expect("settings must serialize");
|
||||||
|
assert!(!json.to_lowercase().contains("api_key"));
|
||||||
|
assert!(!json.to_lowercase().contains("apikey"));
|
||||||
|
assert!(!json.contains("sk-ant-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_llm_provider_args_keeps_the_existing_endpoint_for_non_anthropic_providers() {
|
||||||
|
let mut settings = default_settings();
|
||||||
|
settings.llm_endpoint = "http://192.168.0.42:11434".to_string();
|
||||||
|
apply_llm_provider_args(&mut settings, "ollama", None, Some("llama3.1".to_string()));
|
||||||
|
assert_eq!(settings.llm_provider, "ollama");
|
||||||
|
assert_eq!(settings.llm_endpoint, "http://192.168.0.42:11434");
|
||||||
|
assert_eq!(settings.llm_model, "llama3.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_llm_provider_args_resets_endpoint_when_switching_away_from_anthropics_fixed_url() {
|
||||||
|
// A per-use provider quick-switch (SummaryPanel, M3.3) only sends
|
||||||
|
// `provider`, relying on whatever endpoint/model were last saved —
|
||||||
|
// if that was Anthropic's fixed URL, switching to ollama must not
|
||||||
|
// silently keep pointing at a third party.
|
||||||
|
let mut settings = default_settings();
|
||||||
|
settings.llm_provider = "anthropic".to_string();
|
||||||
|
settings.llm_endpoint = "https://api.anthropic.com".to_string();
|
||||||
|
|
||||||
|
apply_llm_provider_args(&mut settings, "ollama", None, None);
|
||||||
|
assert_eq!(settings.llm_provider, "ollama");
|
||||||
|
assert_eq!(settings.llm_endpoint, "http://localhost:11434");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn privacy_self_check_only_enabled_sync_targets_join_the_allowlist() {
|
fn privacy_self_check_only_enabled_sync_targets_join_the_allowlist() {
|
||||||
let mut settings = default_settings();
|
let mut settings = default_settings();
|
||||||
|
|||||||
+644
-30
@@ -67,7 +67,12 @@ const RESPONSE_FORMAT_INSTRUCTIONS: &str = "Respond in Markdown with exactly thr
|
|||||||
are bullet lists (each line starting with \"- \"); write \"- None\" if a section has nothing \
|
are bullet lists (each line starting with \"- \"); write \"- None\" if a section has nothing \
|
||||||
to report. Do not add any other top-level sections.";
|
to report. Do not add any other top-level sections.";
|
||||||
|
|
||||||
fn build_messages(prompt: &Prompt, user_system: Option<&str>) -> Vec<serde_json::Value> {
|
/// Splits the assembled prompt into its `(system, user)` halves. Shared by
|
||||||
|
/// every provider: OpenAI-shaped chat APIs (Ollama, OpenAI-compat) fold both
|
||||||
|
/// into a `messages` array via `build_messages` below; Anthropic's Messages
|
||||||
|
/// API (ADR-0011) takes `system` as its own top-level field instead, so it
|
||||||
|
/// calls this directly.
|
||||||
|
fn build_system_and_user(prompt: &Prompt, user_system: Option<&str>) -> (String, String) {
|
||||||
let mut user = String::new();
|
let mut user = String::new();
|
||||||
if !prompt.metadata.is_empty() {
|
if !prompt.metadata.is_empty() {
|
||||||
user.push_str(&prompt.metadata);
|
user.push_str(&prompt.metadata);
|
||||||
@@ -90,6 +95,11 @@ fn build_messages(prompt: &Prompt, user_system: Option<&str>) -> Vec<serde_json:
|
|||||||
}
|
}
|
||||||
system.push_str(RESPONSE_FORMAT_INSTRUCTIONS);
|
system.push_str(RESPONSE_FORMAT_INSTRUCTIONS);
|
||||||
|
|
||||||
|
(system, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_messages(prompt: &Prompt, user_system: Option<&str>) -> Vec<serde_json::Value> {
|
||||||
|
let (system, user) = build_system_and_user(prompt, user_system);
|
||||||
vec![
|
vec![
|
||||||
serde_json::json!({ "role": "system", "content": system }),
|
serde_json::json!({ "role": "system", "content": system }),
|
||||||
serde_json::json!({ "role": "user", "content": user }),
|
serde_json::json!({ "role": "user", "content": user }),
|
||||||
@@ -183,10 +193,20 @@ const TAG_INSTRUCTIONS: &str = "You generate short topical tags for a meeting tr
|
|||||||
explanation, no quotes. Each tag: lowercase, 1-3 words, hyphenated instead of spaces (e.g. \
|
explanation, no quotes. Each tag: lowercase, 1-3 words, hyphenated instead of spaces (e.g. \
|
||||||
\"budget-review\" not \"budget review\").";
|
\"budget-review\" not \"budget review\").";
|
||||||
|
|
||||||
|
/// `(system, user)` halves for the tag-suggestion prompt — see
|
||||||
|
/// `build_system_and_user` above for why Anthropic needs these split out.
|
||||||
|
fn tag_system_and_user(transcript: &str) -> (String, String) {
|
||||||
|
(
|
||||||
|
TAG_INSTRUCTIONS.to_string(),
|
||||||
|
format!("Transcript:\n{transcript}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn build_tag_messages(transcript: &str) -> Vec<serde_json::Value> {
|
fn build_tag_messages(transcript: &str) -> Vec<serde_json::Value> {
|
||||||
|
let (system, user) = tag_system_and_user(transcript);
|
||||||
vec![
|
vec![
|
||||||
serde_json::json!({ "role": "system", "content": TAG_INSTRUCTIONS }),
|
serde_json::json!({ "role": "system", "content": system }),
|
||||||
serde_json::json!({ "role": "user", "content": format!("Transcript:\n{transcript}") }),
|
serde_json::json!({ "role": "user", "content": user }),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +499,51 @@ impl LlmProvider for OllamaProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hosted-provider API keys (Anthropic, and a hosted OpenAI-compatible
|
||||||
|
/// gateway once `credential_ref` is set on `OpenAiCompatProvider`) live in
|
||||||
|
/// the OS credential store, keyed by `credential_ref` — never in
|
||||||
|
/// settings.json/wa.db/logs (ADR-0011, FR-SEC-1). Mirrors
|
||||||
|
/// `sync::credentials`, with its own service name so LLM keys and sync
|
||||||
|
/// secrets don't share a keyring namespace.
|
||||||
|
#[cfg(feature = "sync")]
|
||||||
|
pub mod credentials {
|
||||||
|
use super::LlmError;
|
||||||
|
|
||||||
|
const SERVICE: &str = "WhispAssist-llm";
|
||||||
|
|
||||||
|
fn entry(credential_ref: &str) -> Result<keyring::Entry, LlmError> {
|
||||||
|
keyring::Entry::new(SERVICE, credential_ref)
|
||||||
|
.map_err(|e| LlmError::Unreachable(format!("credential store: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(credential_ref: &str, secret: &str) -> Result<(), LlmError> {
|
||||||
|
entry(credential_ref)?
|
||||||
|
.set_password(secret)
|
||||||
|
.map_err(|e| LlmError::Unreachable(format!("credential store: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(credential_ref: &str) -> Result<String, LlmError> {
|
||||||
|
entry(credential_ref)?
|
||||||
|
.get_password()
|
||||||
|
.map_err(|e| LlmError::Unreachable(format!("credential store: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(credential_ref: &str) -> Result<(), LlmError> {
|
||||||
|
// Missing entry is fine — deletion is best-effort cleanup.
|
||||||
|
match entry(credential_ref)?.delete_credential() {
|
||||||
|
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(LlmError::Unreachable(format!("credential store: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fixed credential-store key for the Anthropic API key (ADR-0011). Only one
|
||||||
|
/// hosted Anthropic configuration is supported at a time (single `llm_*`
|
||||||
|
/// settings fields, like every other provider here), so a deterministic ref —
|
||||||
|
/// rather than a per-target uuid like sync's — is enough; nothing to
|
||||||
|
/// disambiguate.
|
||||||
|
pub const ANTHROPIC_CREDENTIAL_REF: &str = "wa-llm-anthropic";
|
||||||
|
|
||||||
// ---- OpenAI-compatible endpoint (Phase 5's "custom" option; Phase 10a reuses ----
|
// ---- OpenAI-compatible endpoint (Phase 5's "custom" option; Phase 10a reuses ----
|
||||||
// this for hosted providers by setting `credential_ref`, ADR-0011.)
|
// this for hosted providers by setting `credential_ref`, ADR-0011.)
|
||||||
|
|
||||||
@@ -527,10 +592,16 @@ impl OpenAiCompatProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Reads the API key from the OS credential store (never settings/DB —
|
/// Reads the API key from the OS credential store (never settings/DB —
|
||||||
/// FR-SEC-1). `None` for local, unauthenticated custom endpoints.
|
/// FR-SEC-1). `None` for local, unauthenticated custom endpoints (no
|
||||||
|
/// `credential_ref` at all) or if the store lookup fails.
|
||||||
|
#[cfg(feature = "sync")]
|
||||||
|
fn api_key(&self) -> Option<String> {
|
||||||
|
self.credential_ref
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|r| credentials::get(r).ok())
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "sync"))]
|
||||||
fn api_key(&self) -> Option<String> {
|
fn api_key(&self) -> Option<String> {
|
||||||
// T10a.2: `keyring` lookup by `credential_ref` lands with hosted
|
|
||||||
// providers; a local custom endpoint has no `credential_ref` at all.
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,42 +883,297 @@ pub async fn pull_model(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Anthropic Messages API (`/v1/messages`) — native shape, NOT OpenAI-compatible.
|
/// Anthropic Messages API version header (ADR-0011) — Anthropic's REST API is
|
||||||
|
/// versioned by header, not URL path.
|
||||||
|
const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||||
|
|
||||||
|
/// `max_tokens` is required by the Messages API and WA has no per-meeting
|
||||||
|
/// tuning surface for it yet; a fixed generous cap is enough for a summary or
|
||||||
|
/// a short tag list (T10a.1 — revisit if a real need for a larger cap shows up).
|
||||||
|
const ANTHROPIC_MAX_TOKENS: u32 = 4096;
|
||||||
|
|
||||||
|
/// Anthropic Messages API (`/v1/messages`) — native shape, NOT OpenAI-
|
||||||
|
/// compatible (ADR-0011): `system` is a top-level field rather than a
|
||||||
|
/// `messages[0]` entry, auth is `x-api-key` (not `Authorization: Bearer`),
|
||||||
|
/// and it needs the `anthropic-version` header. Always hosted — `is_local()`
|
||||||
|
/// is unconditionally `false` (unlike `OpenAiCompatProvider`, there is no
|
||||||
|
/// unauthenticated/local mode for this API).
|
||||||
pub struct AnthropicProvider {
|
pub struct AnthropicProvider {
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub credential_ref: String,
|
pub credential_ref: String,
|
||||||
|
/// `https://api.anthropic.com` in production; overridable so tests can
|
||||||
|
/// point this at a local mock server.
|
||||||
|
pub endpoint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AnthropicModelsResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
data: Vec<AnthropicModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AnthropicModel {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line of a Messages API SSE stream, e.g.
|
||||||
|
/// `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}`.
|
||||||
|
/// Only `content_block_delta` events carry text; every other event type
|
||||||
|
/// (`message_start`, `content_block_start/stop`, `message_delta`,
|
||||||
|
/// `message_stop`, `ping`) is ignored except to detect the stream's end.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AnthropicStreamEvent {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: String,
|
||||||
|
#[serde(default)]
|
||||||
|
delta: Option<AnthropicDelta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct AnthropicDelta {
|
||||||
|
#[serde(default)]
|
||||||
|
text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-streaming Messages API reply (used for `suggest_tags`, whose short
|
||||||
|
/// reply doesn't need token-by-token streaming — same call shape Ollama uses).
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AnthropicMessageResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
content: Vec<AnthropicContentBlock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AnthropicContentBlock {
|
||||||
|
#[serde(default)]
|
||||||
|
text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnthropicProvider {
|
||||||
|
fn base(&self) -> &str {
|
||||||
|
self.endpoint.trim_end_matches('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the API key from the OS credential store (never settings/DB —
|
||||||
|
/// FR-SEC-1, ADR-0011). Unlike `OpenAiCompatProvider` this always fails
|
||||||
|
/// closed (`Err`, not a silent `None`) — Anthropic has no unauthenticated
|
||||||
|
/// mode, so a missing key means the request simply cannot be made.
|
||||||
|
#[cfg(feature = "sync")]
|
||||||
|
fn api_key(&self) -> Result<String, LlmError> {
|
||||||
|
credentials::get(&self.credential_ref)
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "sync"))]
|
||||||
|
fn api_key(&self) -> Result<String, LlmError> {
|
||||||
|
Err(LlmError::Unreachable(
|
||||||
|
"credential store unavailable (built without the \"sync\" feature)".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth(&self, req: reqwest::RequestBuilder, key: &str) -> reqwest::RequestBuilder {
|
||||||
|
req.header("x-api-key", key)
|
||||||
|
.header("anthropic-version", ANTHROPIC_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `status()`'s actual HTTP call, taking an already-resolved key — split
|
||||||
|
/// out so tests can exercise the request/response shape against a mock
|
||||||
|
/// server with a fixed key, without needing that key to actually exist
|
||||||
|
/// in the real OS credential store (the store lookup itself is a thin,
|
||||||
|
/// already-trusted `keyring` wrapper — see `credentials` above — not
|
||||||
|
/// worth re-proving with an HTTP mock).
|
||||||
|
async fn status_with_key(&self, key: &str) -> LlmStatus {
|
||||||
|
let req = self.auth(
|
||||||
|
reqwest::Client::new().get(format!("{}/v1/models", self.base())),
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
let models = match req.send().await {
|
||||||
|
Ok(resp) if resp.status().is_success() => resp
|
||||||
|
.json::<AnthropicModelsResponse>()
|
||||||
|
.await
|
||||||
|
.map(|r| r.data.into_iter().map(|m| m.id).collect())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
_ => {
|
||||||
|
return LlmStatus {
|
||||||
|
provider: "anthropic".to_string(),
|
||||||
|
reachable: false,
|
||||||
|
is_local: false,
|
||||||
|
models: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
LlmStatus {
|
||||||
|
provider: "anthropic".to_string(),
|
||||||
|
reachable: true,
|
||||||
|
is_local: false,
|
||||||
|
models,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `summarize()`'s actual HTTP call — see `status_with_key`.
|
||||||
|
async fn summarize_with_key(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
prompt: Prompt,
|
||||||
|
out: TokenSink,
|
||||||
|
) -> Result<Summary, LlmError> {
|
||||||
|
let (system, user) = build_system_and_user(&prompt, None);
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"model": self.model,
|
||||||
|
"max_tokens": ANTHROPIC_MAX_TOKENS,
|
||||||
|
"system": system,
|
||||||
|
"messages": [{ "role": "user", "content": user }],
|
||||||
|
"stream": true,
|
||||||
|
});
|
||||||
|
let req = self.auth(
|
||||||
|
reqwest::Client::new()
|
||||||
|
.post(format!("{}/v1/messages", self.base()))
|
||||||
|
.json(&body),
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
let resp = req
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut full_text = String::new();
|
||||||
|
stream_lines(resp, |line| {
|
||||||
|
let Some(payload) = line.strip_prefix("data:") else {
|
||||||
|
return true; // "event: ..." lines and blanks — not JSON, skip
|
||||||
|
};
|
||||||
|
let payload = payload.trim();
|
||||||
|
let Ok(evt) = serde_json::from_str::<AnthropicStreamEvent>(payload) else {
|
||||||
|
return true; // ignore an unparseable line, keep reading
|
||||||
|
};
|
||||||
|
if evt.kind == "content_block_delta" {
|
||||||
|
if let Some(text) = evt.delta.and_then(|d| d.text) {
|
||||||
|
if !text.is_empty() {
|
||||||
|
full_text.push_str(&text);
|
||||||
|
let _ = out.send(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
evt.kind != "message_stop"
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(parse_summary(&full_text))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `suggest_tags()`'s actual HTTP call — see `status_with_key`.
|
||||||
|
async fn suggest_tags_with_key(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
transcript: &str,
|
||||||
|
) -> Result<Vec<String>, LlmError> {
|
||||||
|
let (system, user) = tag_system_and_user(transcript);
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"model": self.model,
|
||||||
|
"max_tokens": ANTHROPIC_MAX_TOKENS,
|
||||||
|
"system": system,
|
||||||
|
"messages": [{ "role": "user", "content": user }],
|
||||||
|
"stream": false,
|
||||||
|
});
|
||||||
|
let req = self.auth(
|
||||||
|
reqwest::Client::new()
|
||||||
|
.post(format!("{}/v1/messages", self.base()))
|
||||||
|
.json(&body),
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
let resp = req
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
|
||||||
|
}
|
||||||
|
let parsed: AnthropicMessageResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| LlmError::Request(e.to_string()))?;
|
||||||
|
let text: String = parsed
|
||||||
|
.content
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|b| b.text)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
Ok(parse_tags(&text))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `complete()`'s actual HTTP call — see `status_with_key`. Unlike
|
||||||
|
/// `summarize`/`suggest_tags`, `system`/`user` arrive pre-assembled
|
||||||
|
/// (`FeatureBriefBuilder`'s prompt contract, M1) rather than a `Prompt`,
|
||||||
|
/// so this skips `build_system_and_user` and posts them directly.
|
||||||
|
async fn complete_with_key(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
system: &str,
|
||||||
|
user: &str,
|
||||||
|
) -> Result<String, LlmError> {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"model": self.model,
|
||||||
|
"max_tokens": ANTHROPIC_MAX_TOKENS,
|
||||||
|
"system": system,
|
||||||
|
"messages": [{ "role": "user", "content": user }],
|
||||||
|
"stream": false,
|
||||||
|
});
|
||||||
|
let req = self.auth(
|
||||||
|
reqwest::Client::new()
|
||||||
|
.post(format!("{}/v1/messages", self.base()))
|
||||||
|
.json(&body),
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
let resp = req
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
|
||||||
|
}
|
||||||
|
let parsed: AnthropicMessageResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| LlmError::Request(e.to_string()))?;
|
||||||
|
Ok(parsed
|
||||||
|
.content
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|b| b.text)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(""))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LlmProvider for AnthropicProvider {
|
impl LlmProvider for AnthropicProvider {
|
||||||
async fn status(&self) -> LlmStatus {
|
async fn status(&self) -> LlmStatus {
|
||||||
// Not built yet (Phase 10a) and not reachable today: `set_llm_provider`
|
let Ok(key) = self.api_key() else {
|
||||||
// rejects "anthropic" before settings could ever select this provider.
|
return LlmStatus {
|
||||||
LlmStatus {
|
provider: "anthropic".to_string(),
|
||||||
provider: "anthropic".to_string(),
|
reachable: false,
|
||||||
reachable: false,
|
is_local: false,
|
||||||
is_local: false,
|
models: Vec::new(),
|
||||||
models: Vec::new(),
|
};
|
||||||
}
|
};
|
||||||
|
self.status_with_key(&key).await
|
||||||
}
|
}
|
||||||
async fn summarize(&self, _prompt: Prompt, _out: TokenSink) -> Result<Summary, LlmError> {
|
|
||||||
Err(LlmError::Request(
|
async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result<Summary, LlmError> {
|
||||||
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
|
let key = self.api_key()?;
|
||||||
))
|
self.summarize_with_key(&key, prompt, out).await
|
||||||
}
|
}
|
||||||
async fn suggest_tags(&self, _transcript: &str) -> Result<Vec<String>, LlmError> {
|
|
||||||
Err(LlmError::Request(
|
async fn suggest_tags(&self, transcript: &str) -> Result<Vec<String>, LlmError> {
|
||||||
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
|
let key = self.api_key()?;
|
||||||
))
|
self.suggest_tags_with_key(&key, transcript).await
|
||||||
}
|
}
|
||||||
async fn complete(&self, _system: &str, _user: &str) -> Result<String, LlmError> {
|
|
||||||
// Not built yet (Phase 10a); `briefs::LlmFeatureBriefBuilder` (M1) only
|
async fn complete(&self, system: &str, user: &str) -> Result<String, LlmError> {
|
||||||
// ever gets an Ollama/OpenAI-compatible provider from
|
let key = self.api_key()?;
|
||||||
// `llm_provider_from_settings` today, so this path isn't reachable yet.
|
self.complete_with_key(&key, system, user).await
|
||||||
Err(LlmError::Request(
|
|
||||||
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_local(&self) -> bool {
|
fn is_local(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -857,6 +1183,294 @@ impl LlmProvider for AnthropicProvider {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Minimal single-request HTTP mock: binds loopback on an OS-chosen port,
|
||||||
|
/// accepts one connection on a background thread, hands the raw request
|
||||||
|
/// bytes back over a channel, and writes `response` (a full raw
|
||||||
|
/// `"HTTP/1.1 ..."` response) to the socket. Mirrors the blocking-loopback
|
||||||
|
/// pattern `sync::oauth::LoopbackRedirect` already uses for a similar
|
||||||
|
/// reason (no HTTP mocking crate in the dependency tree) — good enough
|
||||||
|
/// for "one provider call, one reply" test shapes, which is all
|
||||||
|
/// `summarize`/`suggest_tags`/`status` ever do.
|
||||||
|
struct MockServer {
|
||||||
|
port: u16,
|
||||||
|
captured: std::sync::mpsc::Receiver<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockServer {
|
||||||
|
fn start(response: &'static str) -> Self {
|
||||||
|
let listener =
|
||||||
|
std::net::TcpListener::bind("127.0.0.1:0").expect("bind a loopback test port");
|
||||||
|
let port = listener.local_addr().expect("local_addr").port();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
if let Ok((mut stream, _)) = listener.accept() {
|
||||||
|
// A POST's headers and JSON body aren't guaranteed to
|
||||||
|
// arrive in a single `read()` (separate writev calls can
|
||||||
|
// land as separate TCP segments even on loopback), so
|
||||||
|
// accumulate reads until the client goes quiet rather
|
||||||
|
// than trusting one read to have the whole request.
|
||||||
|
let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(300)));
|
||||||
|
let mut request = Vec::new();
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
loop {
|
||||||
|
match stream.read(&mut buf) {
|
||||||
|
Ok(0) | Err(_) => break, // EOF or read-timeout: client is done sending
|
||||||
|
Ok(n) => request.extend_from_slice(&buf[..n]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = tx.send(String::from_utf8_lossy(&request).to_string());
|
||||||
|
let _ = stream.write_all(response.as_bytes());
|
||||||
|
let _ = stream.flush();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Self { port, captured: rx }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_url(&self) -> String {
|
||||||
|
format!("http://127.0.0.1:{}", self.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The raw request the mock received (headers + body), for asserting
|
||||||
|
/// shape (path, auth header, JSON body fields).
|
||||||
|
fn request(&self) -> String {
|
||||||
|
self.captured
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_ok(content_type: &str, body: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_tokens(rx: &std::sync::mpsc::Receiver<String>) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
while let Ok(tok) = rx.try_recv() {
|
||||||
|
out.push_str(&tok);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- AnthropicProvider (M3.1/M3.2, ADR-0011) ----
|
||||||
|
|
||||||
|
fn anthropic_provider(endpoint: String) -> AnthropicProvider {
|
||||||
|
AnthropicProvider {
|
||||||
|
model: "claude-3-5-sonnet-latest".to_string(),
|
||||||
|
credential_ref: "test-anthropic-key".to_string(),
|
||||||
|
endpoint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn anthropic_summarize_streams_tokens_and_hits_v1_messages_with_auth_headers() {
|
||||||
|
let sse = "event: message_start\n\
|
||||||
|
data: {\"type\":\"message_start\"}\n\n\
|
||||||
|
event: content_block_delta\n\
|
||||||
|
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"## Summary\\n\"}}\n\n\
|
||||||
|
event: content_block_delta\n\
|
||||||
|
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Shipped M3.\\n\\n## Decisions\\n- None\\n\\n## Action Items\\n- None\\n\"}}\n\n\
|
||||||
|
event: message_stop\n\
|
||||||
|
data: {\"type\":\"message_stop\"}\n\n";
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||||
|
sse.len(),
|
||||||
|
sse
|
||||||
|
);
|
||||||
|
let response: &'static str = Box::leak(response.into_boxed_str());
|
||||||
|
let server = MockServer::start(response);
|
||||||
|
let provider = anthropic_provider(server.base_url());
|
||||||
|
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||||
|
let prompt = Prompt {
|
||||||
|
transcript: "Alice: let's ship it.".to_string(),
|
||||||
|
metadata: "Meeting: M3 review".to_string(),
|
||||||
|
template: None,
|
||||||
|
};
|
||||||
|
let summary = provider
|
||||||
|
.summarize_with_key("test-anthropic-key", prompt, tx)
|
||||||
|
.await
|
||||||
|
.expect("summarize should succeed against the mock");
|
||||||
|
|
||||||
|
let request = server.request();
|
||||||
|
assert!(request.starts_with("POST /v1/messages"), "{request}");
|
||||||
|
assert!(
|
||||||
|
request.contains("x-api-key: test-anthropic-key"),
|
||||||
|
"{request}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
request.contains(&format!("anthropic-version: {ANTHROPIC_VERSION}")),
|
||||||
|
"{request}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
request.contains("\"model\":\"claude-3-5-sonnet-latest\""),
|
||||||
|
"{request}"
|
||||||
|
);
|
||||||
|
assert!(request.contains("\"stream\":true"), "{request}");
|
||||||
|
// Anthropic's own shape: system is a top-level field, not messages[0].
|
||||||
|
assert!(request.contains("\"system\":"), "{request}");
|
||||||
|
assert!(!request.contains("\"role\":\"system\""), "{request}");
|
||||||
|
|
||||||
|
assert_eq!(summary.summary_md, "Shipped M3.");
|
||||||
|
assert_eq!(
|
||||||
|
drain_tokens(&rx),
|
||||||
|
"## Summary\nShipped M3.\n\n## Decisions\n- None\n\n## Action Items\n- None\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn anthropic_suggest_tags_parses_the_non_streaming_reply() {
|
||||||
|
let body = r#"{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"golang, webrtc, dtls"}],"model":"claude-3-5-sonnet-latest","stop_reason":"end_turn"}"#;
|
||||||
|
let response = http_ok("application/json", body);
|
||||||
|
let response: &'static str = Box::leak(response.into_boxed_str());
|
||||||
|
let server = MockServer::start(response);
|
||||||
|
let provider = anthropic_provider(server.base_url());
|
||||||
|
|
||||||
|
let tags = provider
|
||||||
|
.suggest_tags_with_key(
|
||||||
|
"test-anthropic-key",
|
||||||
|
"Alice: let's talk webrtc and dtls in golang.",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("suggest_tags should succeed against the mock");
|
||||||
|
|
||||||
|
let request = server.request();
|
||||||
|
assert!(request.starts_with("POST /v1/messages"), "{request}");
|
||||||
|
assert!(request.contains("\"stream\":false"), "{request}");
|
||||||
|
assert_eq!(tags, vec!["golang", "webrtc", "dtls"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn anthropic_status_reports_reachable_and_models_from_v1_models() {
|
||||||
|
let body = r#"{"data":[{"type":"model","id":"claude-3-5-sonnet-latest","display_name":"Claude 3.5 Sonnet"}],"has_more":false}"#;
|
||||||
|
let response = http_ok("application/json", body);
|
||||||
|
let response: &'static str = Box::leak(response.into_boxed_str());
|
||||||
|
let server = MockServer::start(response);
|
||||||
|
let provider = anthropic_provider(server.base_url());
|
||||||
|
|
||||||
|
let status = provider.status_with_key("test-anthropic-key").await;
|
||||||
|
let request = server.request();
|
||||||
|
assert!(request.starts_with("GET /v1/models"), "{request}");
|
||||||
|
assert!(
|
||||||
|
request.contains("x-api-key: test-anthropic-key"),
|
||||||
|
"{request}"
|
||||||
|
);
|
||||||
|
assert!(status.reachable);
|
||||||
|
assert!(!status.is_local);
|
||||||
|
assert_eq!(status.provider, "anthropic");
|
||||||
|
assert_eq!(status.models, vec!["claude-3-5-sonnet-latest"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_provider_is_never_local() {
|
||||||
|
// Unlike OpenAiCompatProvider (which is local until a credential_ref
|
||||||
|
// is set), Anthropic has no unauthenticated/local mode at all.
|
||||||
|
let provider = anthropic_provider("http://127.0.0.1:1".to_string());
|
||||||
|
assert!(!provider.is_local());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn anthropic_status_fails_closed_when_no_credential_is_stored() {
|
||||||
|
// Exercises the real trait-level status() (not status_with_key), i.e.
|
||||||
|
// the actual credential-store lookup path — a fresh/unused
|
||||||
|
// credential_ref should never be present, so this proves the "no key
|
||||||
|
// => unreachable, no request attempted" behavior without needing to
|
||||||
|
// seed the real OS credential store from a test.
|
||||||
|
let provider = AnthropicProvider {
|
||||||
|
model: "claude-3-5-sonnet-latest".to_string(),
|
||||||
|
credential_ref: "wa-llm-anthropic-test-missing-credential-do-not-create".to_string(),
|
||||||
|
endpoint: "http://127.0.0.1:1".to_string(), // would refuse the connection if ever hit
|
||||||
|
};
|
||||||
|
let status = provider.status().await;
|
||||||
|
assert!(!status.reachable);
|
||||||
|
assert!(!status.is_local);
|
||||||
|
assert!(status.models.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- OpenAiCompatProvider (already-scaffolded reference; adding the ----
|
||||||
|
// mock-server coverage M3's acceptance criteria calls for, which didn't
|
||||||
|
// exist yet — see openai_compat_provider_is_not_local_once_a_credential_is_set
|
||||||
|
// above for the pre-existing pure-logic test.)
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openai_compat_summarize_streams_sse_tokens_from_v1_chat_completions() {
|
||||||
|
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"## Summary\\n\"}}]}\n\n\
|
||||||
|
data: {\"choices\":[{\"delta\":{\"content\":\"All good.\\n\\n## Decisions\\n- None\\n\\n## Action Items\\n- None\\n\"}}]}\n\n\
|
||||||
|
data: [DONE]\n\n";
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||||
|
sse.len(),
|
||||||
|
sse
|
||||||
|
);
|
||||||
|
let response: &'static str = Box::leak(response.into_boxed_str());
|
||||||
|
let server = MockServer::start(response);
|
||||||
|
let provider = OpenAiCompatProvider {
|
||||||
|
endpoint: server.base_url(),
|
||||||
|
model: "gpt-4o-mini".to_string(),
|
||||||
|
credential_ref: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||||
|
let prompt = Prompt {
|
||||||
|
transcript: "Alice: let's ship it.".to_string(),
|
||||||
|
metadata: "Meeting: M3 review".to_string(),
|
||||||
|
template: None,
|
||||||
|
};
|
||||||
|
let summary = provider
|
||||||
|
.summarize(prompt, tx)
|
||||||
|
.await
|
||||||
|
.expect("summarize should succeed against the mock");
|
||||||
|
|
||||||
|
let request = server.request();
|
||||||
|
assert!(
|
||||||
|
request.starts_with("POST /v1/chat/completions"),
|
||||||
|
"{request}"
|
||||||
|
);
|
||||||
|
assert!(request.contains("\"model\":\"gpt-4o-mini\""), "{request}");
|
||||||
|
assert!(request.contains("\"role\":\"system\""), "{request}"); // OpenAI shape: system IS a message
|
||||||
|
assert_eq!(summary.summary_md, "All good.");
|
||||||
|
assert_eq!(
|
||||||
|
drain_tokens(&rx),
|
||||||
|
"## Summary\nAll good.\n\n## Decisions\n- None\n\n## Action Items\n- None\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openai_compat_provider_with_unset_credential_sends_no_auth_header() {
|
||||||
|
let sse = "data: [DONE]\n\n";
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||||
|
sse.len(),
|
||||||
|
sse
|
||||||
|
);
|
||||||
|
let response: &'static str = Box::leak(response.into_boxed_str());
|
||||||
|
let server = MockServer::start(response);
|
||||||
|
// `credential_ref: None` is the local/unauthenticated custom-endpoint
|
||||||
|
// case (ADR-0007) — no lookup happens at all, so this asserts the
|
||||||
|
// request truly carries no Authorization header, unlike the
|
||||||
|
// `Some(ref)` hosted case above.
|
||||||
|
let provider = OpenAiCompatProvider {
|
||||||
|
endpoint: server.base_url(),
|
||||||
|
model: "gpt-4o-mini".to_string(),
|
||||||
|
credential_ref: None,
|
||||||
|
};
|
||||||
|
assert!(provider.is_local());
|
||||||
|
|
||||||
|
let _ = provider.suggest_tags("hello").await;
|
||||||
|
let request = server.request();
|
||||||
|
assert!(
|
||||||
|
request.starts_with("POST /v1/chat/completions"),
|
||||||
|
"{request}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!request.to_lowercase().contains("authorization:"),
|
||||||
|
"{request}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_summary_splits_the_three_requested_sections() {
|
fn parse_summary_splits_the_three_requested_sections() {
|
||||||
let text = "## Summary\nWe discussed the roadmap.\nIt went well.\n\n\
|
let text = "## Summary\nWe discussed the roadmap.\nIt went well.\n\n\
|
||||||
|
|||||||
@@ -202,6 +202,13 @@ pub struct Settings {
|
|||||||
// Recording retention (ADR-0009). Default OFF.
|
// Recording retention (ADR-0009). Default OFF.
|
||||||
pub default_record: bool,
|
pub default_record: bool,
|
||||||
pub consent_acknowledged: bool,
|
pub consent_acknowledged: bool,
|
||||||
|
/// One-time "data leaves your device" acknowledgment for hosted
|
||||||
|
/// (non-local) AI providers — Anthropic, or a hosted OpenAI-compatible
|
||||||
|
/// gateway (ADR-0011, T10.3/M3.3). Shown once before first hosted use;
|
||||||
|
/// this flag is what makes it not nag every time. Independent of
|
||||||
|
/// `consent_acknowledged` (that one's specifically about recording law).
|
||||||
|
#[serde(default)]
|
||||||
|
pub hosted_ai_acknowledged: bool,
|
||||||
// Sync master switch (ADR-0010). Default OFF. Target rows live in the DB; secrets in OS keychain.
|
// Sync master switch (ADR-0010). Default OFF. Target rows live in the DB; secrets in OS keychain.
|
||||||
pub sync_enabled: bool,
|
pub sync_enabled: bool,
|
||||||
// Storage retention policy (FR-STORE-2). None = no cap on that dimension.
|
// Storage retention policy (FR-STORE-2). None = no cap on that dimension.
|
||||||
|
|||||||
+5
-1
@@ -57,7 +57,7 @@ export interface AudioDeviceInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LlmStatus {
|
export interface LlmStatus {
|
||||||
provider: string; // ollama|custom|off (Phase 10a adds anthropic|openai)
|
provider: string; // ollama|custom|anthropic|off (ADR-0011; "openai" not yet wired)
|
||||||
reachable: boolean;
|
reachable: boolean;
|
||||||
isLocal: boolean;
|
isLocal: boolean;
|
||||||
models: string[];
|
models: string[];
|
||||||
@@ -287,6 +287,10 @@ export interface AppSettings {
|
|||||||
low_overhead: boolean;
|
low_overhead: boolean;
|
||||||
default_record: boolean;
|
default_record: boolean;
|
||||||
consent_acknowledged: boolean;
|
consent_acknowledged: boolean;
|
||||||
|
/** One-time "data leaves your device" ack for hosted (non-local) AI
|
||||||
|
* providers — Anthropic today (ADR-0011, T10.3). Independent of
|
||||||
|
* consent_acknowledged (that one's about recording law). */
|
||||||
|
hosted_ai_acknowledged: boolean;
|
||||||
sync_enabled: boolean;
|
sync_enabled: boolean;
|
||||||
mcp_enabled: boolean;
|
mcp_enabled: boolean;
|
||||||
mcp_transport: string; // http|stdio
|
mcp_transport: string; // http|stdio
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// One-time third-party "data leaves your device" notice (ADR-0011, T10.3/
|
||||||
|
// M3.3), shown before the first use of any hosted (non-local) AI provider —
|
||||||
|
// Anthropic today, a hosted OpenAI-compatible gateway once wired the same
|
||||||
|
// way. Same shared-copy/shared-acknowledgment pattern as ConsentNotice.svelte
|
||||||
|
// (recording consent, ADR-0009): both gate a single Settings toggle AND a
|
||||||
|
// second use-time trigger point on the same one-time flag.
|
||||||
|
import { Globe } from "@lucide/svelte";
|
||||||
|
let {
|
||||||
|
providerLabel,
|
||||||
|
onAccept,
|
||||||
|
onCancel,
|
||||||
|
}: { providerLabel: string; onAccept: () => void; onCancel: () => void } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="banner" role="alertdialog" aria-labelledby="hosted-ai-heading">
|
||||||
|
<div class="heading">
|
||||||
|
<Globe size={18} aria-hidden="true" />
|
||||||
|
<strong id="hosted-ai-heading">Before using {providerLabel}</strong>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Generating with <strong>{providerLabel}</strong> sends this meeting's transcript to their servers
|
||||||
|
— it leaves this device and is subject to their privacy policy. WhispAssist has no control over data
|
||||||
|
handling once it leaves your device. This is off by default; you're choosing it now.
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" onclick={onAccept}>I understand — continue</button>
|
||||||
|
<button class="ghost" onclick={onCancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.banner {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--fg);
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
.heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
color: var(--warning);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.heading strong {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
p strong {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
button.primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
button.ghost {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
button.ghost:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -30,6 +30,7 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
low_overhead: false,
|
low_overhead: false,
|
||||||
default_record: false, // recording OFF by default (ADR-0009)
|
default_record: false, // recording OFF by default (ADR-0009)
|
||||||
consent_acknowledged: false,
|
consent_acknowledged: false,
|
||||||
|
hosted_ai_acknowledged: false, // hosted-AI "leaves your device" notice (ADR-0011)
|
||||||
sync_enabled: false, // sync OFF by default (ADR-0010)
|
sync_enabled: false, // sync OFF by default (ADR-0010)
|
||||||
mcp_enabled: false, // MCP server OFF by default (ADR-0011)
|
mcp_enabled: false, // MCP server OFF by default (ADR-0011)
|
||||||
mcp_transport: "http",
|
mcp_transport: "http",
|
||||||
@@ -251,8 +252,16 @@ class SettingsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist the LLM provider/endpoint/model and refresh status (T5.2). */
|
/** Persist the LLM provider/endpoint/model (+ hosted apiKey, ADR-0011) and
|
||||||
async setLlmProvider(config: { provider: string; endpoint?: string; model?: string }) {
|
* refresh status (T5.2/T10.2). The key is only ever sent to the backend
|
||||||
|
* command (which stores it in the OS credential store) — never held here
|
||||||
|
* beyond this call, and never merged into `this.settings`. */
|
||||||
|
async setLlmProvider(config: {
|
||||||
|
provider: string;
|
||||||
|
endpoint?: string;
|
||||||
|
model?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
}) {
|
||||||
this.llmSaving = true;
|
this.llmSaving = true;
|
||||||
// Optimistic local update so the form reflects the change immediately.
|
// Optimistic local update so the form reflects the change immediately.
|
||||||
this.settings = {
|
this.settings = {
|
||||||
@@ -270,6 +279,13 @@ class SettingsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persist the one-time hosted-AI "leaves your device" acknowledgment
|
||||||
|
* (ADR-0011, T10.3) — same generic patch() every other boolean setting
|
||||||
|
* here uses (see setDefaultRecord below). */
|
||||||
|
acknowledgeHostedAi() {
|
||||||
|
return this.patch({ hosted_ai_acknowledged: true });
|
||||||
|
}
|
||||||
|
|
||||||
async setPreferredBackend(backend: AppSettings["preferred_backend"]) {
|
async setPreferredBackend(backend: AppSettings["preferred_backend"]) {
|
||||||
await this.patch({ preferred_backend: backend });
|
await this.patch({ preferred_backend: backend });
|
||||||
await this.loadHardware();
|
await this.loadHardware();
|
||||||
|
|||||||
+130
-13
@@ -6,6 +6,7 @@
|
|||||||
import { settings } from "../stores/settings.svelte";
|
import { settings } from "../stores/settings.svelte";
|
||||||
import { calendar } from "../stores/calendar.svelte";
|
import { calendar } from "../stores/calendar.svelte";
|
||||||
import ConsentNotice from "../components/ConsentNotice.svelte";
|
import ConsentNotice from "../components/ConsentNotice.svelte";
|
||||||
|
import HostedAiBanner from "../components/HostedAiBanner.svelte";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { api, errorMessage, events } from "../api";
|
import { api, errorMessage, events } from "../api";
|
||||||
import type { BackendId, SyncKind, SyncTargetConfig, SyncTargetInfo } from "../api";
|
import type { BackendId, SyncKind, SyncTargetConfig, SyncTargetInfo } from "../api";
|
||||||
@@ -28,6 +29,9 @@
|
|||||||
Bot,
|
Bot,
|
||||||
Copy,
|
Copy,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Globe,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import {
|
import {
|
||||||
OLLAMA_OPTIONS,
|
OLLAMA_OPTIONS,
|
||||||
@@ -47,22 +51,57 @@
|
|||||||
const SOURCE_URL = "https://git.dou.bet/iamdoubz/WhispAssist";
|
const SOURCE_URL = "https://git.dou.bet/iamdoubz/WhispAssist";
|
||||||
let appInfo = $state<{ version: string; commit: string } | null>(null);
|
let appInfo = $state<{ version: string; commit: string } | null>(null);
|
||||||
|
|
||||||
// ---- AI summary provider (T5.2, FR-LLM-1) ----
|
// ---- AI summary provider (T5.2/T10.1/T10.2, FR-LLM-1, ADR-0011) ----
|
||||||
let llmProvider = $state(settings.settings.llm_provider);
|
let llmProvider = $state(settings.settings.llm_provider);
|
||||||
let llmEndpoint = $state(settings.settings.llm_endpoint);
|
let llmEndpoint = $state(settings.settings.llm_endpoint);
|
||||||
let llmModel = $state(settings.settings.llm_model);
|
let llmModel = $state(settings.settings.llm_model);
|
||||||
|
// Hosted (Anthropic) API key — never read back from the backend (it's
|
||||||
|
// never returned by llm_status/get_settings, FR-SEC-1); this is a
|
||||||
|
// write-only field that's blank unless the user is actively (re)typing
|
||||||
|
// one, and is dropped from memory the moment saveLlm() sends it.
|
||||||
|
let llmApiKey = $state("");
|
||||||
|
let showApiKey = $state(false);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Re-sync the form when settings (re)load or are saved elsewhere.
|
// Re-sync the form when settings (re)load or are saved elsewhere.
|
||||||
llmProvider = settings.settings.llm_provider;
|
llmProvider = settings.settings.llm_provider;
|
||||||
llmEndpoint = settings.settings.llm_endpoint;
|
llmEndpoint = settings.settings.llm_endpoint;
|
||||||
llmModel = settings.settings.llm_model;
|
llmModel = settings.settings.llm_model;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const HOSTED_PROVIDER_LABELS: Record<string, string> = { anthropic: "Anthropic (Claude)" };
|
||||||
|
|
||||||
|
/** True when the *currently selected* provider is hosted (leaves the
|
||||||
|
* device): Anthropic always is; "custom" is hosted only once its endpoint
|
||||||
|
* resolves off-network — mirrors the live warning already shown below. */
|
||||||
|
function isHostedProviderSelection(): boolean {
|
||||||
|
if (llmProvider === "anthropic") return true;
|
||||||
|
if (llmProvider === "custom" && llmEndpoint) return !endpointIsLocalOrLan(llmEndpoint);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Hosted-AI "leaves your device" one-time banner (T10.3/M3.3) ----
|
||||||
|
let showHostedBanner = $state(false);
|
||||||
|
function onSaveLlmClick() {
|
||||||
|
if (isHostedProviderSelection() && !settings.settings.hosted_ai_acknowledged) {
|
||||||
|
showHostedBanner = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveLlm();
|
||||||
|
}
|
||||||
|
async function acceptHostedBannerAndSave() {
|
||||||
|
await settings.acknowledgeHostedAi();
|
||||||
|
showHostedBanner = false;
|
||||||
|
await saveLlm();
|
||||||
|
}
|
||||||
|
|
||||||
async function saveLlm() {
|
async function saveLlm() {
|
||||||
await settings.setLlmProvider({
|
await settings.setLlmProvider({
|
||||||
provider: llmProvider,
|
provider: llmProvider,
|
||||||
endpoint: llmEndpoint || undefined,
|
endpoint: llmProvider === "anthropic" ? undefined : llmEndpoint || undefined,
|
||||||
model: llmModel || undefined,
|
model: llmModel || undefined,
|
||||||
|
apiKey: llmApiKey.trim() || undefined,
|
||||||
});
|
});
|
||||||
|
llmApiKey = ""; // never linger in memory once sent
|
||||||
}
|
}
|
||||||
/** Client-side mirror of the backend's loopback+private-LAN check, for a live
|
/** Client-side mirror of the backend's loopback+private-LAN check, for a live
|
||||||
* "leaves your network" hint while typing. */
|
* "leaves your network" hint while typing. */
|
||||||
@@ -1035,8 +1074,10 @@
|
|||||||
<section>
|
<section>
|
||||||
<h3>AI summary provider</h3>
|
<h3>AI summary provider</h3>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Summaries run on a local LLM. Point this at Ollama on this PC or another machine on your
|
Summaries run on a local LLM by default. Point this at Ollama on this PC or another
|
||||||
LAN (e.g. <code>192.168.0.x</code>) — both count as local, so nothing leaves your network.
|
machine on your LAN (e.g. <code>192.168.0.x</code>) — both count as local, so nothing
|
||||||
|
leaves your network. Hosted providers (Anthropic) are optional, off by default, and send
|
||||||
|
the transcript to a third party once you turn one on.
|
||||||
</p>
|
</p>
|
||||||
<label
|
<label
|
||||||
>Provider
|
>Provider
|
||||||
@@ -1044,32 +1085,76 @@
|
|||||||
<option value="off">Off</option>
|
<option value="off">Off</option>
|
||||||
<option value="ollama">Ollama (local / LAN)</option>
|
<option value="ollama">Ollama (local / LAN)</option>
|
||||||
<option value="custom">Custom (OpenAI-compatible)</option>
|
<option value="custom">Custom (OpenAI-compatible)</option>
|
||||||
|
<option value="anthropic">Anthropic (Claude) — hosted, leaves this device</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{#if llmProvider !== "off"}
|
{#if llmProvider !== "off"}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<label class="wide"
|
{#if llmProvider !== "anthropic"}
|
||||||
>Endpoint<input
|
<label class="wide"
|
||||||
bind:value={llmEndpoint}
|
>Endpoint<input
|
||||||
placeholder="http://192.168.0.42:11434"
|
bind:value={llmEndpoint}
|
||||||
/></label
|
placeholder="http://192.168.0.42:11434"
|
||||||
>
|
/></label
|
||||||
<label>Model<input bind:value={llmModel} placeholder="llama3.1" /></label>
|
>
|
||||||
|
<label>Model<input bind:value={llmModel} placeholder="llama3.1" /></label>
|
||||||
|
{:else}
|
||||||
|
<label class="wide"
|
||||||
|
>Model<input bind:value={llmModel} placeholder="claude-3-5-sonnet-latest" /></label
|
||||||
|
>
|
||||||
|
<label class="wide"
|
||||||
|
>API key
|
||||||
|
<div class="key-input">
|
||||||
|
<input
|
||||||
|
type={showApiKey ? "text" : "password"}
|
||||||
|
autocomplete="off"
|
||||||
|
bind:value={llmApiKey}
|
||||||
|
placeholder={settings.llmStatus?.provider === "anthropic"
|
||||||
|
? "•••••••••••••••• (already set — leave blank to keep it)"
|
||||||
|
: "sk-ant-…"}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-toggle"
|
||||||
|
onclick={() => (showApiKey = !showApiKey)}
|
||||||
|
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||||
|
>
|
||||||
|
{#if showApiKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye
|
||||||
|
size={14}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if llmEndpoint && !endpointIsLocalOrLan(llmEndpoint)}
|
{#if llmProvider === "anthropic"}
|
||||||
|
<div class="banner hosted">
|
||||||
|
<Globe size={14} aria-hidden="true" />
|
||||||
|
Anthropic is a hosted, third-party service — this meeting's transcript leaves your device
|
||||||
|
when you generate a summary.
|
||||||
|
</div>
|
||||||
|
{:else if llmEndpoint && !endpointIsLocalOrLan(llmEndpoint)}
|
||||||
<div class="banner">
|
<div class="banner">
|
||||||
<AlertTriangle size={14} aria-hidden="true" />
|
<AlertTriangle size={14} aria-hidden="true" />
|
||||||
This endpoint isn't on your machine or LAN — your transcript would leave your network.
|
This endpoint isn't on your machine or LAN — your transcript would leave your network.
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="primary" onclick={saveLlm} disabled={settings.llmSaving}>
|
<button class="primary" onclick={onSaveLlmClick} disabled={settings.llmSaving}>
|
||||||
{settings.llmSaving ? "Saving…" : "Save"}
|
{settings.llmSaving ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
<button class="ghost" onclick={() => settings.loadLlmStatus()}>
|
<button class="ghost" onclick={() => settings.loadLlmStatus()}>
|
||||||
<RefreshCw size={14} aria-hidden="true" /> Test connection
|
<RefreshCw size={14} aria-hidden="true" /> Test connection
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{#if showHostedBanner}
|
||||||
|
<HostedAiBanner
|
||||||
|
providerLabel={HOSTED_PROVIDER_LABELS[llmProvider] ?? "this hosted provider"}
|
||||||
|
onAccept={acceptHostedBannerAndSave}
|
||||||
|
onCancel={() => (showHostedBanner = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{#if settings.llmStatus}
|
{#if settings.llmStatus}
|
||||||
{@const s = settings.llmStatus}
|
{@const s = settings.llmStatus}
|
||||||
<div class="status-card">
|
<div class="status-card">
|
||||||
@@ -1719,6 +1804,38 @@
|
|||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
}
|
}
|
||||||
|
/* Informational (not a warning-severity) variant for "this is hosted, by
|
||||||
|
your own choice" notices — Anthropic's fixed endpoint (ADR-0011). */
|
||||||
|
.banner.hosted {
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--bg));
|
||||||
|
}
|
||||||
|
.banner.hosted :global(svg) {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.key-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.key-input input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.icon-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.35rem;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.icon-toggle:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
ul.targets,
|
ul.targets,
|
||||||
ul.models,
|
ul.models,
|
||||||
ul.events {
|
ul.events {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
} from "../api";
|
} from "../api";
|
||||||
import { renderMarkdown } from "../markdown";
|
import { renderMarkdown } from "../markdown";
|
||||||
import TagChip from "../components/TagChip.svelte";
|
import TagChip from "../components/TagChip.svelte";
|
||||||
|
import HostedAiBanner from "../components/HostedAiBanner.svelte";
|
||||||
import {
|
import {
|
||||||
Tags,
|
Tags,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -27,6 +28,8 @@
|
|||||||
FileText,
|
FileText,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
|
Cpu,
|
||||||
|
Globe,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
|
||||||
onMount(() => calendar.load());
|
onMount(() => calendar.load());
|
||||||
@@ -53,19 +56,93 @@
|
|||||||
|
|
||||||
// ---- Summary + action items (T5.4/T5.5/T5.6, FR-LLM-2/3/4) ----
|
// ---- Summary + action items (T5.4/T5.5/T5.6, FR-LLM-2/3/4) ----
|
||||||
let llmStatus = $state<LlmStatus | null>(null);
|
let llmStatus = $state<LlmStatus | null>(null);
|
||||||
onMount(async () => {
|
async function refreshLlmStatus() {
|
||||||
try {
|
try {
|
||||||
llmStatus = await api.llmStatus();
|
llmStatus = await api.llmStatus();
|
||||||
} catch {
|
} catch {
|
||||||
llmStatus = null;
|
llmStatus = null;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
onMount(refreshLlmStatus);
|
||||||
|
|
||||||
function generateSummary() {
|
function generateSummary() {
|
||||||
const m = meetings.selected;
|
const m = meetings.selected;
|
||||||
if (m) meetings.generateSummary(m.id);
|
if (m) meetings.generateSummary(m.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Active provider indicator + per-use quick switch (T10.3/M3.3) ----
|
||||||
|
// Same four providers Settings' AI section offers; switching here reuses
|
||||||
|
// the exact same set_llm_provider command (and hence the same "anthropic's
|
||||||
|
// endpoint is fixed" / "switching away resets a stale hosted endpoint"
|
||||||
|
// guarantees — see apply_llm_provider_args in commands.rs).
|
||||||
|
const PROVIDER_LABELS: Record<string, string> = {
|
||||||
|
off: "Off",
|
||||||
|
ollama: "Ollama",
|
||||||
|
custom: "Custom",
|
||||||
|
anthropic: "Anthropic (Claude)",
|
||||||
|
};
|
||||||
|
function isHostedStatus(status: LlmStatus | null): boolean {
|
||||||
|
return !!status && status.provider !== "off" && !status.isLocal;
|
||||||
|
}
|
||||||
|
let switchingProvider = $state(false);
|
||||||
|
|
||||||
|
// ---- Hosted-AI "leaves your device" one-time gate (T10.3/M3.3) — shared
|
||||||
|
// by the quick switch (switching TO Anthropic) and Generate/Regenerate
|
||||||
|
// (the actual point a transcript would leave the device, so this is the
|
||||||
|
// gate that matters even if the quick switch's own check is skipped, e.g.
|
||||||
|
// "custom" resolving to a hosted endpoint that was configured elsewhere).
|
||||||
|
let showHostedBanner = $state(false);
|
||||||
|
let hostedBannerLabel = $state("this hosted provider");
|
||||||
|
let pendingHostedAction = $state<(() => void) | null>(null);
|
||||||
|
function requireHostedAck(providerLabel: string, action: () => void) {
|
||||||
|
if (!settings.settings.hosted_ai_acknowledged) {
|
||||||
|
hostedBannerLabel = providerLabel;
|
||||||
|
pendingHostedAction = action;
|
||||||
|
showHostedBanner = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
async function acceptHostedBanner() {
|
||||||
|
await settings.acknowledgeHostedAi();
|
||||||
|
showHostedBanner = false;
|
||||||
|
const action = pendingHostedAction;
|
||||||
|
pendingHostedAction = null;
|
||||||
|
action?.();
|
||||||
|
}
|
||||||
|
function cancelHostedBanner() {
|
||||||
|
showHostedBanner = false;
|
||||||
|
pendingHostedAction = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchProvider(provider: string) {
|
||||||
|
switchingProvider = true;
|
||||||
|
try {
|
||||||
|
await settings.setLlmProvider({ provider });
|
||||||
|
await refreshLlmStatus();
|
||||||
|
} finally {
|
||||||
|
switchingProvider = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onProviderChange(e: Event) {
|
||||||
|
const next = (e.target as HTMLSelectElement).value;
|
||||||
|
if (next === "anthropic") {
|
||||||
|
// Deterministically hosted — worth confirming before even switching to
|
||||||
|
// it, not just before the next generate.
|
||||||
|
requireHostedAck(PROVIDER_LABELS[next], () => void switchProvider(next));
|
||||||
|
} else {
|
||||||
|
void switchProvider(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onGenerateClick() {
|
||||||
|
const label = llmStatus ? (PROVIDER_LABELS[llmStatus.provider] ?? llmStatus.provider) : "";
|
||||||
|
if (isHostedStatus(llmStatus)) {
|
||||||
|
requireHostedAck(label, generateSummary);
|
||||||
|
} else {
|
||||||
|
generateSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Editable copy so toggling a checkbox doesn't persist until "Save" is
|
// Editable copy so toggling a checkbox doesn't persist until "Save" is
|
||||||
// pressed; recomputes whenever a different meeting (or a freshly generated
|
// pressed; recomputes whenever a different meeting (or a freshly generated
|
||||||
// summary) comes in. bind:checked mutates each item in place, which a plain
|
// summary) comes in. bind:checked mutates each item in place, which a plain
|
||||||
@@ -375,7 +452,8 @@
|
|||||||
{#if meetings.selected?.recorded}
|
{#if meetings.selected?.recorded}
|
||||||
<h3><Mic2 size={14} aria-hidden="true" /> Recording</h3>
|
<h3><Mic2 size={14} aria-hidden="true" /> Recording</h3>
|
||||||
{#if audioSrc}
|
{#if audioSrc}
|
||||||
<!-- svelte-ignore a11y_media_has_caption -- a meeting recording has no caption track -->
|
<!-- a meeting recording has no caption track — no svelte-ignore needed,
|
||||||
|
the current eslint-plugin-svelte doesn't flag this element. -->
|
||||||
<!-- controlsList/contextmenu: no download affordance — the decrypted audio
|
<!-- controlsList/contextmenu: no download affordance — the decrypted audio
|
||||||
must not be savable to disk (would undermine encryption at rest). -->
|
must not be savable to disk (would undermine encryption at rest). -->
|
||||||
<audio
|
<audio
|
||||||
@@ -475,6 +553,38 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<h3><Sparkles size={14} aria-hidden="true" /> Summary</h3>
|
<h3><Sparkles size={14} aria-hidden="true" /> Summary</h3>
|
||||||
|
|
||||||
|
<div class="provider-row">
|
||||||
|
<label class="provider-select">
|
||||||
|
<span class="sr-only">AI provider</span>
|
||||||
|
<select
|
||||||
|
value={llmStatus?.provider ?? settings.settings.llm_provider}
|
||||||
|
onchange={onProviderChange}
|
||||||
|
disabled={switchingProvider}
|
||||||
|
>
|
||||||
|
{#each Object.entries(PROVIDER_LABELS) as [id, label] (id)}
|
||||||
|
<option value={id}>{label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{#if llmStatus && llmStatus.provider !== "off"}
|
||||||
|
<span class="provider-badge" class:hosted={!llmStatus.isLocal}>
|
||||||
|
{#if llmStatus.isLocal}<Cpu size={12} aria-hidden="true" />{:else}<Globe
|
||||||
|
size={12}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>{/if}
|
||||||
|
{llmStatus.isLocal ? "local" : "leaves this device"}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if showHostedBanner}
|
||||||
|
<HostedAiBanner
|
||||||
|
providerLabel={hostedBannerLabel}
|
||||||
|
onAccept={acceptHostedBanner}
|
||||||
|
onCancel={cancelHostedBanner}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if !meetings.selected}
|
{#if !meetings.selected}
|
||||||
<p class="muted">Select a meeting to generate a summary.</p>
|
<p class="muted">Select a meeting to generate a summary.</p>
|
||||||
{:else if meetings.summarizingId === meetings.selected.id}
|
{:else if meetings.summarizingId === meetings.selected.id}
|
||||||
@@ -494,13 +604,13 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="link" onclick={generateSummary}>Regenerate</button>
|
<button class="link" onclick={onGenerateClick}>Regenerate</button>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="muted">Generated locally after the meeting (requires a local LLM provider).</p>
|
<p class="muted">Generated locally after the meeting (requires a local LLM provider).</p>
|
||||||
{#if llmStatus && llmStatus.provider === "off"}
|
{#if llmStatus && llmStatus.provider === "off"}
|
||||||
<p class="muted small">No local LLM provider configured — enable one in Settings.</p>
|
<p class="muted small">No AI provider configured — enable one in Settings.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="primary" onclick={generateSummary}>
|
<button class="primary" onclick={onGenerateClick}>
|
||||||
<Sparkles size={14} aria-hidden="true" />
|
<Sparkles size={14} aria-hidden="true" />
|
||||||
Generate summary
|
Generate summary
|
||||||
</button>
|
</button>
|
||||||
@@ -742,6 +852,40 @@
|
|||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
border-top: none;
|
border-top: none;
|
||||||
}
|
}
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.provider-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: -0.3rem 0 0.6rem;
|
||||||
|
}
|
||||||
|
.provider-select select {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
}
|
||||||
|
.provider-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: var(--radius-full, 999px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.provider-badge.hosted {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
|
||||||
|
}
|
||||||
button.primary {
|
button.primary {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in new issue
Block a user