Feature chore bug 005 #19
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)
|
||||
},
|
||||
"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
@@ -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
@@ -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\
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user