feat(saas): industry agent template assignment system
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Phase 1-8 of industry-agent-delivery plan: - DB migration: accounts.assigned_template_id (ON DELETE SET NULL) - SaaS API: 4 new endpoints (assign/get/unassign/create-agent) - Service layer: assign_template_to_account, get_assigned_template, unassign_template, create_agent_from_template) - Types: AssignTemplateRequest, AgentConfigFromTemplate (capabilities merged into tools) - Frontend SaaS Client: assignTemplate, getAssignedTemplate, unassignTemplate, createAgentFromTemplate - saasStore: assignedTemplate state + login auto-fetch + actions - saas-relay-client: fix unused import and saasUrl reference error - connectionStore: fix relayModel undefined error - capabilities default to glm-4-flash - Route registration: new template assignment routes Cospec and handlers consolidated Build: cargo check --workspace PASS, tsc --noEmit Pass
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
-- Phase 1: accounts 表增加 assigned_template_id
|
||||
-- 用户选择行业模板后记录分配关系,用于跟踪和跳过 onboarding
|
||||
-- ON DELETE SET NULL: 模板被删除时不影响账户
|
||||
|
||||
ALTER TABLE accounts ADD COLUMN assigned_template_id TEXT
|
||||
REFERENCES agent_templates(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN accounts.assigned_template_id IS
|
||||
'用户选择的行业模板 ID,用于跟踪模板分配状态';
|
||||
@@ -139,3 +139,52 @@ pub async fn archive_template(
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
// --- Template Assignment ---
|
||||
|
||||
/// POST /api/v1/accounts/me/assign-template — 分配行业模板到当前账户
|
||||
pub async fn assign_template(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<AssignTemplateRequest>,
|
||||
) -> SaasResult<Json<AgentTemplateInfo>> {
|
||||
check_permission(&ctx, "model:read")?;
|
||||
|
||||
let result = service::assign_template_to_account(
|
||||
&state.db, &ctx.account_id, &req.template_id,
|
||||
).await?;
|
||||
|
||||
log_operation(&state.db, &ctx.account_id, "account.assign_template", "agent_template", &req.template_id,
|
||||
None, ctx.client_ip.as_deref()).await?;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// GET /api/v1/accounts/me/assigned-template — 获取已分配的行业模板
|
||||
pub async fn get_assigned_template(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Option<AgentTemplateInfo>>> {
|
||||
check_permission(&ctx, "model:read")?;
|
||||
Ok(Json(service::get_assigned_template(&state.db, &ctx.account_id).await?))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/accounts/me/assigned-template — 取消行业模板分配
|
||||
pub async fn unassign_template(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "model:read")?;
|
||||
service::unassign_template(&state.db, &ctx.account_id).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
/// POST /api/v1/agent-templates/:id/create-agent — 从模板创建 Agent 配置
|
||||
pub async fn create_agent_from_template(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path(id): Path<String>,
|
||||
) -> SaasResult<Json<AgentConfigFromTemplate>> {
|
||||
check_permission(&ctx, "model:read")?;
|
||||
Ok(Json(service::create_agent_from_template(&state.db, &id).await?))
|
||||
}
|
||||
|
||||
@@ -10,10 +10,16 @@ use crate::state::AppState;
|
||||
/// Agent 模板管理路由 (需要认证)
|
||||
pub fn routes() -> axum::Router<AppState> {
|
||||
axum::Router::new()
|
||||
// Template CRUD
|
||||
.route("/api/v1/agent-templates", get(handlers::list_templates).post(handlers::create_template))
|
||||
.route("/api/v1/agent-templates/available", get(handlers::list_available))
|
||||
.route("/api/v1/agent-templates/:id", get(handlers::get_template))
|
||||
.route("/api/v1/agent-templates/:id", post(handlers::update_template))
|
||||
.route("/api/v1/agent-templates/:id", delete(handlers::archive_template))
|
||||
.route("/api/v1/agent-templates/:id/full", get(handlers::get_full_template))
|
||||
.route("/api/v1/agent-templates/:id/create-agent", post(handlers::create_agent_from_template))
|
||||
// Template Assignment (per-account)
|
||||
.route("/api/v1/accounts/me/assign-template", post(handlers::assign_template))
|
||||
.route("/api/v1/accounts/me/assigned-template", get(handlers::get_assigned_template))
|
||||
.route("/api/v1/accounts/me/assigned-template", delete(handlers::unassign_template))
|
||||
}
|
||||
|
||||
@@ -266,3 +266,118 @@ pub async fn archive_template(db: &PgPool, id: &str) -> SaasResult<AgentTemplate
|
||||
update_template(db, id, None, None, None, None, None, None, None, None, Some("archived"),
|
||||
None, None, None, None, None, None, None).await
|
||||
}
|
||||
|
||||
// --- Template Assignment ---
|
||||
|
||||
/// Assign a template to the current account.
|
||||
/// Updates accounts.assigned_template_id and returns the template info.
|
||||
pub async fn assign_template_to_account(
|
||||
db: &PgPool,
|
||||
account_id: &str,
|
||||
template_id: &str,
|
||||
) -> SaasResult<AgentTemplateInfo> {
|
||||
// Verify template exists and is active
|
||||
let template = get_template(db, template_id).await?;
|
||||
if template.status != "active" {
|
||||
return Err(SaasError::InvalidInput("模板不可用(已归档)".into()));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET assigned_template_id = $1, updated_at = $2 WHERE id = $3"
|
||||
)
|
||||
.bind(template_id)
|
||||
.bind(&now)
|
||||
.bind(account_id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
Ok(template)
|
||||
}
|
||||
|
||||
/// Get the template assigned to the current account (if any).
|
||||
pub async fn get_assigned_template(
|
||||
db: &PgPool,
|
||||
account_id: &str,
|
||||
) -> SaasResult<Option<AgentTemplateInfo>> {
|
||||
let row = sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT assigned_template_id FROM accounts WHERE id = $1"
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let template_id = match row.flatten() {
|
||||
Some(id) => id,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
// Template may have been deleted (ON DELETE SET NULL), but check anyway
|
||||
match get_template(db, &template_id).await {
|
||||
Ok(t) => Ok(Some(t)),
|
||||
Err(SaasError::NotFound(_)) => {
|
||||
// Template deleted — clear stale reference
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET assigned_template_id = NULL, updated_at = $1 WHERE id = $2"
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(account_id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unassign template from the current account.
|
||||
pub async fn unassign_template(
|
||||
db: &PgPool,
|
||||
account_id: &str,
|
||||
) -> SaasResult<()> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET assigned_template_id = NULL, updated_at = $1 WHERE id = $2"
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(account_id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create an agent configuration from a template.
|
||||
/// Merges capabilities into tools, applies default model fallback.
|
||||
pub async fn create_agent_from_template(
|
||||
db: &PgPool,
|
||||
template_id: &str,
|
||||
) -> SaasResult<AgentConfigFromTemplate> {
|
||||
let t = get_template(db, template_id).await?;
|
||||
if t.status != "active" {
|
||||
return Err(SaasError::InvalidInput("模板不可用(已归档)".into()));
|
||||
}
|
||||
|
||||
// Merge capabilities into tools (deduplicated)
|
||||
let mut merged_tools = t.tools.clone();
|
||||
for cap in &t.capabilities {
|
||||
if !merged_tools.contains(cap) {
|
||||
merged_tools.push(cap.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AgentConfigFromTemplate {
|
||||
name: t.name,
|
||||
model: t.model.unwrap_or_else(|| "glm-4-flash".to_string()),
|
||||
system_prompt: t.system_prompt,
|
||||
tools: merged_tools,
|
||||
soul_content: t.soul_content,
|
||||
welcome_message: t.welcome_message,
|
||||
quick_commands: t.quick_commands,
|
||||
temperature: t.temperature,
|
||||
max_tokens: t.max_tokens,
|
||||
personality: t.personality,
|
||||
communication_style: t.communication_style,
|
||||
emoji: t.emoji,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -103,3 +103,32 @@ pub struct AvailableAgentTemplateInfo {
|
||||
pub description: Option<String>,
|
||||
pub source_id: Option<String>,
|
||||
}
|
||||
|
||||
// --- Template Assignment ---
|
||||
|
||||
/// POST /api/v1/accounts/me/assign-template
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AssignTemplateRequest {
|
||||
pub template_id: String,
|
||||
}
|
||||
|
||||
/// GET /api/v1/accounts/me/assigned-template response (nullable)
|
||||
/// Reuses AgentTemplateInfo when a template is assigned.
|
||||
|
||||
/// Agent configuration derived from a template, returned by create-agent endpoint.
|
||||
/// capabilities are merged into tools (no separate field).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentConfigFromTemplate {
|
||||
pub name: String,
|
||||
pub model: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub tools: Vec<String>,
|
||||
pub soul_content: Option<String>,
|
||||
pub welcome_message: Option<String>,
|
||||
pub quick_commands: Vec<serde_json::Value>,
|
||||
pub temperature: Option<f64>,
|
||||
pub max_tokens: Option<i32>,
|
||||
pub personality: Option<String>,
|
||||
pub communication_style: Option<String>,
|
||||
pub emoji: Option<String>,
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export type {
|
||||
CreateTemplateRequest,
|
||||
AgentTemplateAvailable,
|
||||
AgentTemplateFull,
|
||||
AgentConfigFromTemplate,
|
||||
} from './saas-types';
|
||||
|
||||
export { SaaSApiError } from './saas-errors';
|
||||
@@ -101,6 +102,7 @@ import type {
|
||||
PaginatedResponse,
|
||||
AgentTemplateAvailable,
|
||||
AgentTemplateFull,
|
||||
AgentConfigFromTemplate,
|
||||
} from './saas-types';
|
||||
import { SaaSApiError } from './saas-errors';
|
||||
import { clearSaaSSession } from './saas-session';
|
||||
@@ -403,6 +405,41 @@ export class SaaSClient {
|
||||
async fetchTemplateFull(id: string): Promise<AgentTemplateFull> {
|
||||
return this.request<AgentTemplateFull>('GET', `/api/v1/agent-templates/${id}/full`);
|
||||
}
|
||||
|
||||
// --- Template Assignment ---
|
||||
|
||||
/**
|
||||
* Assign a template to the current account.
|
||||
* Records the user's industry choice for onboarding flow control.
|
||||
*/
|
||||
async assignTemplate(templateId: string): Promise<AgentTemplateFull> {
|
||||
return this.request<AgentTemplateFull>('POST', '/api/v1/accounts/me/assign-template', {
|
||||
template_id: templateId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the template currently assigned to the account.
|
||||
* Returns null if no template is assigned.
|
||||
*/
|
||||
async getAssignedTemplate(): Promise<AgentTemplateFull | null> {
|
||||
return this.request<AgentTemplateFull | null>('GET', '/api/v1/accounts/me/assigned-template');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unassign the current template from the account.
|
||||
*/
|
||||
async unassignTemplate(): Promise<void> {
|
||||
await this.request<unknown>('DELETE', '/api/v1/accounts/me/assigned-template');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent configuration from a template.
|
||||
* Merges capabilities into tools, applies default model fallback.
|
||||
*/
|
||||
async createAgentFromTemplate(templateId: string): Promise<AgentConfigFromTemplate> {
|
||||
return this.request<AgentConfigFromTemplate>('POST', `/api/v1/agent-templates/${templateId}/create-agent`);
|
||||
}
|
||||
}
|
||||
|
||||
// === Install mixin methods ===
|
||||
|
||||
276
desktop/src/lib/saas-relay-client.ts
Normal file
276
desktop/src/lib/saas-relay-client.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* SaaS Relay Gateway Client
|
||||
*
|
||||
* A lightweight GatewayClient-compatible adapter for browser-only mode.
|
||||
* Routes agent listing through SaaS agent-templates, Converts
|
||||
* chatStream() to OpenAI SSE streaming via SaaS relay.
|
||||
*
|
||||
* Used in connectionStore when running in a browser (non-Tauri) with
|
||||
* SaaS relay connection mode.
|
||||
*/
|
||||
|
||||
import type { GatewayClient } from './gateway-client';
|
||||
import { saasClient } from './saas-client';
|
||||
import type { AgentTemplateAvailable } from './saas-types';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const log = createLogger('SaaSRelayGateway');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CloneInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
nickname?: string;
|
||||
emoji?: string;
|
||||
personality?: string;
|
||||
scenarios?: string[];
|
||||
model?: string;
|
||||
status?: string;
|
||||
templateId?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a GatewayClient-compatible object that routes through SaaS APIs.
|
||||
* Only the methods needed by the stores are implemented; others return
|
||||
* sensible defaults.
|
||||
*/
|
||||
export function createSaaSRelayGatewayClient(
|
||||
_saasUrl: string,
|
||||
relayModel: string,
|
||||
): GatewayClient {
|
||||
// saasUrl preserved for future direct API routing (currently routed through saasClient singleton)
|
||||
void _saasUrl;
|
||||
|
||||
// Local in-memory agent registry
|
||||
const agents = new Map<string, CloneInfo>();
|
||||
let defaultAgentId: string | null = null;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helper: list agents as clones
|
||||
// -----------------------------------------------------------------------
|
||||
async function listClones(): Promise<{ clones: CloneInfo[] }> {
|
||||
try {
|
||||
const templates: AgentTemplateAvailable[] = await saasClient.fetchAvailableTemplates();
|
||||
|
||||
const clones: CloneInfo[] = templates.map((t) => {
|
||||
const id = t.id || `agent-${t.name}`;
|
||||
const clone: CloneInfo = {
|
||||
id,
|
||||
name: t.name,
|
||||
role: t.description || t.category,
|
||||
emoji: t.emoji,
|
||||
personality: t.category,
|
||||
scenarios: [],
|
||||
model: relayModel,
|
||||
status: 'active',
|
||||
templateId: t.id,
|
||||
};
|
||||
agents.set(id, clone);
|
||||
return clone;
|
||||
});
|
||||
|
||||
// Set first as default
|
||||
if (clones.length > 0 && !defaultAgentId) {
|
||||
defaultAgentId = clones[0].id;
|
||||
}
|
||||
|
||||
return { clones };
|
||||
} catch (err) {
|
||||
log.warn('Failed to list templates', err);
|
||||
return { clones: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helper: OpenAI SSE streaming via SaaS relay
|
||||
// -----------------------------------------------------------------------
|
||||
async function chatStream(
|
||||
message: string,
|
||||
callbacks: {
|
||||
onDelta: (delta: string) => void;
|
||||
onThinkingDelta?: (delta: string) => void;
|
||||
onTool?: (tool: string, input: string, output: string) => void;
|
||||
onHand?: (name: string, status: string, result?: unknown) => void;
|
||||
onComplete: (inputTokens?: number, outputTokens?: number) => void;
|
||||
onError: (error: string) => void;
|
||||
},
|
||||
opts?: {
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
thinking_enabled?: boolean;
|
||||
reasoning_effort?: string;
|
||||
plan_mode?: boolean;
|
||||
},
|
||||
): Promise<{ runId: string }> {
|
||||
const runId = `run_${Date.now()}`;
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
model: relayModel,
|
||||
messages: [{ role: 'user', content: message }],
|
||||
stream: true,
|
||||
};
|
||||
|
||||
if (opts?.thinking_enabled) body['thinking_enabled'] = true;
|
||||
if (opts?.reasoning_effort) body['reasoning_effort'] = opts.reasoning_effort;
|
||||
|
||||
const response = await saasClient.chatCompletion(body);
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text().catch(() => '');
|
||||
callbacks.onError(`Relay error: ${response.status} ${errText}`);
|
||||
callbacks.onComplete();
|
||||
return { runId };
|
||||
}
|
||||
|
||||
// Parse SSE stream
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
callbacks.onError('No response body');
|
||||
callbacks.onComplete();
|
||||
return { runId };
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // keep incomplete last line
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6).trim();
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const choices = parsed.choices?.[0];
|
||||
if (!choices) continue;
|
||||
|
||||
const delta = choices.delta;
|
||||
|
||||
// Handle thinking/reasoning content
|
||||
if (delta?.reasoning_content) {
|
||||
callbacks.onThinkingDelta?.(delta.reasoning_content);
|
||||
}
|
||||
|
||||
// Handle regular content
|
||||
if (delta?.content) {
|
||||
callbacks.onDelta(delta.content);
|
||||
}
|
||||
|
||||
// Check for completion
|
||||
if (choices.finish_reason) {
|
||||
const usage = parsed.usage;
|
||||
callbacks.onComplete(
|
||||
usage?.prompt_tokens,
|
||||
usage?.completion_tokens,
|
||||
);
|
||||
return { runId };
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed SSE lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended without explicit finish_reason
|
||||
callbacks.onComplete();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
callbacks.onError(msg);
|
||||
callbacks.onComplete();
|
||||
}
|
||||
|
||||
return { runId };
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Build the client object with GatewayClient-compatible shape
|
||||
// -----------------------------------------------------------------------
|
||||
return {
|
||||
// --- Connection ---
|
||||
connect: async () => { log.debug('SaaS relay client connect'); },
|
||||
disconnect: async () => {},
|
||||
getState: () => 'connected' as const,
|
||||
onStateChange: undefined,
|
||||
onLog: undefined,
|
||||
|
||||
// --- Agents (Clones) ---
|
||||
listClones,
|
||||
createClone: async (opts: Record<string, unknown>) => {
|
||||
const id = `agent-${Date.now()}`;
|
||||
const clone: CloneInfo = {
|
||||
id,
|
||||
name: (opts.name as string) || 'New Agent',
|
||||
role: opts.role as string,
|
||||
nickname: opts.nickname as string,
|
||||
emoji: opts.emoji as string,
|
||||
model: relayModel,
|
||||
status: 'active',
|
||||
};
|
||||
agents.set(id, clone);
|
||||
if (!defaultAgentId) defaultAgentId = id;
|
||||
return { clone };
|
||||
},
|
||||
updateClone: async (id: string, updates: Record<string, unknown>) => {
|
||||
const existing = agents.get(id);
|
||||
if (existing) agents.set(id, { ...existing, ...updates });
|
||||
return { clone: agents.get(id) };
|
||||
},
|
||||
deleteClone: async (id: string) => {
|
||||
agents.delete(id);
|
||||
if (defaultAgentId === id) defaultAgentId = null;
|
||||
},
|
||||
getDefaultAgentId: () => defaultAgentId,
|
||||
setDefaultAgentId: (id: string) => { defaultAgentId = id; },
|
||||
|
||||
// --- Chat ---
|
||||
chatStream,
|
||||
|
||||
// --- Hands ---
|
||||
listHands: async () => ({ hands: [] }),
|
||||
getHand: async () => null,
|
||||
triggerHand: async () => ({ runId: `hand_${Date.now()}`, status: 'completed' }),
|
||||
|
||||
// --- Skills ---
|
||||
listSkills: async () => ({ skills: [] }),
|
||||
getSkill: async () => null,
|
||||
createSkill: async () => null,
|
||||
updateSkill: async () => null,
|
||||
deleteSkill: async () => {},
|
||||
|
||||
// --- Config ---
|
||||
getQuickConfig: async () => ({}),
|
||||
saveQuickConfig: async () => {},
|
||||
getWorkspaceInfo: async () => null,
|
||||
|
||||
// --- Health ---
|
||||
health: async () => ({ status: 'ok', mode: 'saas-relay' }),
|
||||
status: async () => ({ version: 'saas-relay', mode: 'browser' }),
|
||||
|
||||
// --- Usage ---
|
||||
getUsageStats: async () => null,
|
||||
getSessionStats: async () => null,
|
||||
|
||||
// --- REST helpers (not used in browser mode) ---
|
||||
restGet: async () => { throw new Error('REST not available in browser mode'); },
|
||||
restPost: async () => { throw new Error('REST not available in browser mode'); },
|
||||
restPut: async () => { throw new Error('REST not available in browser mode'); },
|
||||
restDelete: async () => { throw new Error('REST not available in browser mode'); },
|
||||
restPatch: async () => { throw new Error('REST not available in browser mode'); },
|
||||
} as unknown as GatewayClient;
|
||||
}
|
||||
@@ -460,3 +460,27 @@ export interface CreateTemplateRequest {
|
||||
description?: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
// === Template Assignment Types ===
|
||||
|
||||
/** Request to assign a template to the current account */
|
||||
export interface AssignTemplateRequest {
|
||||
template_id: string;
|
||||
}
|
||||
|
||||
/** Agent configuration derived from a template.
|
||||
* capabilities are merged into tools (no separate field). */
|
||||
export interface AgentConfigFromTemplate {
|
||||
name: string;
|
||||
model: string;
|
||||
system_prompt?: string;
|
||||
tools: string[];
|
||||
soul_content?: string;
|
||||
welcome_message?: string;
|
||||
quick_commands: Array<{ label: string; command: string }>;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
personality?: string;
|
||||
communication_style?: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
@@ -472,6 +472,19 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
||||
// agent management local via KernelClient.
|
||||
// baseUrl = saasUrl + /api/v1/relay → kernel appends /chat/completions
|
||||
// apiKey = SaaS JWT token → sent as Authorization: Bearer <jwt>
|
||||
|
||||
// Fetch available models from SaaS relay (shared by both branches)
|
||||
let relayModels: Array<{ id: string }>;
|
||||
try {
|
||||
relayModels = await saasClient.listModels();
|
||||
} catch {
|
||||
throw new Error('无法获取可用模型列表,请确认管理后台已配置 Provider 和模型');
|
||||
}
|
||||
|
||||
if (relayModels.length === 0) {
|
||||
throw new Error('SaaS 平台没有可用模型,请先在管理后台配置 Provider 和模型');
|
||||
}
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
if (!session.token) {
|
||||
throw new Error('SaaS 中转模式需要认证令牌,请重新登录 SaaS 平台');
|
||||
@@ -479,20 +492,8 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
||||
|
||||
const kernelClient = getKernelClient();
|
||||
|
||||
// Fetch available models from SaaS relay
|
||||
let models: Array<{ id: string }>;
|
||||
try {
|
||||
models = await saasClient.listModels();
|
||||
} catch {
|
||||
throw new Error('无法获取可用模型列表,请确认管理后台已配置 Provider 和模型');
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
throw new Error('SaaS 平台没有可用模型,请先在管理后台配置 Provider 和模型');
|
||||
}
|
||||
|
||||
// Use first available model (TODO: let user choose preferred model)
|
||||
const relayModel = models[0];
|
||||
const relayModel = relayModels[0];
|
||||
|
||||
kernelClient.setConfig({
|
||||
provider: 'custom',
|
||||
@@ -525,9 +526,21 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
||||
baseUrl: `${session.saasUrl}/api/v1/relay`,
|
||||
});
|
||||
} else {
|
||||
// Non-Tauri (browser) — simple connected state without kernel
|
||||
set({ connectionState: 'connected', gatewayVersion: 'saas-relay' });
|
||||
log.debug('Connected to SaaS relay (browser mode)');
|
||||
// Non-Tauri (browser) — use SaaS relay gateway client for agent listing + chat
|
||||
const { createSaaSRelayGatewayClient } = await import('../lib/saas-relay-client');
|
||||
const relayModelId = relayModels[0].id;
|
||||
const relayClient = createSaaSRelayGatewayClient(session.saasUrl, relayModelId);
|
||||
|
||||
set({
|
||||
connectionState: 'connected',
|
||||
gatewayVersion: 'saas-relay',
|
||||
client: relayClient as unknown as GatewayClient,
|
||||
});
|
||||
|
||||
const { initializeStores } = await import('./index');
|
||||
initializeStores();
|
||||
|
||||
log.debug('Connected to SaaS relay (browser mode)', { relayModel: relayModelId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type TotpSetupResponse,
|
||||
type SyncConfigRequest,
|
||||
type AgentTemplateAvailable,
|
||||
type AgentTemplateFull,
|
||||
} from '../lib/saas-client';
|
||||
import { createLogger } from '../lib/logger';
|
||||
import {
|
||||
@@ -73,6 +74,8 @@ export interface SaaSStateSlice {
|
||||
saasReachable: boolean;
|
||||
/** Agent templates available for onboarding */
|
||||
availableTemplates: AgentTemplateAvailable[];
|
||||
/** Currently assigned template (null if not yet assigned or assignment removed) */
|
||||
assignedTemplate: AgentTemplateFull | null;
|
||||
/** Consecutive heartbeat/health-check failures */
|
||||
_consecutiveFailures: number;
|
||||
_heartbeatTimer?: ReturnType<typeof setInterval>;
|
||||
@@ -90,6 +93,12 @@ export interface SaaSActionsSlice {
|
||||
pushConfigToSaaS: () => Promise<void>;
|
||||
registerCurrentDevice: () => Promise<void>;
|
||||
fetchAvailableTemplates: () => Promise<void>;
|
||||
/** Assign a template to the current account */
|
||||
assignTemplate: (templateId: string) => Promise<void>;
|
||||
/** Fetch the currently assigned template (auto-called after login) */
|
||||
fetchAssignedTemplate: () => Promise<void>;
|
||||
/** Unassign the current template */
|
||||
unassignTemplate: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
restoreSession: () => void;
|
||||
setupTotp: () => Promise<TotpSetupResponse>;
|
||||
@@ -145,6 +154,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
totpSetupData: null,
|
||||
saasReachable: true,
|
||||
availableTemplates: [],
|
||||
assignedTemplate: null,
|
||||
_consecutiveFailures: 0,
|
||||
|
||||
// === Actions ===
|
||||
@@ -199,6 +209,11 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
log.warn('Failed to fetch templates after login:', err);
|
||||
});
|
||||
|
||||
// Fetch assigned template in background (non-blocking)
|
||||
get().fetchAssignedTemplate().catch((err: unknown) => {
|
||||
log.warn('Failed to fetch assigned template after login:', err);
|
||||
});
|
||||
|
||||
// Fetch available models in background (non-blocking)
|
||||
get().fetchAvailableModels().catch((err: unknown) => {
|
||||
log.warn('Failed to fetch models after login:', err);
|
||||
@@ -628,6 +643,35 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
assignTemplate: async (templateId: string) => {
|
||||
try {
|
||||
const template = await saasClient.assignTemplate(templateId);
|
||||
set({ assignedTemplate: template });
|
||||
} catch (err) {
|
||||
log.warn('Failed to assign template:', err);
|
||||
// Don't throw — let wizard continue with fallback flow
|
||||
}
|
||||
},
|
||||
|
||||
fetchAssignedTemplate: async () => {
|
||||
try {
|
||||
const template = await saasClient.getAssignedTemplate();
|
||||
set({ assignedTemplate: template });
|
||||
} catch {
|
||||
// Not critical — null is fine
|
||||
set({ assignedTemplate: null });
|
||||
}
|
||||
},
|
||||
|
||||
unassignTemplate: async () => {
|
||||
try {
|
||||
await saasClient.unassignTemplate();
|
||||
set({ assignedTemplate: null });
|
||||
} catch (err) {
|
||||
log.warn('Failed to unassign template:', err);
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => {
|
||||
set({ error: null });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user