Feature chore bug 005 #19

Merged
iamdoubz merged 106 commits from feature_chore_bug_005 into main 2026-07-07 22:02:41 -05:00
9 changed files with 1207 additions and 72 deletions
Showing only changes of commit e966181ab8 - Show all commits

No files matched your search

+2 -1
View File
@@ -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)
},
"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",
"model": "llama3",
"stream": true,
// 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.
// settings.json only holds the global default. No credentials here (FR-SYNC-6).
+158 -19
View File
@@ -57,6 +57,7 @@ fn default_settings() -> Settings {
low_overhead: false,
default_record: false,
consent_acknowledged: false,
hosted_ai_acknowledged: false,
sync_enabled: false,
retention_max_age_days: 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(),
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"
}
}
@@ -1994,34 +2007,87 @@ pub async fn llm_status() -> WaResult<serde_json::Value> {
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
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 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
/// (`apiKey`, stored only in the OS credential store — never settings/DB)
/// land in Phase 10a.
#[tauri::command]
pub async fn set_llm_provider(args: SetLlmProviderArgs) -> WaResult<()> {
if !matches!(args.provider.as_str(), "ollama" | "custom" | "off") {
return Err(WaError::new(
"llm",
format!(
"provider '{}' is not available yet — hosted providers land in Phase 10a",
args.provider
),
));
}
let mut settings = load_settings();
settings.llm_provider = args.provider;
if let Some(endpoint) = args.endpoint {
/// Pure settings mutation for `set_llm_provider` — everything except the
/// credential-store write, so it's unit-testable without touching a real
/// keyring and so the "the API key never reaches `Settings`" guarantee is
/// checkable structurally (this function's signature has no key parameter
/// at all; see `set_llm_provider_never_writes_the_api_key_into_settings`).
/// Anthropic's endpoint is fixed, not user-configurable (ADR-0011) — any
/// `endpoint` argument is ignored for that provider so a stale endpoint left
/// over from a previous "custom"/"ollama" selection can't leak through.
/// Symmetrically, switching *away* from Anthropic without supplying a new
/// endpoint resets to Ollama's own local default rather than silently
/// 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.
fn apply_llm_provider_args(
settings: &mut Settings,
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;
} 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;
}
}
/// 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)
}
@@ -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]
fn privacy_self_check_only_enabled_sync_targets_join_the_allowlist() {
let mut settings = default_settings();
+644 -30
View File
@@ -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 \
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();
if !prompt.metadata.is_empty() {
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, 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![
serde_json::json!({ "role": "system", "content": system }),
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. \
\"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> {
let (system, user) = tag_system_and_user(transcript);
vec![
serde_json::json!({ "role": "system", "content": TAG_INSTRUCTIONS }),
serde_json::json!({ "role": "user", "content": format!("Transcript:\n{transcript}") }),
serde_json::json!({ "role": "system", "content": system }),
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 ----
// 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 —
/// 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> {
// T10a.2: `keyring` lookup by `credential_ref` lands with hosted
// providers; a local custom endpoint has no `credential_ref` at all.
None
}
@@ -812,42 +883,297 @@ pub async fn pull_model(
.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 model: 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]
impl LlmProvider for AnthropicProvider {
async fn status(&self) -> LlmStatus {
// Not built yet (Phase 10a) and not reachable today: `set_llm_provider`
// rejects "anthropic" before settings could ever select this provider.
LlmStatus {
provider: "anthropic".to_string(),
reachable: false,
is_local: false,
models: Vec::new(),
}
let Ok(key) = self.api_key() else {
return LlmStatus {
provider: "anthropic".to_string(),
reachable: false,
is_local: false,
models: Vec::new(),
};
};
self.status_with_key(&key).await
}
async fn summarize(&self, _prompt: Prompt, _out: TokenSink) -> Result<Summary, LlmError> {
Err(LlmError::Request(
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
))
async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result<Summary, LlmError> {
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(
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
))
async fn suggest_tags(&self, transcript: &str) -> Result<Vec<String>, LlmError> {
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
// ever gets an Ollama/OpenAI-compatible provider from
// `llm_provider_from_settings` today, so this path isn't reachable yet.
Err(LlmError::Request(
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
))
async fn complete(&self, system: &str, user: &str) -> Result<String, LlmError> {
let key = self.api_key()?;
self.complete_with_key(&key, system, user).await
}
fn is_local(&self) -> bool {
false
}
@@ -857,6 +1183,294 @@ impl LlmProvider for AnthropicProvider {
mod tests {
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]
fn parse_summary_splits_the_three_requested_sections() {
let text = "## Summary\nWe discussed the roadmap.\nIt went well.\n\n\
+7
View File
@@ -202,6 +202,13 @@ pub struct Settings {
// Recording retention (ADR-0009). Default OFF.
pub default_record: 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.
pub sync_enabled: bool,
// Storage retention policy (FR-STORE-2). None = no cap on that dimension.
+5 -1
View File
@@ -57,7 +57,7 @@ export interface AudioDeviceInfo {
}
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;
isLocal: boolean;
models: string[];
@@ -287,6 +287,10 @@ export interface AppSettings {
low_overhead: boolean;
default_record: 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;
mcp_enabled: boolean;
mcp_transport: string; // http|stdio
+93
View File
@@ -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>
+18 -2
View File
@@ -30,6 +30,7 @@ const DEFAULT_SETTINGS: AppSettings = {
low_overhead: false,
default_record: false, // recording OFF by default (ADR-0009)
consent_acknowledged: false,
hosted_ai_acknowledged: false, // hosted-AI "leaves your device" notice (ADR-0011)
sync_enabled: false, // sync OFF by default (ADR-0010)
mcp_enabled: false, // MCP server OFF by default (ADR-0011)
mcp_transport: "http",
@@ -251,8 +252,16 @@ class SettingsStore {
}
}
/** Persist the LLM provider/endpoint/model and refresh status (T5.2). */
async setLlmProvider(config: { provider: string; endpoint?: string; model?: string }) {
/** Persist the LLM provider/endpoint/model (+ hosted apiKey, ADR-0011) and
* 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;
// Optimistic local update so the form reflects the change immediately.
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"]) {
await this.patch({ preferred_backend: backend });
await this.loadHardware();
+130 -13
View File
@@ -6,6 +6,7 @@
import { settings } from "../stores/settings.svelte";
import { calendar } from "../stores/calendar.svelte";
import ConsentNotice from "../components/ConsentNotice.svelte";
import HostedAiBanner from "../components/HostedAiBanner.svelte";
import { open } from "@tauri-apps/plugin-dialog";
import { api, errorMessage, events } from "../api";
import type { BackendId, SyncKind, SyncTargetConfig, SyncTargetInfo } from "../api";
@@ -28,6 +29,9 @@
Bot,
Copy,
KeyRound,
Eye,
EyeOff,
Globe,
} from "@lucide/svelte";
import {
OLLAMA_OPTIONS,
@@ -47,22 +51,57 @@
const SOURCE_URL = "https://git.dou.bet/iamdoubz/WhispAssist";
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 llmEndpoint = $state(settings.settings.llm_endpoint);
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(() => {
// Re-sync the form when settings (re)load or are saved elsewhere.
llmProvider = settings.settings.llm_provider;
llmEndpoint = settings.settings.llm_endpoint;
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() {
await settings.setLlmProvider({
provider: llmProvider,
endpoint: llmEndpoint || undefined,
endpoint: llmProvider === "anthropic" ? undefined : llmEndpoint || 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
* "leaves your network" hint while typing. */
@@ -1035,8 +1074,10 @@
<section>
<h3>AI summary provider</h3>
<p class="muted">
Summaries run on a local LLM. Point this at Ollama on this PC or another machine on your
LAN (e.g. <code>192.168.0.x</code>) — both count as local, so nothing leaves your network.
Summaries run on a local LLM by default. Point this at Ollama on this PC or another
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>
<label
>Provider
@@ -1044,32 +1085,76 @@
<option value="off">Off</option>
<option value="ollama">Ollama (local / LAN)</option>
<option value="custom">Custom (OpenAI-compatible)</option>
<option value="anthropic">Anthropic (Claude) — hosted, leaves this device</option>
</select>
</label>
{#if llmProvider !== "off"}
<div class="grid">
<label class="wide"
>Endpoint<input
bind:value={llmEndpoint}
placeholder="http://192.168.0.42:11434"
/></label
>
<label>Model<input bind:value={llmModel} placeholder="llama3.1" /></label>
{#if llmProvider !== "anthropic"}
<label class="wide"
>Endpoint<input
bind:value={llmEndpoint}
placeholder="http://192.168.0.42:11434"
/></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>
{#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">
<AlertTriangle size={14} aria-hidden="true" />
This endpoint isn't on your machine or LAN — your transcript would leave your network.
</div>
{/if}
<div class="actions">
<button class="primary" onclick={saveLlm} disabled={settings.llmSaving}>
<button class="primary" onclick={onSaveLlmClick} disabled={settings.llmSaving}>
{settings.llmSaving ? "Saving…" : "Save"}
</button>
<button class="ghost" onclick={() => settings.loadLlmStatus()}>
<RefreshCw size={14} aria-hidden="true" /> Test connection
</button>
</div>
{#if showHostedBanner}
<HostedAiBanner
providerLabel={HOSTED_PROVIDER_LABELS[llmProvider] ?? "this hosted provider"}
onAccept={acceptHostedBannerAndSave}
onCancel={() => (showHostedBanner = false)}
/>
{/if}
{#if settings.llmStatus}
{@const s = settings.llmStatus}
<div class="status-card">
@@ -1719,6 +1804,38 @@
margin-top: 0.15rem;
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.models,
ul.events {
+150 -6
View File
@@ -15,6 +15,7 @@
} from "../api";
import { renderMarkdown } from "../markdown";
import TagChip from "../components/TagChip.svelte";
import HostedAiBanner from "../components/HostedAiBanner.svelte";
import {
Tags,
Sparkles,
@@ -27,6 +28,8 @@
FileText,
Copy,
Check,
Cpu,
Globe,
} from "@lucide/svelte";
onMount(() => calendar.load());
@@ -53,19 +56,93 @@
// ---- Summary + action items (T5.4/T5.5/T5.6, FR-LLM-2/3/4) ----
let llmStatus = $state<LlmStatus | null>(null);
onMount(async () => {
async function refreshLlmStatus() {
try {
llmStatus = await api.llmStatus();
} catch {
llmStatus = null;
}
});
}
onMount(refreshLlmStatus);
function generateSummary() {
const m = meetings.selected;
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
// pressed; recomputes whenever a different meeting (or a freshly generated
// summary) comes in. bind:checked mutates each item in place, which a plain
@@ -375,7 +452,8 @@
{#if meetings.selected?.recorded}
<h3><Mic2 size={14} aria-hidden="true" /> Recording</h3>
{#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
must not be savable to disk (would undermine encryption at rest). -->
<audio
@@ -475,6 +553,38 @@
{/if}
<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}
<p class="muted">Select a meeting to generate a summary.</p>
{:else if meetings.summarizingId === meetings.selected.id}
@@ -494,13 +604,13 @@
{/each}
</ul>
{/if}
<button class="link" onclick={generateSummary}>Regenerate</button>
<button class="link" onclick={onGenerateClick}>Regenerate</button>
{:else}
<p class="muted">Generated locally after the meeting (requires a local LLM provider).</p>
{#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}
<button class="primary" onclick={generateSummary}>
<button class="primary" onclick={onGenerateClick}>
<Sparkles size={14} aria-hidden="true" />
Generate summary
</button>
@@ -742,6 +852,40 @@
padding-top: 0;
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 {
display: inline-flex;
align-items: center;