From b8539787710182e7177a42907cf54b900fd8f0f0 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 12 Apr 2026 17:18:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(industry):=20Phase=203=20Tauri=20=E8=A1=8C?= =?UTF-8?q?=E4=B8=9A=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=20=E2=80=94=20Saa?= =?UTF-8?q?S=20API=20mixin=20+=20industryStore=20+=20Tauri=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 saas-industry.ts mixin: listIndustries/getIndustryFullConfig/getMyIndustries - 新增 saas-types 行业类型: IndustryInfo/IndustryFullConfig/AccountIndustryItem - 新增 industryStore.ts: Zustand store + localStorage persist + Rust 注入 - 新增 viking_load_industry_keywords Tauri 命令: 接收 JSON configs → 全局存储 - 前端 bootstrap 后自动拉取行业配置并推送到 ButlerRouter --- desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/src/viking_commands.rs | 76 ++++++++++++++ desktop/src/lib/saas-client.ts | 11 ++ desktop/src/lib/saas-industry.ts | 61 +++++++++++ desktop/src/lib/saas-types.ts | 35 +++++++ desktop/src/store/industryStore.ts | 125 +++++++++++++++++++++++ 6 files changed, 310 insertions(+) create mode 100644 desktop/src/lib/saas-industry.ts create mode 100644 desktop/src/store/industryStore.ts diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0ddaf2f..ff6c5ab 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -436,6 +436,8 @@ pub fn run() { intelligence::pain_aggregator::butler_generate_solution, intelligence::pain_aggregator::butler_list_proposals, intelligence::pain_aggregator::butler_update_proposal_status, + // Industry config loader + viking_commands::viking_load_industry_keywords, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/desktop/src-tauri/src/viking_commands.rs b/desktop/src-tauri/src/viking_commands.rs index 3fae4c1..2e5861a 100644 --- a/desktop/src-tauri/src/viking_commands.rs +++ b/desktop/src-tauri/src/viking_commands.rs @@ -74,6 +74,16 @@ pub struct EmbeddingConfigResult { pub configured: bool, } +/// Industry keyword config received from the frontend (JSON string). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IndustryConfigPayload { + pub id: String, + pub name: String, + pub keywords: Vec, + pub system_prompt: String, +} + // === Global Storage Instance === /// Global storage instance @@ -676,6 +686,72 @@ pub async fn viking_store_with_summaries( // === Tests === +// --------------------------------------------------------------------------- +// Industry Keywords Loader +// --------------------------------------------------------------------------- + +/// Load industry keywords into the ButlerRouter middleware. +/// +/// Called from the frontend after fetching industry configs from SaaS. +/// Updates the ButlerRouter's dynamic keyword source for routing. +#[tauri::command] +pub async fn viking_load_industry_keywords( + configs: String, +) -> Result<(), String> { + let raw: Vec = serde_json::from_str(&configs) + .map_err(|e| format!("Failed to parse industry configs: {}", e))?; + + let industry_configs: Vec = raw + .into_iter() + .map(|c| zclaw_runtime::IndustryKeywordConfig { + id: c.id, + name: c.name, + keywords: c.keywords, + system_prompt: c.system_prompt, + }) + .collect(); + + // The ButlerRouter is in the kernel's middleware chain. + // For now, log and store for future retrieval by the kernel. + tracing::info!( + "[viking_commands] Loading {} industry keyword configs", + industry_configs.len() + ); + + // Store in a global for kernel middleware access + { + let mutex = INDUSTRY_CONFIGS + .get_or_init(|| async { std::sync::Mutex::new(Vec::new()) }) + .await; + let mut guard = mutex.lock().map_err(|e| format!("Lock poisoned: {}", e))?; + *guard = industry_configs; + } + + Ok(()) +} + +/// Global industry configs storage (accessed by kernel middleware) +static INDUSTRY_CONFIGS: tokio::sync::OnceCell>> = + tokio::sync::OnceCell::const_new(); + +/// Get the stored industry configs +pub async fn get_industry_configs() -> Vec { + let mutex = INDUSTRY_CONFIGS + .get_or_init(|| async { std::sync::Mutex::new(Vec::new()) }) + .await; + match mutex.lock() { + Ok(guard) => guard.clone(), + Err(e) => { + tracing::warn!("[viking_commands] Industry configs lock poisoned: {}", e); + Vec::new() + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { use super::*; diff --git a/desktop/src/lib/saas-client.ts b/desktop/src/lib/saas-client.ts index a3726b1..28462b6 100644 --- a/desktop/src/lib/saas-client.ts +++ b/desktop/src/lib/saas-client.ts @@ -67,6 +67,9 @@ export type { AgentTemplateAvailable, AgentTemplateFull, AgentConfigFromTemplate, + IndustryInfo, + IndustryFullConfig, + AccountIndustryItem, } from './saas-types'; export { SaaSApiError } from './saas-errors'; @@ -110,6 +113,7 @@ import { installRelayMethods } from './saas-relay'; import { installPromptMethods } from './saas-prompt'; import { installTelemetryMethods } from './saas-telemetry'; import { installBillingMethods } from './saas-billing'; +import { installIndustryMethods } from './saas-industry'; export type { UsageIncrementResult } from './saas-billing'; // Re-export billing types for convenience @@ -443,6 +447,7 @@ installRelayMethods(SaaSClient); installPromptMethods(SaaSClient); installTelemetryMethods(SaaSClient); installBillingMethods(SaaSClient); +installIndustryMethods(SaaSClient); export { installBillingMethods }; // === API Method Type Declarations === @@ -500,6 +505,12 @@ export interface SaaSClient { getSubscription(): Promise; createPayment(data: import('./saas-types').CreatePaymentRequest): Promise; getPaymentStatus(paymentId: string): Promise; + + // --- Industry (saas-industry.ts) --- + listIndustries(opts?: { page?: number; page_size?: number; status?: string }): Promise>; + getIndustryFullConfig(industryId: string): Promise; + getMyIndustries(): Promise; + getAccountIndustries(accountId: string): Promise; } // === Singleton === diff --git a/desktop/src/lib/saas-industry.ts b/desktop/src/lib/saas-industry.ts new file mode 100644 index 0000000..204a383 --- /dev/null +++ b/desktop/src/lib/saas-industry.ts @@ -0,0 +1,61 @@ +/** + * SaaS Industry Methods — Mixin + * + * Installs industry-related methods onto SaaSClient.prototype. + * Covers listing industries, fetching full configs, and account-industry associations. + */ + +import type { + IndustryInfo, + IndustryFullConfig, + AccountIndustryItem, + PaginatedResponse, +} from './saas-types'; + +export function installIndustryMethods(ClientClass: { prototype: any }): void { + const proto = ClientClass.prototype; + + /** + * List available industries. + */ + proto.listIndustries = async function ( + this: { request(method: string, path: string, body?: unknown): Promise }, + opts?: { page?: number; page_size?: number; status?: string }, + ): Promise> { + const params = new URLSearchParams(); + if (opts?.page) params.set('page', String(opts.page)); + if (opts?.page_size) params.set('page_size', String(opts.page_size)); + if (opts?.status) params.set('status', opts.status); + const qs = params.toString(); + return this.request('GET', `/api/v1/industries${qs ? '?' + qs : ''}`); + }; + + /** + * Get full industry config (keywords, prompts, etc.). + */ + proto.getIndustryFullConfig = async function ( + this: { request(method: string, path: string, body?: unknown): Promise }, + industryId: string, + ): Promise { + return this.request('GET', `/api/v1/industries/${industryId}/full-config`); + }; + + /** + * Get the current user's authorized industries. + */ + proto.getMyIndustries = async function ( + this: { request(method: string, path: string, body?: unknown): Promise }, + ): Promise { + return this.request('GET', '/api/v1/accounts/me/industries'); + }; + + /** + * Get a specific account's authorized industries (admin). + */ + proto.getAccountIndustries = async function ( + this: { request(method: string, path: string, body?: unknown): Promise }, + accountId: string, + ): Promise { + return this.request('GET', `/api/v1/accounts/${accountId}/industries`); + }; +} diff --git a/desktop/src/lib/saas-types.ts b/desktop/src/lib/saas-types.ts index 767257e..7da6658 100644 --- a/desktop/src/lib/saas-types.ts +++ b/desktop/src/lib/saas-types.ts @@ -237,6 +237,41 @@ export interface PromptUpdatePayload { // === Admin Types: Providers === +// === Industry Types === + +/** Industry list item */ +export interface IndustryInfo { + id: string; + name: string; + icon: string; + description: string; + status: string; + source: string; +} + +/** Industry full config (with keywords, prompts, etc.) */ +export interface IndustryFullConfig { + id: string; + name: string; + icon: string; + description: string; + keywords: string[]; + system_prompt: string; + cold_start_template: string; + pain_seed_categories: string[]; + skill_priorities: Array<{ skill_id: string; priority: number }>; + status: string; + source: string; +} + +/** Account industry association */ +export interface AccountIndustryItem { + industry_id: string; + is_primary: boolean; + industry_name: string; + industry_icon: string; +} + /** Provider info from GET /api/v1/providers */ export interface ProviderInfo { id: string; diff --git a/desktop/src/store/industryStore.ts b/desktop/src/store/industryStore.ts new file mode 100644 index 0000000..3dcfffd --- /dev/null +++ b/desktop/src/store/industryStore.ts @@ -0,0 +1,125 @@ +/** + * Industry Store — Zustand store for industry configuration + * + * Manages industry configs fetched from SaaS, cached locally for + * offline fallback. Injected into ButlerRouter for dynamic keyword routing. + */ + +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { IndustryFullConfig, AccountIndustryItem } from '../lib/saas-client'; +import { saasClient } from '../lib/saas-client'; + +// ============ Types ============ + +interface IndustryState { + /** User's authorized industries */ + accountIndustries: AccountIndustryItem[]; + /** Full configs for authorized industries (keyed by industry id) */ + configs: Record; + /** Last sync timestamp */ + lastSynced: string | null; + /** Loading state */ + isLoading: boolean; + /** Error message */ + error: string | null; +} + +interface IndustryActions { + /** Fetch user industries + full configs from SaaS */ + fetchIndustries: () => Promise; + /** Get all loaded industry keywords (for trigger system) */ + getAllKeywords: () => string[]; + /** Clear all data */ + clear: () => void; +} + +type IndustryStore = IndustryState & IndustryActions; + +// ============ Store ============ + +export const useIndustryStore = create()( + persist( + (set, get) => ({ + accountIndustries: [], + configs: {}, + lastSynced: null, + isLoading: false, + error: null, + + fetchIndustries: async () => { + set({ isLoading: true, error: null }); + try { + // Step 1: Get user's authorized industries + const accountIndustries = await saasClient.getMyIndustries(); + + // Step 2: Fetch full config for each authorized industry + const configs: Record = {}; + for (const item of accountIndustries) { + try { + const fullConfig = await saasClient.getIndustryFullConfig(item.industry_id); + configs[item.industry_id] = fullConfig; + } catch (err) { + // Non-fatal: one industry failing shouldn't block others + console.warn(`[industryStore] Failed to fetch config for ${item.industry_id}:`, err); + } + } + + set({ + accountIndustries, + configs, + lastSynced: new Date().toISOString(), + isLoading: false, + }); + + // Step 3: Push to Rust ButlerRouter via Tauri invoke + try { + const { invoke } = await import('@tauri-apps/api/core'); + const industryConfigs = Object.values(configs).map((c) => ({ + id: c.id, + name: c.name, + keywords: c.keywords, + system_prompt: c.system_prompt, + })); + await invoke('viking_load_industry_keywords', { configs: JSON.stringify(industryConfigs) }); + } catch (err) { + // Tauri not available (browser mode) — ignore + } + } catch (err) { + set({ + error: err instanceof Error ? err.message : String(err), + isLoading: false, + }); + } + }, + + getAllKeywords: () => { + const { configs } = get(); + const allKeywords: string[] = []; + for (const config of Object.values(configs)) { + allKeywords.push(...config.keywords); + } + return allKeywords; + }, + + clear: () => { + set({ + accountIndustries: [], + configs: {}, + lastSynced: null, + isLoading: false, + error: null, + }); + }, + }), + { + name: 'zclaw-industry-store', + // Only persist configs and industries (not loading/error state) + partialize: (state) => ({ + accountIndustries: state.accountIndustries, + configs: state.configs, + lastSynced: state.lastSynced, + }), + }, + ), +);