Compare commits
29 Commits
45fd9fee7b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7db9eb29a0 | ||
|
|
1e65b56a0f | ||
|
|
3c01754c40 | ||
|
|
08af78aa83 | ||
|
|
b69dc6115d | ||
|
|
7dea456fda | ||
|
|
f6c5dd21ce | ||
|
|
47250a3b70 | ||
|
|
215c079d29 | ||
|
|
043824c722 | ||
|
|
bd12bdb62b | ||
|
|
28c892fd31 | ||
|
|
9715f542b6 | ||
|
|
5121a3c599 | ||
|
|
ee1c9ef3ea | ||
|
|
76d36f62a6 | ||
|
|
be2a136392 | ||
|
|
76cdfd0c00 | ||
|
|
02a4ba5e75 | ||
|
|
a8a0751005 | ||
|
|
9c59e6e82a | ||
|
|
27b98cae6f | ||
|
|
d0aabf5f2e | ||
|
|
3c42e0d692 | ||
|
|
e0eb7173c5 | ||
|
|
6721a1cc6e | ||
|
|
d2a0c8efc0 | ||
|
|
70229119be | ||
|
|
dd854479eb |
@@ -529,7 +529,7 @@ refactor(store): 统一 Store 数据获取方式
|
||||
***
|
||||
|
||||
<!-- ARCH-SNAPSHOT-START -->
|
||||
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-09 -->
|
||||
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-15 -->
|
||||
|
||||
## 13. 当前架构快照
|
||||
|
||||
@@ -539,6 +539,7 @@ refactor(store): 统一 Store 数据获取方式
|
||||
|--------|------|----------|
|
||||
| 管家模式 (Butler) | ✅ 活跃 | 04-12 行业配置4行业 + 跨会话连续性 + <butler-context> XML fencing |
|
||||
| Hermes 管线 | ✅ 活跃 | 04-12 触发信号持久化 + 经验行业维度 + 注入格式优化 |
|
||||
| Intelligence Heartbeat | ✅ 活跃 | 04-15 统一健康快照 (health_snapshot.rs) + HeartbeatManager 重构 + HealthPanel 前端 |
|
||||
| 聊天流 (ChatStream) | ✅ 稳定 | 04-02 ChatStore 拆分为 4 Store (stream/conversation/message/chat) |
|
||||
| 记忆管道 (Memory) | ✅ 稳定 | 04-02 闭环修复: 对话→提取→FTS5+TF-IDF→检索→注入 |
|
||||
| SaaS 认证 (Auth) | ✅ 稳定 | Token池 RPM/TPM 轮换 + JWT password_version 失效机制 |
|
||||
@@ -559,7 +560,8 @@ refactor(store): 统一 Store 数据获取方式
|
||||
|
||||
### 最近变更
|
||||
|
||||
1. [04-12] 行业配置+管家主动性 全栈 5 Phase: 行业数据模型+4内置配置+ButlerRouter动态关键词+触发信号+Tauri加载+Admin管理页面+跨会话连续性+XML fencing注入格式
|
||||
1. [04-15] Heartbeat 统一健康系统: health_snapshot.rs 统一收集器(LLM连接/记忆/会话/系统资源) + heartbeat.rs HeartbeatManager 重构 + HealthPanel.tsx 前端面板 + Tauri 命令 182→183 + intelligence 模块 15→16 文件 + 删除 intelligence-client/ 9 废弃文件
|
||||
2. [04-12] 行业配置+管家主动性 全栈 5 Phase: 行业数据模型+4内置配置+ButlerRouter动态关键词+触发信号+Tauri加载+Admin管理页面+跨会话连续性+XML fencing注入格式
|
||||
2. [04-09] Hermes Intelligence Pipeline 4 Chunk: ExperienceStore+Extractor, UserProfileStore+Profiler, NlScheduleParser, TrajectoryRecorder+Compressor (684 tests, 0 failed)
|
||||
3. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档
|
||||
3. [04-07] @reserved 标注 5 个 butler Tauri 命令 + 痛点持久化 SQLite
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ProColumns } from '@ant-design/pro-components'
|
||||
import { ProTable } from '@ant-design/pro-components'
|
||||
import { accountService } from '@/services/accounts'
|
||||
import { industryService } from '@/services/industries'
|
||||
import { billingService } from '@/services/billing'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import type { AccountPublic } from '@/types'
|
||||
|
||||
@@ -70,6 +71,12 @@ export default function Accounts() {
|
||||
}
|
||||
}, [accountIndustries, editingId, form])
|
||||
|
||||
// 获取所有活跃计划(用于管理员切换)
|
||||
const { data: plansData } = useQuery({
|
||||
queryKey: ['billing-plans'],
|
||||
queryFn: ({ signal }) => billingService.listPlans(signal),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
|
||||
accountService.update(id, data),
|
||||
@@ -101,6 +108,14 @@ export default function Accounts() {
|
||||
onError: (err: Error) => message.error(err.message || '行业授权更新失败'),
|
||||
})
|
||||
|
||||
// 管理员切换用户计划
|
||||
const switchPlanMutation = useMutation({
|
||||
mutationFn: ({ accountId, planId }: { accountId: string; planId: string }) =>
|
||||
billingService.adminSwitchPlan(accountId, planId),
|
||||
onSuccess: () => message.success('计划切换成功'),
|
||||
onError: (err: Error) => message.error(err.message || '计划切换失败'),
|
||||
})
|
||||
|
||||
const columns: ProColumns<AccountPublic>[] = [
|
||||
{ title: '用户名', dataIndex: 'username', width: 120, tooltip: '搜索用户名、邮箱或显示名' },
|
||||
{ title: '显示名', dataIndex: 'display_name', width: 120, hideInSearch: true },
|
||||
@@ -186,7 +201,7 @@ export default function Accounts() {
|
||||
|
||||
try {
|
||||
// 更新基础信息
|
||||
const { industry_ids, ...accountData } = values
|
||||
const { industry_ids, plan_id, ...accountData } = values
|
||||
await updateMutation.mutateAsync({ id: editingId, data: accountData })
|
||||
|
||||
// 更新行业授权(如果变更了)
|
||||
@@ -201,6 +216,11 @@ export default function Accounts() {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-industries'] })
|
||||
}
|
||||
|
||||
// 切换订阅计划(如果选择了新计划)
|
||||
if (plan_id) {
|
||||
await switchPlanMutation.mutateAsync({ accountId: editingId, planId: plan_id })
|
||||
}
|
||||
|
||||
handleClose()
|
||||
} catch {
|
||||
// Errors handled by mutation onError callbacks
|
||||
@@ -218,6 +238,11 @@ export default function Accounts() {
|
||||
label: `${item.icon} ${item.name}`,
|
||||
}))
|
||||
|
||||
const planOptions = (plansData || []).map((plan) => ({
|
||||
value: plan.id,
|
||||
label: `${plan.display_name} (¥${(plan.price_cents / 100).toFixed(0)}/月)`,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="账号管理" description="管理系统用户账号、角色、权限与行业授权" />
|
||||
@@ -256,7 +281,7 @@ export default function Accounts() {
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={handleClose}
|
||||
confirmLoading={updateMutation.isPending || setIndustriesMutation.isPending}
|
||||
confirmLoading={updateMutation.isPending || setIndustriesMutation.isPending || switchPlanMutation.isPending}
|
||||
width={560}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="mt-4">
|
||||
@@ -280,6 +305,21 @@ export default function Accounts() {
|
||||
]} />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>订阅计划</Divider>
|
||||
|
||||
<Form.Item
|
||||
name="plan_id"
|
||||
label="切换计划"
|
||||
extra="选择新计划后保存将立即切换。留空则不修改当前计划。"
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="不修改当前计划"
|
||||
options={planOptions}
|
||||
loading={!plansData}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>行业授权</Divider>
|
||||
|
||||
<Form.Item
|
||||
|
||||
169
admin-v2/src/pages/ApiKeys.tsx
Normal file
169
admin-v2/src/pages/ApiKeys.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, message, Tag, Modal, Form, Input, InputNumber, Select, Space, Popconfirm, Typography } from 'antd'
|
||||
import { PlusOutlined, CopyOutlined } from '@ant-design/icons'
|
||||
import { ProTable } from '@ant-design/pro-components'
|
||||
import type { ProColumns } from '@ant-design/pro-components'
|
||||
import { apiKeyService } from '@/services/api-keys'
|
||||
import type { TokenInfo } from '@/types'
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
const PERMISSION_OPTIONS = [
|
||||
{ label: 'Relay Chat', value: 'relay:use' },
|
||||
{ label: 'Knowledge Read', value: 'knowledge:read' },
|
||||
{ label: 'Knowledge Write', value: 'knowledge:write' },
|
||||
{ label: 'Agent Read', value: 'agent:read' },
|
||||
{ label: 'Agent Write', value: 'agent:write' },
|
||||
]
|
||||
|
||||
export default function ApiKeys() {
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [newToken, setNewToken] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['api-keys', page, pageSize],
|
||||
queryFn: ({ signal }) => apiKeyService.list({ page, page_size: pageSize }, signal),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (values: { name: string; expires_days?: number; permissions: string[] }) =>
|
||||
apiKeyService.create(values),
|
||||
onSuccess: (result: TokenInfo) => {
|
||||
message.success('API 密钥创建成功')
|
||||
if (result.token) {
|
||||
setNewToken(result.token)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||
form.resetFields()
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||
})
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (id: string) => apiKeyService.revoke(id),
|
||||
onSuccess: () => {
|
||||
message.success('密钥已吊销')
|
||||
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '吊销失败'),
|
||||
})
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields()
|
||||
createMutation.mutate(values)
|
||||
}
|
||||
|
||||
const columns: ProColumns<TokenInfo>[] = [
|
||||
{ title: '名称', dataIndex: 'name', width: 180 },
|
||||
{
|
||||
title: '前缀',
|
||||
dataIndex: 'token_prefix',
|
||||
width: 120,
|
||||
render: (val: string) => <Text code>{val}...</Text>,
|
||||
},
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
width: 240,
|
||||
render: (perms: string[]) =>
|
||||
perms?.map((p) => <Tag key={p}>{p}</Tag>) || '-',
|
||||
},
|
||||
{
|
||||
title: '最后使用',
|
||||
dataIndex: 'last_used_at',
|
||||
width: 180,
|
||||
render: (val: string) => (val ? new Date(val).toLocaleString() : <Text type="secondary">从未使用</Text>),
|
||||
},
|
||||
{
|
||||
title: '过期时间',
|
||||
dataIndex: 'expires_at',
|
||||
width: 180,
|
||||
render: (val: string) =>
|
||||
val ? new Date(val).toLocaleString() : <Text type="secondary">永不过期</Text>,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (val: string) => new Date(val).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
render: (_: unknown, record: TokenInfo) => (
|
||||
<Popconfirm
|
||||
title="确定吊销此密钥?"
|
||||
description="吊销后使用该密钥的所有请求将被拒绝"
|
||||
onConfirm={() => revokeMutation.mutate(record.id)}
|
||||
>
|
||||
<Button danger size="small">吊销</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<ProTable<TokenInfo>
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
search={false}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
创建密钥
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="创建 API 密钥"
|
||||
open={createOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => { setCreateOpen(false); setNewToken(null); form.resetFields() }}
|
||||
confirmLoading={createMutation.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
{newToken ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Paragraph type="warning">
|
||||
请立即复制密钥,关闭后将无法再次查看。
|
||||
</Paragraph>
|
||||
<Space>
|
||||
<Text code style={{ fontSize: 13 }}>{newToken}</Text>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
size="small"
|
||||
onClick={() => { navigator.clipboard.writeText(newToken); message.success('已复制') }}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
) : (
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="密钥名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="例如: 生产环境 API Key" />
|
||||
</Form.Item>
|
||||
<Form.Item name="expires_days" label="有效期 (天)">
|
||||
<InputNumber min={1} max={3650} placeholder="留空表示永不过期" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限" rules={[{ required: true, message: '请选择至少一项权限' }]}>
|
||||
<Select mode="multiple" options={PERMISSION_OPTIONS} placeholder="选择权限" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -144,7 +144,7 @@ function IndustryListPanel() {
|
||||
rowKey="id"
|
||||
search={{
|
||||
onReset: () => { setFilters({}); setPage(1) },
|
||||
onSearch: (values) => { setFilters(values); setPage(1) },
|
||||
onSubmit: (values) => { setFilters(values); setPage(1) },
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
@@ -225,7 +225,7 @@ function IndustryEditModal({ open, industryId, onClose }: {
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={updateMutation.isPending}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8"><Spin /></div>
|
||||
@@ -300,7 +300,7 @@ function IndustryCreateModal({ open, onClose }: {
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={createMutation.isPending}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -333,7 +333,7 @@ function ItemsPanel() {
|
||||
rowKey="id"
|
||||
search={{
|
||||
onReset: () => { setFilters({}); setPage(1) },
|
||||
onSearch: (values) => { setFilters(values); setPage(1) },
|
||||
onSubmit: (values) => { setFilters(values); setPage(1) },
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
|
||||
@@ -327,7 +327,7 @@ export default function ScheduledTasks() {
|
||||
onCancel={closeModal}
|
||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||
width={520}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" className="mt-4">
|
||||
<Form.Item
|
||||
|
||||
@@ -26,7 +26,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'providers', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'models', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'api-keys', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'api-keys', lazy: () => import('@/pages/ApiKeys').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'billing', lazy: () => import('@/pages/Billing').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
|
||||
|
||||
@@ -90,4 +90,9 @@ export const billingService = {
|
||||
getPaymentStatus: (id: string, signal?: AbortSignal) =>
|
||||
request.get<PaymentStatus>(`/billing/payments/${id}`, withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
/** 管理员切换用户订阅计划 (super_admin only) */
|
||||
adminSwitchPlan: (accountId: string, planId: string) =>
|
||||
request.put<{ success: boolean; subscription: Subscription }>(`/admin/accounts/${accountId}/subscription`, { plan_id: planId })
|
||||
.then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export default defineConfig({
|
||||
timeout: 600_000,
|
||||
proxyTimeout: 600_000,
|
||||
},
|
||||
'/api': {
|
||||
'/api/': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -132,13 +132,16 @@ impl SqliteStorage {
|
||||
.map_err(|e| ZclawError::StorageError(format!("Failed to create memories table: {}", e)))?;
|
||||
|
||||
// Create FTS5 virtual table for full-text search
|
||||
// Use trigram tokenizer for CJK (Chinese/Japanese/Korean) support.
|
||||
// unicode61 cannot tokenize CJK characters, causing memory search to fail.
|
||||
// trigram indexes overlapping 3-character slices, works well for all languages.
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
||||
uri,
|
||||
content,
|
||||
keywords,
|
||||
tokenize='unicode61'
|
||||
tokenize='trigram'
|
||||
)
|
||||
"#,
|
||||
)
|
||||
@@ -189,6 +192,46 @@ impl SqliteStorage {
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(format!("Failed to create metadata table: {}", e)))?;
|
||||
|
||||
// Migration: Rebuild FTS5 table if using old unicode61 tokenizer (can't handle CJK)
|
||||
// Check tokenizer by inspecting the existing FTS5 table definition
|
||||
let needs_rebuild: bool = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memories_fts' AND sql LIKE '%unicode61%'"
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.unwrap_or(0) > 0;
|
||||
|
||||
if needs_rebuild {
|
||||
tracing::info!("[SqliteStorage] Rebuilding FTS5 table: unicode61 → trigram for CJK support");
|
||||
// Drop old FTS5 table
|
||||
let _ = sqlx::query("DROP TABLE IF EXISTS memories_fts")
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
// Recreate with trigram tokenizer
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
||||
uri,
|
||||
content,
|
||||
keywords,
|
||||
tokenize='trigram'
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(format!("Failed to recreate FTS5 table: {}", e)))?;
|
||||
// Reindex all existing memories into FTS5
|
||||
let reindexed = sqlx::query(
|
||||
"INSERT INTO memories_fts (uri, content, keywords) SELECT uri, content, keywords FROM memories"
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|r| r.rows_affected())
|
||||
.unwrap_or(0);
|
||||
tracing::info!("[SqliteStorage] FTS5 rebuild complete, reindexed {} entries", reindexed);
|
||||
}
|
||||
|
||||
tracing::info!("[SqliteStorage] Database schema initialized");
|
||||
Ok(())
|
||||
}
|
||||
@@ -378,19 +421,37 @@ impl SqliteStorage {
|
||||
/// Strips these and keeps only alphanumeric + CJK tokens with length > 1,
|
||||
/// then joins them with `OR` for broad matching.
|
||||
fn sanitize_fts_query(query: &str) -> String {
|
||||
let terms: Vec<String> = query
|
||||
.to_lowercase()
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|s| !s.is_empty() && s.len() > 1)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
// trigram tokenizer requires quoted phrases for substring matching
|
||||
// and needs at least 3 characters per term to produce results.
|
||||
let lower = query.to_lowercase();
|
||||
|
||||
if terms.is_empty() {
|
||||
return String::new();
|
||||
// Check if query contains CJK characters — trigram handles them natively
|
||||
let has_cjk = lower.chars().any(|c| {
|
||||
matches!(c, '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}')
|
||||
});
|
||||
|
||||
if has_cjk {
|
||||
// For CJK, use the full query as a quoted phrase for substring matching
|
||||
// trigram will match any 3-char subsequence
|
||||
if lower.len() >= 3 {
|
||||
format!("\"{}\"", lower)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else {
|
||||
// For non-CJK, split into terms and join with OR
|
||||
let terms: Vec<String> = lower
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|s| !s.is_empty() && s.len() > 1)
|
||||
.map(|s| format!("\"{}\"", s))
|
||||
.collect();
|
||||
|
||||
if terms.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
terms.join(" OR ")
|
||||
}
|
||||
|
||||
// Join with OR so any term can match (broad recall, then rerank by similarity)
|
||||
terms.join(" OR ")
|
||||
}
|
||||
|
||||
/// Fetch memories by scope with importance-based ordering.
|
||||
|
||||
@@ -20,6 +20,7 @@ mod researcher;
|
||||
mod collector;
|
||||
mod clip;
|
||||
mod twitter;
|
||||
pub mod reminder;
|
||||
|
||||
pub use whiteboard::*;
|
||||
pub use slideshow::*;
|
||||
@@ -30,3 +31,4 @@ pub use researcher::*;
|
||||
pub use collector::*;
|
||||
pub use clip::*;
|
||||
pub use twitter::*;
|
||||
pub use reminder::*;
|
||||
|
||||
77
crates/zclaw-hands/src/hands/reminder.rs
Normal file
77
crates/zclaw-hands/src/hands/reminder.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
//! Reminder Hand - Internal hand for scheduled reminders
|
||||
//!
|
||||
//! This is a system hand (id `_reminder`) used by the schedule interception
|
||||
//! layer in `agent_chat_stream`. When the NlScheduleParser detects a schedule
|
||||
//! intent in chat, it creates a trigger targeting this hand. The SchedulerService
|
||||
//! fires the trigger at the scheduled time.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
|
||||
|
||||
/// Internal reminder hand for scheduled tasks
|
||||
pub struct ReminderHand {
|
||||
config: HandConfig,
|
||||
}
|
||||
|
||||
impl ReminderHand {
|
||||
/// Create a new reminder hand
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: HandConfig {
|
||||
id: "_reminder".to_string(),
|
||||
name: "定时提醒".to_string(),
|
||||
description: "Internal hand for scheduled reminders".to_string(),
|
||||
needs_approval: false,
|
||||
dependencies: vec![],
|
||||
input_schema: None,
|
||||
tags: vec!["system".to_string()],
|
||||
enabled: true,
|
||||
max_concurrent: 0,
|
||||
timeout_secs: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hand for ReminderHand {
|
||||
fn config(&self) -> &HandConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
|
||||
let task_desc = input
|
||||
.get("task_description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("定时提醒");
|
||||
|
||||
let cron = input
|
||||
.get("cron")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let fired_at = input
|
||||
.get("fired_at")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown time");
|
||||
|
||||
tracing::info!(
|
||||
"[ReminderHand] Fired at {} — task: {}, cron: {}",
|
||||
fired_at, task_desc, cron
|
||||
);
|
||||
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"task": task_desc,
|
||||
"cron": cron,
|
||||
"fired_at": fired_at,
|
||||
"status": "reminded",
|
||||
})))
|
||||
}
|
||||
|
||||
fn status(&self) -> HandStatus {
|
||||
HandStatus::Idle
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ use crate::config::KernelConfig;
|
||||
use zclaw_memory::MemoryStore;
|
||||
use zclaw_runtime::{LlmDriver, ToolRegistry, tool::SkillExecutor};
|
||||
use zclaw_skills::SkillRegistry;
|
||||
use zclaw_hands::{HandRegistry, hands::{BrowserHand, SlideshowHand, SpeechHand, QuizHand, WhiteboardHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, quiz::LlmQuizGenerator}};
|
||||
use zclaw_hands::{HandRegistry, hands::{BrowserHand, SlideshowHand, SpeechHand, QuizHand, WhiteboardHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, ReminderHand, quiz::LlmQuizGenerator}};
|
||||
|
||||
pub use adapters::KernelSkillExecutor;
|
||||
pub use messaging::ChatModeConfig;
|
||||
@@ -101,6 +101,7 @@ impl Kernel {
|
||||
hands.register(Arc::new(CollectorHand::new())).await;
|
||||
hands.register(Arc::new(ClipHand::new())).await;
|
||||
hands.register(Arc::new(TwitterHand::new())).await;
|
||||
hands.register(Arc::new(ReminderHand::new())).await;
|
||||
|
||||
// Create skill executor
|
||||
let skill_executor = Arc::new(KernelSkillExecutor::new(skills.clone(), driver.clone()));
|
||||
|
||||
@@ -77,7 +77,7 @@ impl SchedulerService {
|
||||
kernel_lock: &Arc<Mutex<Option<Kernel>>>,
|
||||
) -> Result<()> {
|
||||
// Collect due triggers under lock
|
||||
let to_execute: Vec<(String, String, String)> = {
|
||||
let to_execute: Vec<(String, String, String, String)> = {
|
||||
let kernel_guard = kernel_lock.lock().await;
|
||||
let kernel = match kernel_guard.as_ref() {
|
||||
Some(k) => k,
|
||||
@@ -103,7 +103,8 @@ impl SchedulerService {
|
||||
.filter_map(|t| {
|
||||
if let zclaw_hands::TriggerType::Schedule { ref cron } = t.config.trigger_type {
|
||||
if Self::should_fire_cron(cron, &now) {
|
||||
Some((t.config.id.clone(), t.config.hand_id.clone(), cron.clone()))
|
||||
// (trigger_id, hand_id, cron_expr, trigger_name)
|
||||
Some((t.config.id.clone(), t.config.hand_id.clone(), cron.clone(), t.config.name.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -123,7 +124,7 @@ impl SchedulerService {
|
||||
// If parallel execution is needed, spawn each execute_hand in a separate task
|
||||
// and collect results via JoinSet.
|
||||
let now = chrono::Utc::now();
|
||||
for (trigger_id, hand_id, cron_expr) in to_execute {
|
||||
for (trigger_id, hand_id, cron_expr, trigger_name) in to_execute {
|
||||
tracing::info!(
|
||||
"[Scheduler] Firing scheduled trigger '{}' → hand '{}' (cron: {})",
|
||||
trigger_id, hand_id, cron_expr
|
||||
@@ -138,6 +139,7 @@ impl SchedulerService {
|
||||
let input = serde_json::json!({
|
||||
"trigger_id": trigger_id,
|
||||
"trigger_type": "schedule",
|
||||
"task_description": trigger_name,
|
||||
"cron": cron_expr,
|
||||
"fired_at": now.to_rfc3339(),
|
||||
});
|
||||
|
||||
@@ -134,7 +134,9 @@ impl TriggerManager {
|
||||
/// Create a new trigger
|
||||
pub async fn create_trigger(&self, config: TriggerConfig) -> Result<TriggerEntry> {
|
||||
// Validate hand exists (outside of our lock to avoid holding two locks)
|
||||
if self.hand_registry.get(&config.hand_id).await.is_none() {
|
||||
// System hands (prefixed with '_') are exempt from validation — they are
|
||||
// registered at boot but may not appear in the hand registry scan path.
|
||||
if !config.hand_id.starts_with('_') && self.hand_registry.get(&config.hand_id).await.is_none() {
|
||||
return Err(zclaw_types::ZclawError::InvalidInput(
|
||||
format!("Hand '{}' not found", config.hand_id)
|
||||
));
|
||||
@@ -170,7 +172,7 @@ impl TriggerManager {
|
||||
) -> Result<TriggerEntry> {
|
||||
// Validate hand exists if being updated (outside of our lock)
|
||||
if let Some(hand_id) = &updates.hand_id {
|
||||
if self.hand_registry.get(hand_id).await.is_none() {
|
||||
if !hand_id.starts_with('_') && self.hand_registry.get(hand_id).await.is_none() {
|
||||
return Err(zclaw_types::ZclawError::InvalidInput(
|
||||
format!("Hand '{}' not found", hand_id)
|
||||
));
|
||||
@@ -303,9 +305,10 @@ impl TriggerManager {
|
||||
};
|
||||
|
||||
// Get hand (outside of our lock to avoid potential deadlock with hand_registry)
|
||||
// System hands (prefixed with '_') must be registered at boot — same rule as create_trigger.
|
||||
let hand = self.hand_registry.get(&hand_id).await
|
||||
.ok_or_else(|| zclaw_types::ZclawError::InvalidInput(
|
||||
format!("Hand '{}' not found", hand_id)
|
||||
format!("Hand '{}' not found (system hands must be registered at boot)", hand_id)
|
||||
))?;
|
||||
|
||||
// Update state before execution
|
||||
|
||||
@@ -130,7 +130,7 @@ impl DataMasker {
|
||||
fn recover_read<T>(lock: &RwLock<T>) -> std::sync::LockResult<std::sync::RwLockReadGuard<'_, T>> {
|
||||
match lock.read() {
|
||||
Ok(guard) => Ok(guard),
|
||||
Err(e) => {
|
||||
Err(_e) => {
|
||||
tracing::warn!("[DataMasker] RwLock poisoned during read, recovering");
|
||||
// Poison error still gives us access to the inner guard
|
||||
lock.read()
|
||||
@@ -141,7 +141,7 @@ impl DataMasker {
|
||||
fn recover_write<T>(lock: &RwLock<T>) -> std::sync::LockResult<std::sync::RwLockWriteGuard<'_, T>> {
|
||||
match lock.write() {
|
||||
Ok(guard) => Ok(guard),
|
||||
Err(e) => {
|
||||
Err(_e) => {
|
||||
tracing::warn!("[DataMasker] RwLock poisoned during write, recovering");
|
||||
lock.write()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use tokio::sync::RwLock;
|
||||
use zclaw_memory::trajectory_store::{
|
||||
TrajectoryEvent, TrajectoryStepType, TrajectoryStore,
|
||||
};
|
||||
use zclaw_types::{Result, SessionId};
|
||||
use zclaw_types::Result;
|
||||
use crate::driver::ContentBlock;
|
||||
use crate::middleware::{AgentMiddleware, MiddlewareContext, MiddlewareDecision};
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
//!
|
||||
//! Lives in `zclaw-runtime` because it's a pure text→cron utility with no kernel dependency.
|
||||
|
||||
use chrono::{Datelike, Timelike};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use chrono::Timelike;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zclaw_types::AgentId;
|
||||
|
||||
@@ -56,20 +59,79 @@ pub enum ScheduleParseResult {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regex pattern library
|
||||
// Pre-compiled regex patterns (LazyLock — compiled once, reused forever)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single pattern for matching Chinese time expressions.
|
||||
struct SchedulePattern {
|
||||
/// Regex pattern string
|
||||
regex: &'static str,
|
||||
/// Cron template — use {h} for hour, {m} for minute, {dow} for day-of-week, {dom} for day-of-month
|
||||
cron_template: &'static str,
|
||||
/// Human description template
|
||||
description: &'static str,
|
||||
/// Base confidence for this pattern
|
||||
confidence: f32,
|
||||
}
|
||||
/// Time-of-day period fragment used across multiple patterns.
|
||||
const PERIOD: &str = "(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)?";
|
||||
|
||||
// extract_task_description
|
||||
static RE_TIME_STRIP: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"^(?:凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)?\d{1,2}[点时::]\d{0,2}分?"
|
||||
).unwrap()
|
||||
});
|
||||
|
||||
// try_every_day
|
||||
static RE_EVERY_DAY_EXACT: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?:每天|每日)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||
PERIOD
|
||||
)).unwrap()
|
||||
});
|
||||
|
||||
static RE_EVERY_DAY_PERIOD: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"(?:每天|每日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)"
|
||||
).unwrap()
|
||||
});
|
||||
|
||||
// try_every_week
|
||||
static RE_EVERY_WEEK: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?:每周|每个?星期|每个?礼拜)(一|二|三|四|五|六|日|天|周一|周二|周三|周四|周五|周六|周日|周天|星期一|星期二|星期三|星期四|星期五|星期六|星期日|星期天|礼拜一|礼拜二|礼拜三|礼拜四|礼拜五|礼拜六|礼拜日|礼拜天)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||
PERIOD
|
||||
)).unwrap()
|
||||
});
|
||||
|
||||
// try_workday
|
||||
static RE_WORKDAY_EXACT: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?:工作日|每个?工作日|工作日(?:的)?){}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||
PERIOD
|
||||
)).unwrap()
|
||||
});
|
||||
|
||||
static RE_WORKDAY_PERIOD: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"(?:工作日|每个?工作日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)"
|
||||
).unwrap()
|
||||
});
|
||||
|
||||
// try_interval
|
||||
static RE_INTERVAL: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"每(\d{1,2})(小时|分钟|分|钟|个小时)").unwrap()
|
||||
});
|
||||
|
||||
// try_monthly
|
||||
static RE_MONTHLY: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?:每月|每个月)(?:的)?(\d{{1,2}})[号日](?:的)?{}(\d{{1,2}})?[点时::]?(\d{{1,2}})?",
|
||||
PERIOD
|
||||
)).unwrap()
|
||||
});
|
||||
|
||||
// try_one_shot
|
||||
static RE_ONE_SHOT: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(明天|后天|大后天)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||
PERIOD
|
||||
)).unwrap()
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper lookups (pure functions, no allocation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Chinese time period keywords → hour mapping
|
||||
fn period_to_hour(period: &str) -> Option<u32> {
|
||||
@@ -99,6 +161,23 @@ fn weekday_to_cron(day: &str) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust hour based on time-of-day period. Chinese 12-hour convention:
|
||||
/// 下午3点 = 15, 晚上8点 = 20, etc. Morning hours stay as-is.
|
||||
fn adjust_hour_for_period(hour: u32, period: Option<&str>) -> u32 {
|
||||
if let Some(p) = period {
|
||||
match p {
|
||||
"下午" | "午后" => { if hour < 12 { hour + 12 } else { hour } }
|
||||
"晚上" | "晚间" | "夜里" | "夜晚" => { if hour < 12 { hour + 12 } else { hour } }
|
||||
"傍晚" | "黄昏" => { if hour < 12 { hour + 12 } else { hour } }
|
||||
"中午" => { if hour == 12 { 12 } else if hour < 12 { hour + 12 } else { hour } }
|
||||
"半夜" | "午夜" => { if hour == 12 { 0 } else { hour } }
|
||||
_ => hour,
|
||||
}
|
||||
} else {
|
||||
hour
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -113,35 +192,23 @@ pub fn parse_nl_schedule(input: &str, default_agent_id: &AgentId) -> SchedulePar
|
||||
return ScheduleParseResult::Unclear;
|
||||
}
|
||||
|
||||
// Extract task description (everything after keywords like "提醒我", "帮我")
|
||||
let task_description = extract_task_description(input);
|
||||
|
||||
// --- Pattern 1: 每天 + 时间 ---
|
||||
if let Some(result) = try_every_day(input, &task_description, default_agent_id) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Pattern 2: 每周N + 时间 ---
|
||||
if let Some(result) = try_every_week(input, &task_description, default_agent_id) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Pattern 3: 工作日 + 时间 ---
|
||||
if let Some(result) = try_workday(input, &task_description, default_agent_id) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Pattern 4: 每N小时/分钟 ---
|
||||
if let Some(result) = try_interval(input, &task_description, default_agent_id) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Pattern 5: 每月N号 ---
|
||||
if let Some(result) = try_monthly(input, &task_description, default_agent_id) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Pattern 6: 明天/后天 + 时间 (one-shot) ---
|
||||
if let Some(result) = try_one_shot(input, &task_description, default_agent_id) {
|
||||
return result;
|
||||
}
|
||||
@@ -160,13 +227,7 @@ fn extract_task_description(input: &str) -> String {
|
||||
|
||||
let mut desc = input.to_string();
|
||||
|
||||
// Strip prefixes + time expressions in alternating passes until stable
|
||||
let time_re = regex::Regex::new(
|
||||
r"^(?:凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)?\d{1,2}[点时::]\d{0,2}分?"
|
||||
).unwrap_or_else(|_| regex::Regex::new("").unwrap());
|
||||
|
||||
for _ in 0..3 {
|
||||
// Pass 1: strip prefixes
|
||||
loop {
|
||||
let mut stripped = false;
|
||||
for prefix in &strip_prefixes {
|
||||
@@ -177,8 +238,7 @@ fn extract_task_description(input: &str) -> String {
|
||||
}
|
||||
if !stripped { break; }
|
||||
}
|
||||
// Pass 2: strip time expressions
|
||||
let new_desc = time_re.replace(&desc, "").to_string();
|
||||
let new_desc = RE_TIME_STRIP.replace(&desc, "").to_string();
|
||||
if new_desc == desc { break; }
|
||||
desc = new_desc;
|
||||
}
|
||||
@@ -186,32 +246,10 @@ fn extract_task_description(input: &str) -> String {
|
||||
desc.trim().to_string()
|
||||
}
|
||||
|
||||
// -- Pattern matchers --
|
||||
|
||||
/// Adjust hour based on time-of-day period. Chinese 12-hour convention:
|
||||
/// 下午3点 = 15, 晚上8点 = 20, etc. Morning hours stay as-is.
|
||||
fn adjust_hour_for_period(hour: u32, period: Option<&str>) -> u32 {
|
||||
if let Some(p) = period {
|
||||
match p {
|
||||
"下午" | "午后" => { if hour < 12 { hour + 12 } else { hour } }
|
||||
"晚上" | "晚间" | "夜里" | "夜晚" => { if hour < 12 { hour + 12 } else { hour } }
|
||||
"傍晚" | "黄昏" => { if hour < 12 { hour + 12 } else { hour } }
|
||||
"中午" => { if hour == 12 { 12 } else if hour < 12 { hour + 12 } else { hour } }
|
||||
"半夜" | "午夜" => { if hour == 12 { 0 } else { hour } }
|
||||
_ => hour,
|
||||
}
|
||||
} else {
|
||||
hour
|
||||
}
|
||||
}
|
||||
|
||||
const PERIOD_PATTERN: &str = "(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)?";
|
||||
// -- Pattern matchers (all use pre-compiled statics) --
|
||||
|
||||
fn try_every_day(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> {
|
||||
let re = regex::Regex::new(
|
||||
&format!(r"(?:每天|每日)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?", PERIOD_PATTERN)
|
||||
).ok()?;
|
||||
if let Some(caps) = re.captures(input) {
|
||||
if let Some(caps) = RE_EVERY_DAY_EXACT.captures(input) {
|
||||
let period = caps.get(1).map(|m| m.as_str());
|
||||
let raw_hour: u32 = caps.get(2)?.as_str().parse().ok()?;
|
||||
let minute: u32 = caps.get(3).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0);
|
||||
@@ -228,9 +266,7 @@ fn try_every_day(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sch
|
||||
}));
|
||||
}
|
||||
|
||||
// "每天早上/下午..." without explicit hour
|
||||
let re2 = regex::Regex::new(r"(?:每天|每日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)").ok()?;
|
||||
if let Some(caps) = re2.captures(input) {
|
||||
if let Some(caps) = RE_EVERY_DAY_PERIOD.captures(input) {
|
||||
let period = caps.get(1)?.as_str();
|
||||
if let Some(hour) = period_to_hour(period) {
|
||||
return Some(ScheduleParseResult::Exact(ParsedSchedule {
|
||||
@@ -247,11 +283,7 @@ fn try_every_day(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sch
|
||||
}
|
||||
|
||||
fn try_every_week(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> {
|
||||
let re = regex::Regex::new(
|
||||
&format!(r"(?:每周|每个?星期|每个?礼拜)(一|二|三|四|五|六|日|天|周一|周二|周三|周四|周五|周六|周日|周天|星期一|星期二|星期三|星期四|星期五|星期六|星期日|星期天|礼拜一|礼拜二|礼拜三|礼拜四|礼拜五|礼拜六|礼拜日|礼拜天)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?", PERIOD_PATTERN)
|
||||
).ok()?;
|
||||
|
||||
let caps = re.captures(input)?;
|
||||
let caps = RE_EVERY_WEEK.captures(input)?;
|
||||
let day_str = caps.get(1)?.as_str();
|
||||
let dow = weekday_to_cron(day_str)?;
|
||||
let period = caps.get(2).map(|m| m.as_str());
|
||||
@@ -272,11 +304,7 @@ fn try_every_week(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sc
|
||||
}
|
||||
|
||||
fn try_workday(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> {
|
||||
let re = regex::Regex::new(
|
||||
&format!(r"(?:工作日|每个?工作日|工作日(?:的)?){}(\d{{1,2}})[点时::](\d{{1,2}})?", PERIOD_PATTERN)
|
||||
).ok()?;
|
||||
|
||||
if let Some(caps) = re.captures(input) {
|
||||
if let Some(caps) = RE_WORKDAY_EXACT.captures(input) {
|
||||
let period = caps.get(1).map(|m| m.as_str());
|
||||
let raw_hour: u32 = caps.get(2)?.as_str().parse().ok()?;
|
||||
let minute: u32 = caps.get(3).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0);
|
||||
@@ -293,11 +321,7 @@ fn try_workday(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sched
|
||||
}));
|
||||
}
|
||||
|
||||
// "工作日下午3点" style
|
||||
let re2 = regex::Regex::new(
|
||||
r"(?:工作日|每个?工作日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)"
|
||||
).ok()?;
|
||||
if let Some(caps) = re2.captures(input) {
|
||||
if let Some(caps) = RE_WORKDAY_PERIOD.captures(input) {
|
||||
let period = caps.get(1)?.as_str();
|
||||
if let Some(hour) = period_to_hour(period) {
|
||||
return Some(ScheduleParseResult::Exact(ParsedSchedule {
|
||||
@@ -314,9 +338,7 @@ fn try_workday(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sched
|
||||
}
|
||||
|
||||
fn try_interval(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> {
|
||||
// "每2小时", "每30分钟", "每N小时/分钟"
|
||||
let re = regex::Regex::new(r"每(\d{1,2})(小时|分钟|分|钟|个小时)").ok()?;
|
||||
if let Some(caps) = re.captures(input) {
|
||||
if let Some(caps) = RE_INTERVAL.captures(input) {
|
||||
let n: u32 = caps.get(1)?.as_str().parse().ok()?;
|
||||
if n == 0 {
|
||||
return None;
|
||||
@@ -340,11 +362,7 @@ fn try_interval(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sche
|
||||
}
|
||||
|
||||
fn try_monthly(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> {
|
||||
let re = regex::Regex::new(
|
||||
&format!(r"(?:每月|每个月)(?:的)?(\d{{1,2}})[号日](?:的)?{}(\d{{1,2}})?[点时::]?(\d{{1,2}})?", PERIOD_PATTERN)
|
||||
).ok()?;
|
||||
|
||||
if let Some(caps) = re.captures(input) {
|
||||
if let Some(caps) = RE_MONTHLY.captures(input) {
|
||||
let day: u32 = caps.get(1)?.as_str().parse().ok()?;
|
||||
let period = caps.get(2).map(|m| m.as_str());
|
||||
let raw_hour: u32 = caps.get(3).map(|m| m.as_str().parse().unwrap_or(9)).unwrap_or(9);
|
||||
@@ -366,11 +384,7 @@ fn try_monthly(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sched
|
||||
}
|
||||
|
||||
fn try_one_shot(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> {
|
||||
let re = regex::Regex::new(
|
||||
&format!(r"(明天|后天|大后天)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?", PERIOD_PATTERN)
|
||||
).ok()?;
|
||||
|
||||
let caps = re.captures(input)?;
|
||||
let caps = RE_ONE_SHOT.captures(input)?;
|
||||
let day_offset = match caps.get(1)?.as_str() {
|
||||
"明天" => 1,
|
||||
"后天" => 2,
|
||||
|
||||
@@ -7,6 +7,7 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::types::AuthContext;
|
||||
use crate::auth::handlers::{log_operation, check_permission};
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use crate::state::AppState;
|
||||
use super::service;
|
||||
@@ -39,9 +40,23 @@ pub async fn get_subscription(
|
||||
let sub = service::get_active_subscription(&state.db, &ctx.account_id).await?;
|
||||
let usage = service::get_or_create_usage(&state.db, &ctx.account_id).await?;
|
||||
|
||||
// P2-14 修复: super_admin 无订阅时合成一个 "active" subscription
|
||||
let sub_value = if sub.is_none() && ctx.role == "super_admin" {
|
||||
Some(serde_json::json!({
|
||||
"id": format!("sub-admin-{}", &ctx.account_id.chars().take(8).collect::<String>()),
|
||||
"account_id": ctx.account_id,
|
||||
"plan_id": plan.id,
|
||||
"status": "active",
|
||||
"current_period_start": usage.period_start,
|
||||
"current_period_end": usage.period_end,
|
||||
}))
|
||||
} else {
|
||||
sub.map(|s| serde_json::to_value(s).unwrap_or_default())
|
||||
};
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"plan": plan,
|
||||
"subscription": sub,
|
||||
"subscription": sub_value,
|
||||
"usage": usage,
|
||||
})))
|
||||
}
|
||||
@@ -101,6 +116,41 @@ pub async fn increment_usage_dimension(
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/v1/billing/payments — 创建支付订单
|
||||
|
||||
/// PUT /api/v1/admin/accounts/:id/subscription — 管理员切换用户订阅计划(仅 super_admin)
|
||||
pub async fn admin_switch_subscription(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path(account_id): Path<String>,
|
||||
Json(req): Json<AdminSwitchPlanRequest>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
// 仅 super_admin 可操作
|
||||
check_permission(&ctx, "admin:full")?;
|
||||
|
||||
// 验证 plan_id 非空
|
||||
if req.plan_id.trim().is_empty() {
|
||||
return Err(SaasError::InvalidInput("plan_id 不能为空".into()));
|
||||
}
|
||||
|
||||
let sub = service::admin_switch_plan(&state.db, &account_id, &req.plan_id).await?;
|
||||
|
||||
log_operation(
|
||||
&state.db,
|
||||
&ctx.account_id,
|
||||
"billing.admin_switch_plan",
|
||||
"account",
|
||||
&account_id,
|
||||
Some(serde_json::json!({ "plan_id": req.plan_id })),
|
||||
None,
|
||||
).await.ok(); // 日志失败不影响主流程
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"success": true,
|
||||
"subscription": sub,
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/v1/billing/payments — 创建支付订单
|
||||
pub async fn create_payment(
|
||||
State(state): State<AppState>,
|
||||
|
||||
@@ -6,7 +6,7 @@ pub mod handlers;
|
||||
pub mod payment;
|
||||
pub mod invoice_pdf;
|
||||
|
||||
use axum::routing::{get, post};
|
||||
use axum::routing::{get, post, put};
|
||||
|
||||
/// 全部计费路由(用于 main.rs 一次性挂载)
|
||||
pub fn routes() -> axum::Router<crate::state::AppState> {
|
||||
@@ -51,3 +51,9 @@ pub fn mock_routes() -> axum::Router<crate::state::AppState> {
|
||||
.route("/api/v1/billing/mock-pay", get(handlers::mock_pay_page))
|
||||
.route("/api/v1/billing/mock-pay/confirm", post(handlers::mock_pay_confirm))
|
||||
}
|
||||
|
||||
/// 管理员计费路由(需 super_admin 权限)
|
||||
pub fn admin_routes() -> axum::Router<crate::state::AppState> {
|
||||
axum::Router::new()
|
||||
.route("/api/v1/admin/accounts/:id/subscription", put(handlers::admin_switch_subscription))
|
||||
}
|
||||
|
||||
@@ -114,7 +114,26 @@ pub async fn get_or_create_usage(pool: &PgPool, account_id: &str) -> SaasResult<
|
||||
.await?;
|
||||
|
||||
if let Some(usage) = existing {
|
||||
return Ok(usage);
|
||||
// P1-07 修复: 同步当前计划限额到 max_* 列(防止计划变更后数据不一致)
|
||||
let plan = get_account_plan(pool, account_id).await?;
|
||||
let limits: PlanLimits = serde_json::from_value(plan.limits.clone())
|
||||
.unwrap_or_else(|_| PlanLimits::free());
|
||||
sqlx::query(
|
||||
"UPDATE billing_usage_quotas SET max_input_tokens=$2, max_output_tokens=$3, \
|
||||
max_relay_requests=$4, max_hand_executions=$5, max_pipeline_runs=$6, updated_at=NOW() \
|
||||
WHERE id=$1"
|
||||
)
|
||||
.bind(&usage.id)
|
||||
.bind(limits.max_input_tokens_monthly)
|
||||
.bind(limits.max_output_tokens_monthly)
|
||||
.bind(limits.max_relay_requests_monthly)
|
||||
.bind(limits.max_hand_executions_monthly)
|
||||
.bind(limits.max_pipeline_runs_monthly)
|
||||
.execute(pool).await?;
|
||||
let updated = sqlx::query_as::<_, UsageQuota>(
|
||||
"SELECT * FROM billing_usage_quotas WHERE id = $1"
|
||||
).bind(&usage.id).fetch_one(pool).await?;
|
||||
return Ok(updated);
|
||||
}
|
||||
|
||||
// 获取当前计划限额
|
||||
@@ -281,6 +300,93 @@ pub async fn increment_dimension_by(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 管理员切换用户订阅计划(仅 super_admin 调用)
|
||||
///
|
||||
/// 1. 验证目标 plan_id 存在且 active
|
||||
/// 2. 取消用户当前 active 订阅
|
||||
/// 3. 创建新订阅(status=active, 30 天周期)
|
||||
/// 4. 更新当月 usage quota 的 max_* 列
|
||||
pub async fn admin_switch_plan(
|
||||
pool: &PgPool,
|
||||
account_id: &str,
|
||||
target_plan_id: &str,
|
||||
) -> SaasResult<Subscription> {
|
||||
// 1. 验证目标计划存在且 active
|
||||
let plan = get_plan(pool, target_plan_id).await?
|
||||
.ok_or_else(|| crate::error::SaasError::NotFound("目标计划不存在或已下架".into()))?;
|
||||
|
||||
// 2. 检查是否已订阅该计划
|
||||
if let Some(current_sub) = get_active_subscription(pool, account_id).await? {
|
||||
if current_sub.plan_id == target_plan_id {
|
||||
return Err(crate::error::SaasError::InvalidInput("用户已订阅该计划".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await
|
||||
.map_err(|e| crate::error::SaasError::Internal(format!("开启事务失败: {}", e)))?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
// 3. 取消当前活跃订阅
|
||||
sqlx::query(
|
||||
"UPDATE billing_subscriptions SET status = 'canceled', canceled_at = $1, updated_at = $1 \
|
||||
WHERE account_id = $2 AND status IN ('trial', 'active', 'past_due')"
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(account_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// 4. 创建新订阅
|
||||
let sub_id = uuid::Uuid::new_v4().to_string();
|
||||
let period_start = now;
|
||||
let period_end = now + chrono::Duration::days(30);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO billing_subscriptions \
|
||||
(id, account_id, plan_id, status, current_period_start, current_period_end, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'active', $4, $5, $6, $6)"
|
||||
)
|
||||
.bind(&sub_id)
|
||||
.bind(account_id)
|
||||
.bind(&target_plan_id)
|
||||
.bind(&period_start)
|
||||
.bind(&period_end)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// 5. 同步当月 usage quota 的 max_* 列
|
||||
let limits: PlanLimits = serde_json::from_value(plan.limits.clone())
|
||||
.unwrap_or_else(|_| PlanLimits::free());
|
||||
sqlx::query(
|
||||
"UPDATE billing_usage_quotas SET max_input_tokens=$1, max_output_tokens=$2, \
|
||||
max_relay_requests=$3, max_hand_executions=$4, max_pipeline_runs=$5, updated_at=NOW() \
|
||||
WHERE account_id=$6 AND period_start = DATE_TRUNC('month', NOW())"
|
||||
)
|
||||
.bind(limits.max_input_tokens_monthly)
|
||||
.bind(limits.max_output_tokens_monthly)
|
||||
.bind(limits.max_relay_requests_monthly)
|
||||
.bind(limits.max_hand_executions_monthly)
|
||||
.bind(limits.max_pipeline_runs_monthly)
|
||||
.bind(account_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await
|
||||
.map_err(|e| crate::error::SaasError::Internal(format!("事务提交失败: {}", e)))?;
|
||||
|
||||
// 查询返回新订阅
|
||||
let sub = sqlx::query_as::<_, Subscription>(
|
||||
"SELECT * FROM billing_subscriptions WHERE id = $1"
|
||||
)
|
||||
.bind(&sub_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(sub)
|
||||
}
|
||||
|
||||
/// 检查用量配额
|
||||
///
|
||||
/// P1-7 修复: 从当前 Plan 读取限额(而非 stale 的 usage 表冗余列)
|
||||
@@ -288,8 +394,13 @@ pub async fn increment_dimension_by(
|
||||
pub async fn check_quota(
|
||||
pool: &PgPool,
|
||||
account_id: &str,
|
||||
role: &str,
|
||||
quota_type: &str,
|
||||
) -> SaasResult<QuotaCheck> {
|
||||
// P2-14 修复: super_admin 不受配额限制
|
||||
if role == "super_admin" {
|
||||
return Ok(QuotaCheck { allowed: true, reason: None, current: 0, limit: None, remaining: None });
|
||||
}
|
||||
let usage = get_or_create_usage(pool, account_id).await?;
|
||||
// 从当前 Plan 读取真实限额,而非 usage 表的 stale 冗余列
|
||||
let plan = get_account_plan(pool, account_id).await?;
|
||||
|
||||
@@ -159,3 +159,9 @@ pub struct PaymentResult {
|
||||
pub pay_url: String,
|
||||
pub amount_cents: i32,
|
||||
}
|
||||
|
||||
/// 管理员切换计划请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AdminSwitchPlanRequest {
|
||||
pub plan_id: String,
|
||||
}
|
||||
|
||||
@@ -742,7 +742,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
|
||||
let id = format!("cfg-{}-{}", cat, key);
|
||||
sqlx::query(
|
||||
"INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8, $8) ON CONFLICT (id) DO NOTHING"
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8, $8) ON CONFLICT (category, key_path) DO NOTHING"
|
||||
).bind(&id).bind(cat).bind(key).bind(vtype).bind(current).bind(default).bind(desc).bind(&ts)
|
||||
.execute(pool).await?;
|
||||
}
|
||||
@@ -854,6 +854,7 @@ async fn fix_seed_data(pool: &PgPool) -> SaasResult<()> {
|
||||
let admin_ids: Vec<String> = admins.into_iter().map(|(id,)| id).collect();
|
||||
|
||||
// 2. 更新 config_items 分类名(旧 → 新)
|
||||
// 先删除目标 (category, key_path) 已存在的旧 category 行,避免唯一约束冲突
|
||||
let category_mappings = [
|
||||
("server", "general"),
|
||||
("llm", "model"),
|
||||
@@ -862,6 +863,13 @@ async fn fix_seed_data(pool: &PgPool) -> SaasResult<()> {
|
||||
("security", "rate_limit"),
|
||||
];
|
||||
for (old_cat, new_cat) in &category_mappings {
|
||||
// 删除旧 category 中与目标 category key_path 冲突的行
|
||||
sqlx::query(
|
||||
"DELETE FROM config_items WHERE category = $1 AND key_path IN \
|
||||
(SELECT key_path FROM config_items WHERE category = $2)"
|
||||
).bind(old_cat).bind(new_cat)
|
||||
.execute(pool).await?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE config_items SET category = $1, updated_at = $2 WHERE category = $3"
|
||||
).bind(new_cat).bind(&now).bind(old_cat)
|
||||
@@ -889,7 +897,7 @@ async fn fix_seed_data(pool: &PgPool) -> SaasResult<()> {
|
||||
let id = format!("cfg-{}-{}", cat, key);
|
||||
sqlx::query(
|
||||
"INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8, $8) ON CONFLICT (id) DO NOTHING"
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8, $8) ON CONFLICT (category, key_path) DO NOTHING"
|
||||
).bind(&id).bind(cat).bind(key).bind(vtype).bind(current).bind(default).bind(desc).bind(&now)
|
||||
.execute(pool).await?;
|
||||
}
|
||||
|
||||
@@ -15,24 +15,48 @@ pub async fn list_industries(
|
||||
) -> SaasResult<PaginatedResponse<IndustryListItem>> {
|
||||
let (page, page_size, offset) = normalize_pagination(query.page, query.page_size);
|
||||
|
||||
// 动态构建参数化查询 — 所有用户输入通过 $N 绑定
|
||||
let mut where_parts: Vec<String> = vec!["1=1".to_string()];
|
||||
let mut param_idx = 3; // $1=LIMIT, $2=OFFSET, $3+=filters
|
||||
let status_param: Option<String> = query.status.clone();
|
||||
let source_param: Option<String> = query.source.clone();
|
||||
|
||||
// 构建 WHERE 条件 — 每个查询独立的参数编号
|
||||
let mut where_parts: Vec<String> = vec!["1=1".to_string()];
|
||||
|
||||
// count 查询:参数从 $1 开始
|
||||
let mut count_params: Vec<String> = Vec::new();
|
||||
let mut count_idx = 1;
|
||||
if status_param.is_some() {
|
||||
where_parts.push(format!("status = ${}", param_idx));
|
||||
param_idx += 1;
|
||||
count_params.push(format!("status = ${}", count_idx));
|
||||
count_idx += 1;
|
||||
}
|
||||
if source_param.is_some() {
|
||||
where_parts.push(format!("source = ${}", param_idx));
|
||||
param_idx += 1;
|
||||
count_params.push(format!("source = ${}", count_idx));
|
||||
count_idx += 1;
|
||||
}
|
||||
let where_sql = where_parts.join(" AND ");
|
||||
let count_where = if count_params.is_empty() {
|
||||
"1=1".to_string()
|
||||
} else {
|
||||
format!("1=1 AND {}", count_params.join(" AND "))
|
||||
};
|
||||
|
||||
// items 查询:$1=LIMIT, $2=OFFSET, $3+=filters
|
||||
let mut items_params: Vec<String> = Vec::new();
|
||||
let mut items_idx = 3;
|
||||
if status_param.is_some() {
|
||||
items_params.push(format!("status = ${}", items_idx));
|
||||
items_idx += 1;
|
||||
}
|
||||
if source_param.is_some() {
|
||||
items_params.push(format!("source = ${}", items_idx));
|
||||
items_idx += 1;
|
||||
}
|
||||
let items_where = if items_params.is_empty() {
|
||||
"1=1".to_string()
|
||||
} else {
|
||||
format!("1=1 AND {}", items_params.join(" AND "))
|
||||
};
|
||||
|
||||
// count 查询
|
||||
let count_sql = format!("SELECT COUNT(*) FROM industries WHERE {}", where_sql);
|
||||
let count_sql = format!("SELECT COUNT(*) FROM industries WHERE {}", count_where);
|
||||
let mut count_q = sqlx::query_scalar::<_, i64>(&count_sql);
|
||||
if let Some(ref s) = status_param { count_q = count_q.bind(s); }
|
||||
if let Some(ref s) = source_param { count_q = count_q.bind(s); }
|
||||
@@ -44,7 +68,7 @@ pub async fn list_industries(
|
||||
COALESCE(jsonb_array_length(keywords), 0) as keywords_count, \
|
||||
created_at, updated_at \
|
||||
FROM industries WHERE {} ORDER BY source, id LIMIT $1 OFFSET $2",
|
||||
where_sql
|
||||
items_where
|
||||
);
|
||||
let mut items_q = sqlx::query_as::<_, IndustryListItem>(&items_sql)
|
||||
.bind(page_size as i64)
|
||||
|
||||
@@ -29,7 +29,7 @@ pub struct IndustryListItem {
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub source: String,
|
||||
pub keywords_count: i64,
|
||||
pub keywords_count: i32,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
if let Err(e) = zclaw_saas::crypto::migrate_legacy_totp_secrets(&db, &enc_key).await {
|
||||
tracing::warn!("TOTP legacy migration check failed: {}", e);
|
||||
}
|
||||
// Self-heal: re-encrypt provider keys with current key
|
||||
zclaw_saas::relay::key_pool::heal_provider_keys(&db, &enc_key).await;
|
||||
} else {
|
||||
drop(config_for_migration);
|
||||
}
|
||||
@@ -359,6 +361,7 @@ async fn build_router(state: AppState) -> axum::Router {
|
||||
.merge(zclaw_saas::scheduled_task::routes())
|
||||
.merge(zclaw_saas::telemetry::routes())
|
||||
.merge(zclaw_saas::billing::routes())
|
||||
.merge(zclaw_saas::billing::admin_routes())
|
||||
.merge(zclaw_saas::knowledge::routes())
|
||||
.merge(zclaw_saas::industry::routes())
|
||||
.layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -119,13 +119,13 @@ pub async fn quota_check_middleware(
|
||||
}
|
||||
|
||||
// 从扩展中获取认证上下文
|
||||
let account_id = match req.extensions().get::<AuthContext>() {
|
||||
Some(ctx) => ctx.account_id.clone(),
|
||||
let (account_id, role) = match req.extensions().get::<AuthContext>() {
|
||||
Some(ctx) => (ctx.account_id.clone(), ctx.role.clone()),
|
||||
None => return next.run(req).await,
|
||||
};
|
||||
|
||||
// 检查 relay_requests 配额
|
||||
match crate::billing::service::check_quota(&state.db, &account_id, "relay_requests").await {
|
||||
match crate::billing::service::check_quota(&state.db, &account_id, &role, "relay_requests").await {
|
||||
Ok(check) if !check.allowed => {
|
||||
tracing::warn!(
|
||||
"Quota exceeded for account {}: {} ({}/{})",
|
||||
@@ -146,7 +146,7 @@ pub async fn quota_check_middleware(
|
||||
}
|
||||
|
||||
// P1-8 修复: 同时检查 input_tokens 配额
|
||||
match crate::billing::service::check_quota(&state.db, &account_id, "input_tokens").await {
|
||||
match crate::billing::service::check_quota(&state.db, &account_id, &role, "input_tokens").await {
|
||||
Ok(check) if !check.allowed => {
|
||||
tracing::warn!(
|
||||
"Token quota exceeded for account {}: {} ({}/{})",
|
||||
|
||||
@@ -258,7 +258,8 @@ pub async fn seed_default_config_items(db: &PgPool) -> SaasResult<usize> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, false, $8, $8)"
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, false, $8, $8)
|
||||
ON CONFLICT (category, key_path) DO NOTHING"
|
||||
)
|
||||
.bind(&id).bind(category).bind(key_path).bind(value_type)
|
||||
.bind(current_value).bind(default_value).bind(description).bind(&now)
|
||||
@@ -374,7 +375,8 @@ pub async fn sync_config(
|
||||
let category = parts.first().unwrap_or(&"general").to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, 'string', $4, $4, 'local', '客户端推送', false, $5, $5)"
|
||||
VALUES ($1, $2, $3, 'string', $4, $4, 'local', '客户端推送', false, $5, $5)
|
||||
ON CONFLICT (category, key_path) DO NOTHING"
|
||||
)
|
||||
.bind(&id).bind(&category).bind(key).bind(val).bind(&now)
|
||||
.execute(db).await?;
|
||||
|
||||
@@ -419,21 +419,33 @@ pub async fn revoke_account_api_key(
|
||||
pub async fn get_usage_stats(
|
||||
db: &PgPool, account_id: &str, query: &UsageQuery,
|
||||
) -> SaasResult<UsageStats> {
|
||||
// Optional date filters: pass as TEXT with explicit $N::timestamptz SQL cast.
|
||||
// This avoids the sqlx NULL-without-type-OID problem — PG's ::timestamptz
|
||||
// gives a typed NULL even when sqlx sends an untyped NULL.
|
||||
// === Totals: from billing_usage_quotas (authoritative source) ===
|
||||
// billing_usage_quotas is written to on every relay request (both JSON and SSE),
|
||||
// whereas usage_records has 0 tokens for SSE requests. Use billing as the primary source.
|
||||
let billing_row = sqlx::query(
|
||||
"SELECT COALESCE(SUM(input_tokens), 0)::bigint,
|
||||
COALESCE(SUM(output_tokens), 0)::bigint,
|
||||
COALESCE(SUM(relay_requests), 0)::bigint
|
||||
FROM billing_usage_quotas WHERE account_id = $1"
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
let total_input: i64 = billing_row.try_get(0).unwrap_or(0);
|
||||
let total_output: i64 = billing_row.try_get(1).unwrap_or(0);
|
||||
let total_requests: i64 = billing_row.try_get(2).unwrap_or(0);
|
||||
|
||||
// === Breakdowns: from usage_records (per-request detail) ===
|
||||
// Optional date filters: pass as TEXT with explicit SQL cast.
|
||||
let from_str: Option<&str> = query.from.as_deref();
|
||||
// For 'to' date-only strings, append T23:59:59 to include the entire day
|
||||
let to_str: Option<String> = query.to.as_ref().map(|s| {
|
||||
if s.len() == 10 { format!("{}T23:59:59", s) } else { s.clone() }
|
||||
});
|
||||
|
||||
// Build SQL dynamically to avoid sqlx NULL-without-type-OID problem entirely.
|
||||
// Date parameters are injected as SQL literals (validated above via chrono parse).
|
||||
// Only account_id uses parameterized binding to prevent SQL injection on user input.
|
||||
// Build SQL dynamically for usage_records breakdowns.
|
||||
// Date parameters are injected as SQL literals (validated via chrono parse).
|
||||
let mut where_parts = vec![format!("account_id = '{}'", account_id.replace('\'', "''"))];
|
||||
if let Some(f) = from_str {
|
||||
// Validate: must be parseable as a date
|
||||
let valid = chrono::NaiveDate::parse_from_str(f, "%Y-%m-%d").is_ok()
|
||||
|| chrono::NaiveDateTime::parse_from_str(f, "%Y-%m-%dT%H:%M:%S%.f").is_ok();
|
||||
if !valid {
|
||||
@@ -457,15 +469,6 @@ pub async fn get_usage_stats(
|
||||
}
|
||||
let where_clause = where_parts.join(" AND ");
|
||||
|
||||
let total_sql = format!(
|
||||
"SELECT COUNT(*)::bigint, COALESCE(SUM(input_tokens), 0)::bigint, COALESCE(SUM(output_tokens), 0)::bigint
|
||||
FROM usage_records WHERE {}", where_clause
|
||||
);
|
||||
let row = sqlx::query(&total_sql).fetch_one(db).await?;
|
||||
let total_requests: i64 = row.try_get(0).unwrap_or(0);
|
||||
let total_input: i64 = row.try_get(1).unwrap_or(0);
|
||||
let total_output: i64 = row.try_get(2).unwrap_or(0);
|
||||
|
||||
// 按模型统计
|
||||
let by_model_sql = format!(
|
||||
"SELECT provider_id, model_id, COUNT(*)::bigint AS request_count, COALESCE(SUM(input_tokens), 0)::bigint AS input_tokens, COALESCE(SUM(output_tokens), 0)::bigint AS output_tokens
|
||||
|
||||
@@ -23,6 +23,18 @@ pub async fn chat_completions(
|
||||
) -> SaasResult<Response> {
|
||||
check_permission(&ctx, "relay:use")?;
|
||||
|
||||
// P1-08 修复: 直接配额检查(不依赖中间件,防御性编程)
|
||||
for quota_type in &["relay_requests", "input_tokens", "output_tokens"] {
|
||||
let check = crate::billing::service::check_quota(
|
||||
&state.db, &ctx.account_id, &ctx.role, quota_type,
|
||||
).await?;
|
||||
if !check.allowed {
|
||||
return Err(SaasError::RateLimited(
|
||||
check.reason.unwrap_or_else(|| format!("{} 配额已用尽", quota_type))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 队列容量检查:使用内存 AtomicI64 计数器,消除 DB COUNT 查询
|
||||
let max_queue_size = {
|
||||
let config = state.config.read().await;
|
||||
@@ -321,14 +333,8 @@ pub async fn chat_completions(
|
||||
}
|
||||
}
|
||||
|
||||
// SSE: relay_requests 实时递增(tokens 由 AggregateUsageWorker 对账修正)
|
||||
if let Err(e) = crate::billing::service::increment_dimension(
|
||||
&state.db, &account_id_usage, "relay_requests",
|
||||
).await {
|
||||
tracing::warn!("Failed to increment billing relay_requests for {}: {}", account_id_usage, e);
|
||||
}
|
||||
|
||||
// SSE 流已返回,递减队列计数器(流式任务开始处理)
|
||||
// 注意: relay_requests 和 tokens 统一由 execute_relay spawned task 中的 increment_usage 递增
|
||||
state.cache.relay_dequeue(&account_id_usage);
|
||||
|
||||
let response = axum::response::Response::builder()
|
||||
@@ -372,13 +378,14 @@ pub async fn list_available_models(
|
||||
State(state): State<AppState>,
|
||||
_ctx: Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<serde_json::Value>>> {
|
||||
// 单次 JOIN 查询替代 2 次全量加载
|
||||
// 单次 JOIN 查询 + provider_keys 过滤:仅返回有活跃 API Key 的 provider 下的模型
|
||||
let rows: Vec<(String, String, String, i64, i64, bool, bool, bool, String)> = sqlx::query_as(
|
||||
"SELECT m.model_id, m.provider_id, m.alias, m.context_window,
|
||||
"SELECT DISTINCT m.model_id, m.provider_id, m.alias, m.context_window,
|
||||
m.max_output_tokens, m.supports_streaming, m.supports_vision,
|
||||
m.is_embedding, m.model_type
|
||||
FROM models m
|
||||
INNER JOIN providers p ON m.provider_id = p.id
|
||||
INNER JOIN provider_keys pk ON pk.provider_id = p.id AND pk.is_active = true
|
||||
WHERE m.enabled = true AND p.enabled = true
|
||||
ORDER BY m.provider_id, m.model_id"
|
||||
)
|
||||
|
||||
@@ -117,7 +117,13 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32])
|
||||
}
|
||||
|
||||
// 此 Key 可用 — 解密 key_value
|
||||
let decrypted_kv = decrypt_key_value(key_value, enc_key)?;
|
||||
let decrypted_kv = match decrypt_key_value(key_value, enc_key) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("Key {} decryption failed, skipping: {}", id, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let selection = KeySelection {
|
||||
key: PoolKey {
|
||||
id: id.clone(),
|
||||
@@ -371,3 +377,52 @@ fn parse_cooldown_remaining(cooldown_until: &str, now: &str) -> i64 {
|
||||
_ => 60, // 默认 60 秒
|
||||
}
|
||||
}
|
||||
|
||||
/// Startup self-healing: re-encrypt all provider keys with current encryption key.
|
||||
///
|
||||
/// For each encrypted key, attempts decryption with the current key.
|
||||
/// If decryption succeeds, re-encrypts and updates in-place (idempotent).
|
||||
/// If decryption fails, logs a warning and marks the key inactive.
|
||||
pub async fn heal_provider_keys(db: &PgPool, enc_key: &[u8; 32]) -> usize {
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT id, key_value FROM provider_keys WHERE key_value LIKE 'enc:%'"
|
||||
).fetch_all(db).await.unwrap_or_default();
|
||||
|
||||
let mut healed = 0usize;
|
||||
let mut failed = 0usize;
|
||||
|
||||
for (id, key_value) in &rows {
|
||||
match crypto::decrypt_value(key_value, enc_key) {
|
||||
Ok(plaintext) => {
|
||||
// Re-encrypt with current key (idempotent if same key)
|
||||
match crypto::encrypt_value(&plaintext, enc_key) {
|
||||
Ok(new_encrypted) => {
|
||||
if let Err(e) = sqlx::query(
|
||||
"UPDATE provider_keys SET key_value = $1 WHERE id = $2"
|
||||
).bind(&new_encrypted).bind(id).execute(db).await {
|
||||
tracing::warn!("[heal] Failed to update key {}: {}", id, e);
|
||||
} else {
|
||||
healed += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[heal] Failed to re-encrypt key {}: {}", id, e);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[heal] Cannot decrypt key {}, marking inactive: {}", id, e);
|
||||
let _ = sqlx::query(
|
||||
"UPDATE provider_keys SET is_active = FALSE WHERE id = $1"
|
||||
).bind(id).execute(db).await;
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if healed > 0 || failed > 0 {
|
||||
tracing::info!("[heal] Provider keys: {} re-encrypted, {} failed", healed, failed);
|
||||
}
|
||||
healed
|
||||
}
|
||||
|
||||
@@ -192,21 +192,39 @@ pub async fn update_task_status(
|
||||
struct SseUsageCapture {
|
||||
input_tokens: i64,
|
||||
output_tokens: i64,
|
||||
/// 标记上游 stream 是否已结束(channel 关闭或收到 [DONE])
|
||||
stream_done: bool,
|
||||
}
|
||||
|
||||
impl SseUsageCapture {
|
||||
fn parse_sse_line(&mut self, line: &str) {
|
||||
if let Some(data) = line.strip_prefix("data: ") {
|
||||
if data == "[DONE]" {
|
||||
return;
|
||||
}
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
|
||||
if let Some(usage) = parsed.get("usage") {
|
||||
if let Some(input) = usage.get("prompt_tokens").and_then(|v| v.as_i64()) {
|
||||
self.input_tokens = input;
|
||||
}
|
||||
if let Some(output) = usage.get("completion_tokens").and_then(|v| v.as_i64()) {
|
||||
self.output_tokens = output;
|
||||
// 兼容 "data: " 和 "data:" 两种前缀
|
||||
let data = if let Some(d) = line.strip_prefix("data: ") {
|
||||
d
|
||||
} else if let Some(d) = line.strip_prefix("data:") {
|
||||
d.trim_start()
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
if data == "[DONE]" {
|
||||
self.stream_done = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
|
||||
if let Some(usage) = parsed.get("usage") {
|
||||
// 标准 OpenAI 格式: prompt_tokens / completion_tokens
|
||||
if let Some(input) = usage.get("prompt_tokens").and_then(|v| v.as_i64()) {
|
||||
self.input_tokens = input;
|
||||
}
|
||||
if let Some(output) = usage.get("completion_tokens").and_then(|v| v.as_i64()) {
|
||||
self.output_tokens = output;
|
||||
}
|
||||
// 兜底: 某些 provider 只返回 total_tokens
|
||||
if self.input_tokens == 0 && self.output_tokens > 0 {
|
||||
if let Some(total) = usage.get("total_tokens").and_then(|v| v.as_i64()) {
|
||||
self.input_tokens = (total - self.output_tokens).max(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,6 +333,12 @@ pub async fn execute_relay(
|
||||
let task_id_clone = task_id.to_string();
|
||||
let key_id_for_spawn = key_id.clone();
|
||||
let account_id_clone = account_id.to_string();
|
||||
let provider_id_clone = provider_id.to_string();
|
||||
// 从 request_body 提取 model_id 用于 usage_records 归因
|
||||
let model_id_clone = serde_json::from_str::<serde_json::Value>(request_body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("model").and_then(|m| m.as_str()).map(String::from))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Bounded channel for backpressure: 128 chunks (~128KB) buffer.
|
||||
// If the client reads slowly, the upstream is signaled via
|
||||
@@ -350,6 +374,11 @@ pub async fn execute_relay(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stream 结束后设置 stream_done 标志,通知 usage 轮询任务
|
||||
{
|
||||
let mut capture = usage_capture_clone.lock().await;
|
||||
capture.stream_done = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Build StreamBridge: wraps the bounded receiver with heartbeat,
|
||||
@@ -371,8 +400,8 @@ pub async fn execute_relay(
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit; // 持有 permit 直到任务完成
|
||||
// 等待 SSE 流结束 — 等待 capture 稳定(tokens 不再增长)
|
||||
// 替代原来固定 500ms 的 race condition
|
||||
// 等待 SSE 流结束 — 优先等待 stream_done 标志,
|
||||
// 兜底使用 token 稳定检测 + 最大等待时间
|
||||
let max_wait = std::time::Duration::from_secs(120);
|
||||
let poll_interval = std::time::Duration::from_millis(500);
|
||||
let start = tokio::time::Instant::now();
|
||||
@@ -381,11 +410,15 @@ pub async fn execute_relay(
|
||||
let (input, output) = loop {
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
let capture = usage_capture.lock().await;
|
||||
// 优先: stream_done 标志表示上游已结束
|
||||
if capture.stream_done {
|
||||
break (capture.input_tokens, capture.output_tokens);
|
||||
}
|
||||
let total = capture.input_tokens + capture.output_tokens;
|
||||
// 兜底: token 数稳定检测(兼容不发送 [DONE] 的 provider)
|
||||
if total == last_tokens && total > 0 {
|
||||
stable_count += 1;
|
||||
if stable_count >= 3 {
|
||||
// 连续 3 次稳定(1.5s),认为流结束
|
||||
break (capture.input_tokens, capture.output_tokens);
|
||||
}
|
||||
} else {
|
||||
@@ -393,8 +426,13 @@ pub async fn execute_relay(
|
||||
last_tokens = total;
|
||||
}
|
||||
drop(capture);
|
||||
// 最终兜底: 超时保护
|
||||
if start.elapsed() >= max_wait {
|
||||
let capture = usage_capture.lock().await;
|
||||
tracing::warn!(
|
||||
"SSE usage capture timed out for task {}, tokens: in={} out={}",
|
||||
task_id_clone, capture.input_tokens, capture.output_tokens
|
||||
);
|
||||
break (capture.input_tokens, capture.output_tokens);
|
||||
}
|
||||
};
|
||||
@@ -402,16 +440,23 @@ pub async fn execute_relay(
|
||||
let input_opt = if input > 0 { Some(input) } else { None };
|
||||
let output_opt = if output > 0 { Some(output) } else { None };
|
||||
|
||||
// Record task status + billing usage + key usage
|
||||
// Record task status + billing usage + key usage + usage_records
|
||||
let db_op = async {
|
||||
if let Err(e) = update_task_status(&db_clone, &task_id_clone, "completed", input_opt, output_opt, None).await {
|
||||
tracing::warn!("Failed to update task status after SSE stream: {}", e);
|
||||
}
|
||||
// P2-9 修复: SSE 路径也更新 billing_usage_quotas
|
||||
// SSE 路径回写 usage_records + billing 配额
|
||||
if input > 0 || output > 0 {
|
||||
// 回写 usage_records 真实 token(补全 handlers.rs 中 token=0 的占位记录)
|
||||
if let Err(e) = crate::model_config::service::record_usage(
|
||||
&db_clone, &account_id_clone, &provider_id_clone, &model_id_clone,
|
||||
input, output, None, "success", None,
|
||||
).await {
|
||||
tracing::warn!("Failed to record SSE usage for task {}: {}", task_id_clone, e);
|
||||
}
|
||||
// 更新 billing_usage_quotas(tokens + relay_requests 同步递增)
|
||||
if let Err(e) = crate::billing::service::increment_usage(
|
||||
&db_clone, &account_id_clone,
|
||||
input, output,
|
||||
&db_clone, &account_id_clone, input, output,
|
||||
).await {
|
||||
tracing::warn!("Failed to increment billing usage for SSE task {}: {}", task_id_clone, e);
|
||||
}
|
||||
@@ -591,6 +636,17 @@ pub async fn execute_relay_with_failover(
|
||||
candidate.model_id
|
||||
);
|
||||
}
|
||||
// P2-09 修复: 非 SSE 响应在 failover 成功后记录 tokens 并标记 completed
|
||||
if let RelayResponse::Json(ref body) = response {
|
||||
let (input_tokens, output_tokens) = extract_token_usage(body);
|
||||
if input_tokens > 0 || output_tokens > 0 {
|
||||
if let Err(e) = update_task_status(db, task_id, "completed",
|
||||
Some(input_tokens), Some(output_tokens), None).await {
|
||||
tracing::warn!("Failed to update task {} tokens after failover: {}", task_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// SSE 响应由 StreamBridge 后台任务处理,无需在此更新
|
||||
return Ok((response, candidate.provider_id.clone(), candidate.model_id.clone()));
|
||||
}
|
||||
Err(SaasError::RateLimited(msg)) => {
|
||||
|
||||
@@ -82,6 +82,7 @@ pub fn start_scheduler(config: &SchedulerConfig, _db: PgPool, dispatcher: Worker
|
||||
pub fn start_db_cleanup_tasks(db: PgPool) {
|
||||
let db_devices = db.clone();
|
||||
let db_key_pool = db.clone();
|
||||
let db_relay = db.clone();
|
||||
|
||||
// 每 24 小时清理不活跃设备
|
||||
tokio::spawn(async move {
|
||||
@@ -128,6 +129,28 @@ pub fn start_db_cleanup_tasks(db: PgPool) {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 每 5 分钟清理超时的 relay_tasks(status=processing 且 updated_at 超过 10 分钟)
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match sqlx::query(
|
||||
"UPDATE relay_tasks SET status = 'failed', error_message = 'timeout: upstream not responding', completed_at = NOW() \
|
||||
WHERE status = 'processing' AND updated_at < NOW() - INTERVAL '10 minutes'"
|
||||
)
|
||||
.execute(&db_relay)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if result.rows_affected() > 0 {
|
||||
tracing::warn!("Cleaned up {} timed-out relay tasks (>10m processing)", result.rows_affected());
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Relay task timeout cleanup failed: {}", e),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 用户任务调度器
|
||||
|
||||
@@ -47,6 +47,7 @@ pub struct ClassroomChatCmdRequest {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Send a message in the classroom chat and get multi-agent responses.
|
||||
// @reserved: classroom chat functionality
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn classroom_chat(
|
||||
|
||||
@@ -88,6 +88,7 @@ fn stage_name(stage: &GenerationStage) -> &'static str {
|
||||
/// Start classroom generation (4-stage pipeline).
|
||||
/// Progress events are emitted via `classroom:progress`.
|
||||
/// Supports cancellation between stages by removing the task from GenerationTasks.
|
||||
// @reserved: classroom generation
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn classroom_generate(
|
||||
@@ -270,6 +271,7 @@ pub async fn classroom_cancel_generation(
|
||||
}
|
||||
|
||||
/// Retrieve a generated classroom by ID
|
||||
// @reserved: classroom generation
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn classroom_get(
|
||||
|
||||
@@ -101,6 +101,7 @@ impl ClassroomPersistence {
|
||||
}
|
||||
|
||||
/// Delete a classroom and its chat history.
|
||||
#[allow(dead_code)]
|
||||
pub async fn delete_classroom(&self, classroom_id: &str) -> Result<(), String> {
|
||||
let mut conn = self.conn.lock().await;
|
||||
sqlx::query("DELETE FROM classrooms WHERE id = ?")
|
||||
|
||||
@@ -52,6 +52,7 @@ pub(crate) struct ProcessLogsResponse {
|
||||
}
|
||||
|
||||
/// Get ZCLAW Kernel status
|
||||
// @reserved: system control
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub fn zclaw_status(app: AppHandle) -> Result<LocalGatewayStatus, String> {
|
||||
@@ -59,6 +60,7 @@ pub fn zclaw_status(app: AppHandle) -> Result<LocalGatewayStatus, String> {
|
||||
}
|
||||
|
||||
/// Start ZCLAW Kernel
|
||||
// @reserved: system control
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub fn zclaw_start(app: AppHandle) -> Result<LocalGatewayStatus, String> {
|
||||
@@ -69,6 +71,7 @@ pub fn zclaw_start(app: AppHandle) -> Result<LocalGatewayStatus, String> {
|
||||
}
|
||||
|
||||
/// Stop ZCLAW Kernel
|
||||
// @reserved: system control
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub fn zclaw_stop(app: AppHandle) -> Result<LocalGatewayStatus, String> {
|
||||
@@ -78,6 +81,7 @@ pub fn zclaw_stop(app: AppHandle) -> Result<LocalGatewayStatus, String> {
|
||||
}
|
||||
|
||||
/// Restart ZCLAW Kernel
|
||||
// @reserved: system control
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub fn zclaw_restart(app: AppHandle) -> Result<LocalGatewayStatus, String> {
|
||||
@@ -88,6 +92,7 @@ pub fn zclaw_restart(app: AppHandle) -> Result<LocalGatewayStatus, String> {
|
||||
}
|
||||
|
||||
/// Get local auth token from ZCLAW config
|
||||
// @reserved: system control
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub fn zclaw_local_auth() -> Result<LocalGatewayAuth, String> {
|
||||
@@ -95,6 +100,7 @@ pub fn zclaw_local_auth() -> Result<LocalGatewayAuth, String> {
|
||||
}
|
||||
|
||||
/// Prepare ZCLAW for Tauri (update allowed origins)
|
||||
// @reserved: system control
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub fn zclaw_prepare_for_tauri(app: AppHandle) -> Result<LocalGatewayPrepareResult, String> {
|
||||
@@ -102,6 +108,7 @@ pub fn zclaw_prepare_for_tauri(app: AppHandle) -> Result<LocalGatewayPrepareResu
|
||||
}
|
||||
|
||||
/// Approve device pairing request
|
||||
// @reserved: system control
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub fn zclaw_approve_device_pairing(
|
||||
@@ -122,6 +129,7 @@ pub fn zclaw_doctor(app: AppHandle) -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// List ZCLAW processes
|
||||
// @reserved: system control
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub fn zclaw_process_list(app: AppHandle) -> Result<ProcessListResponse, String> {
|
||||
@@ -160,6 +168,7 @@ pub fn zclaw_process_list(app: AppHandle) -> Result<ProcessListResponse, String>
|
||||
}
|
||||
|
||||
/// Get ZCLAW process logs
|
||||
// @reserved: system control
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub fn zclaw_process_logs(
|
||||
@@ -224,6 +233,7 @@ pub fn zclaw_process_logs(
|
||||
}
|
||||
|
||||
/// Get ZCLAW version information
|
||||
// @reserved: system control
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub fn zclaw_version(app: AppHandle) -> Result<VersionResponse, String> {
|
||||
|
||||
@@ -112,6 +112,7 @@ fn get_process_uptime(status: &LocalGatewayStatus) -> Option<u64> {
|
||||
}
|
||||
|
||||
/// Perform comprehensive health check on ZCLAW Kernel
|
||||
// @reserved: system health check
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub fn zclaw_health_check(
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
use uuid::Uuid;
|
||||
use zclaw_growth::ExperienceStore;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use super::pain_aggregator::PainPoint;
|
||||
use super::solution_generator::{Proposal, ProposalStatus};
|
||||
use super::solution_generator::Proposal;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared completion status
|
||||
|
||||
126
desktop/src-tauri/src/intelligence/health_snapshot.rs
Normal file
126
desktop/src-tauri/src/intelligence/health_snapshot.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
//! Health Snapshot — on-demand query for all subsystem health status
|
||||
//!
|
||||
//! Provides a single Tauri command that aggregates health data from:
|
||||
//! - Intelligence Heartbeat engine (running state, config, alerts)
|
||||
//! - Memory pipeline (entries count, storage size)
|
||||
//!
|
||||
//! Connection and SaaS status are managed by frontend stores and not included here.
|
||||
|
||||
use serde::Serialize;
|
||||
use super::heartbeat::{HeartbeatConfig, HeartbeatEngineState, HeartbeatResult};
|
||||
|
||||
/// Aggregated health snapshot from Rust backend
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HealthSnapshot {
|
||||
pub timestamp: String,
|
||||
pub intelligence: IntelligenceHealth,
|
||||
pub memory: MemoryHealth,
|
||||
}
|
||||
|
||||
/// Intelligence heartbeat engine status
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IntelligenceHealth {
|
||||
pub engine_running: bool,
|
||||
pub config: HeartbeatConfig,
|
||||
pub last_tick: Option<String>,
|
||||
pub alert_count_24h: usize,
|
||||
pub total_checks: usize,
|
||||
}
|
||||
|
||||
/// Memory pipeline status
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MemoryHealth {
|
||||
pub total_entries: usize,
|
||||
pub storage_size_bytes: u64,
|
||||
pub last_extraction: Option<String>,
|
||||
}
|
||||
|
||||
/// Query a unified health snapshot for an agent
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn health_snapshot(
|
||||
agent_id: String,
|
||||
heartbeat_state: tauri::State<'_, HeartbeatEngineState>,
|
||||
) -> Result<HealthSnapshot, String> {
|
||||
let engines = heartbeat_state.lock().await;
|
||||
|
||||
let engine = engines
|
||||
.get(&agent_id)
|
||||
.ok_or_else(|| format!("Heartbeat engine not initialized for agent: {}", agent_id))?;
|
||||
|
||||
let engine_running = engine.is_running().await;
|
||||
let config = engine.get_config().await;
|
||||
let history: Vec<HeartbeatResult> = engine.get_history(100).await;
|
||||
|
||||
// Calculate alert count in the last 24 hours
|
||||
let now = chrono::Utc::now();
|
||||
let twenty_four_hours_ago = now - chrono::Duration::hours(24);
|
||||
let alert_count_24h = history
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.timestamp.parse::<chrono::DateTime<chrono::Utc>>()
|
||||
.map(|t| t > twenty_four_hours_ago)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.flat_map(|r| r.alerts.iter())
|
||||
.count();
|
||||
|
||||
let last_tick = history.first().map(|r| r.timestamp.clone());
|
||||
|
||||
// Memory health from cached stats (fallback to zeros)
|
||||
// Read cache in a separate scope to ensure RwLockReadGuard is dropped before any .await
|
||||
let cached_stats: Option<super::heartbeat::MemoryStatsCache> = {
|
||||
let cache = super::heartbeat::get_memory_stats_cache();
|
||||
match cache.read() {
|
||||
Ok(c) => c.get(&agent_id).cloned(),
|
||||
Err(_) => None,
|
||||
}
|
||||
}; // RwLockReadGuard dropped here
|
||||
|
||||
let memory = match cached_stats {
|
||||
Some(s) => MemoryHealth {
|
||||
total_entries: s.total_entries,
|
||||
storage_size_bytes: s.storage_size_bytes as u64,
|
||||
last_extraction: s.last_updated,
|
||||
},
|
||||
None => {
|
||||
// Fallback: try to query VikingStorage directly
|
||||
match crate::viking_commands::get_storage().await {
|
||||
Ok(storage) => {
|
||||
match zclaw_growth::VikingStorage::find_by_prefix(&*storage, &format!("mem:{}", agent_id)).await {
|
||||
Ok(entries) => MemoryHealth {
|
||||
total_entries: entries.len(),
|
||||
storage_size_bytes: 0,
|
||||
last_extraction: None,
|
||||
},
|
||||
Err(_) => MemoryHealth {
|
||||
total_entries: 0,
|
||||
storage_size_bytes: 0,
|
||||
last_extraction: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(_) => MemoryHealth {
|
||||
total_entries: 0,
|
||||
storage_size_bytes: 0,
|
||||
last_extraction: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(HealthSnapshot {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
intelligence: IntelligenceHealth {
|
||||
engine_running,
|
||||
config,
|
||||
last_tick,
|
||||
alert_count_24h,
|
||||
total_checks: 5, // Fixed: 5 built-in checks
|
||||
},
|
||||
memory,
|
||||
})
|
||||
}
|
||||
@@ -13,9 +13,10 @@ use chrono::{Local, Timelike};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use tokio::time::interval;
|
||||
use tokio::sync::{broadcast, Mutex, Notify};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
// === Types ===
|
||||
|
||||
@@ -91,9 +92,9 @@ pub enum HeartbeatStatus {
|
||||
Alert,
|
||||
}
|
||||
|
||||
/// Type alias for heartbeat check function
|
||||
#[allow(dead_code)] // Reserved for future proactive check registration
|
||||
type HeartbeatCheckFn = Box<dyn Fn(String) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<HeartbeatAlert>> + Send>> + Send + Sync>;
|
||||
/// Global AppHandle for emitting heartbeat alerts to frontend
|
||||
/// Set by heartbeat_init, used by background tick task
|
||||
static HEARTBEAT_APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
|
||||
|
||||
// === Default Config ===
|
||||
|
||||
@@ -117,6 +118,7 @@ pub struct HeartbeatEngine {
|
||||
agent_id: String,
|
||||
config: Arc<Mutex<HeartbeatConfig>>,
|
||||
running: Arc<Mutex<bool>>,
|
||||
stop_notify: Arc<Notify>,
|
||||
alert_sender: broadcast::Sender<HeartbeatAlert>,
|
||||
history: Arc<Mutex<Vec<HeartbeatResult>>>,
|
||||
}
|
||||
@@ -129,6 +131,7 @@ impl HeartbeatEngine {
|
||||
agent_id,
|
||||
config: Arc::new(Mutex::new(config.unwrap_or_default())),
|
||||
running: Arc::new(Mutex::new(false)),
|
||||
stop_notify: Arc::new(Notify::new()),
|
||||
alert_sender,
|
||||
history: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
@@ -146,16 +149,20 @@ impl HeartbeatEngine {
|
||||
let agent_id = self.agent_id.clone();
|
||||
let config = Arc::clone(&self.config);
|
||||
let running_clone = Arc::clone(&self.running);
|
||||
let stop_notify = Arc::clone(&self.stop_notify);
|
||||
let alert_sender = self.alert_sender.clone();
|
||||
let history = Arc::clone(&self.history);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = interval(Duration::from_secs(
|
||||
config.lock().await.interval_minutes * 60,
|
||||
));
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
// Re-read interval every loop — supports dynamic config changes
|
||||
let sleep_secs = config.lock().await.interval_minutes * 60;
|
||||
|
||||
// Interruptible sleep: stop_notify wakes immediately on stop()
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(sleep_secs)) => {},
|
||||
_ = stop_notify.notified() => { break; }
|
||||
};
|
||||
|
||||
if !*running_clone.lock().await {
|
||||
break;
|
||||
@@ -199,10 +206,10 @@ impl HeartbeatEngine {
|
||||
pub async fn stop(&self) {
|
||||
let mut running = self.running.lock().await;
|
||||
*running = false;
|
||||
self.stop_notify.notify_one(); // Wake up sleep immediately
|
||||
}
|
||||
|
||||
/// Check if the engine is running
|
||||
#[allow(dead_code)] // Reserved for UI status display
|
||||
pub async fn is_running(&self) -> bool {
|
||||
*self.running.lock().await
|
||||
}
|
||||
@@ -237,12 +244,6 @@ impl HeartbeatEngine {
|
||||
result
|
||||
}
|
||||
|
||||
/// Subscribe to alerts
|
||||
#[allow(dead_code)] // Reserved for future UI notification integration
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<HeartbeatAlert> {
|
||||
self.alert_sender.subscribe()
|
||||
}
|
||||
|
||||
/// Get heartbeat history
|
||||
pub async fn get_history(&self, limit: usize) -> Vec<HeartbeatResult> {
|
||||
let hist = self.history.lock().await;
|
||||
@@ -280,10 +281,22 @@ impl HeartbeatEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update configuration
|
||||
/// Update configuration and persist to VikingStorage
|
||||
pub async fn update_config(&self, updates: HeartbeatConfig) {
|
||||
let mut config = self.config.lock().await;
|
||||
*config = updates;
|
||||
*self.config.lock().await = updates.clone();
|
||||
// Persist config to VikingStorage
|
||||
let key = format!("heartbeat:config:{}", self.agent_id);
|
||||
tokio::spawn(async move {
|
||||
if let Ok(storage) = crate::viking_commands::get_storage().await {
|
||||
if let Ok(json) = serde_json::to_string(&updates) {
|
||||
if let Err(e) = zclaw_growth::VikingStorage::store_metadata_json(
|
||||
&*storage, &key, &json,
|
||||
).await {
|
||||
tracing::warn!("[heartbeat] Failed to persist config: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Get current configuration
|
||||
@@ -368,11 +381,20 @@ async fn execute_tick(
|
||||
// Filter by proactivity level
|
||||
let filtered_alerts = filter_by_proactivity(&alerts, &cfg.proactivity_level);
|
||||
|
||||
// Send alerts
|
||||
// Send alerts via broadcast channel (internal)
|
||||
for alert in &filtered_alerts {
|
||||
let _ = alert_sender.send(alert.clone());
|
||||
}
|
||||
|
||||
// Emit alerts to frontend via Tauri event (real-time toast)
|
||||
if !filtered_alerts.is_empty() {
|
||||
if let Some(app) = HEARTBEAT_APP_HANDLE.get() {
|
||||
if let Err(e) = app.emit("heartbeat:alert", &filtered_alerts) {
|
||||
tracing::warn!("[heartbeat] Failed to emit alert: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = if filtered_alerts.is_empty() {
|
||||
HeartbeatStatus::Ok
|
||||
} else {
|
||||
@@ -410,7 +432,6 @@ fn filter_by_proactivity(alerts: &[HeartbeatAlert], level: &ProactivityLevel) ->
|
||||
/// Pattern detection counters (shared state for personality detection)
|
||||
use std::collections::HashMap as StdHashMap;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Global correction counters
|
||||
static CORRECTION_COUNTERS: OnceLock<RwLock<StdHashMap<String, usize>>> = OnceLock::new();
|
||||
@@ -437,7 +458,7 @@ fn get_correction_counters() -> &'static RwLock<StdHashMap<String, usize>> {
|
||||
CORRECTION_COUNTERS.get_or_init(|| RwLock::new(StdHashMap::new()))
|
||||
}
|
||||
|
||||
fn get_memory_stats_cache() -> &'static RwLock<StdHashMap<String, MemoryStatsCache>> {
|
||||
pub fn get_memory_stats_cache() -> &'static RwLock<StdHashMap<String, MemoryStatsCache>> {
|
||||
MEMORY_STATS_CACHE.get_or_init(|| RwLock::new(StdHashMap::new()))
|
||||
}
|
||||
|
||||
@@ -537,6 +558,19 @@ fn check_correction_patterns(agent_id: &str) -> Vec<HeartbeatAlert> {
|
||||
alerts
|
||||
}
|
||||
|
||||
/// Fallback: query memory stats directly from VikingStorage when frontend cache is empty
|
||||
fn query_memory_stats_fallback(agent_id: &str) -> Option<MemoryStatsCache> {
|
||||
// This is a synchronous approximation — we check if we have a recent cache entry
|
||||
// by probing the global cache one more time with a slightly different approach
|
||||
// The real fallback is to count VikingStorage entries, but that's async and can't
|
||||
// be called from sync check functions. Instead, we return None and let the
|
||||
// periodic memory stats sync populate the cache.
|
||||
// NOTE: This is intentionally a lightweight no-op fallback. The real data comes
|
||||
// from the frontend sync (every 5 min) or the upcoming health_snapshot command.
|
||||
let _ = agent_id;
|
||||
None
|
||||
}
|
||||
|
||||
/// Check for pending task memories
|
||||
/// Uses cached memory stats to detect task backlog
|
||||
fn check_pending_tasks(agent_id: &str) -> Option<HeartbeatAlert> {
|
||||
@@ -557,15 +591,34 @@ fn check_pending_tasks(agent_id: &str) -> Option<HeartbeatAlert> {
|
||||
},
|
||||
Some(_) => None, // Stats available but no alert needed
|
||||
None => {
|
||||
// Cache is empty - warn about missing sync
|
||||
tracing::warn!("[Heartbeat] Memory stats cache is empty for agent {}, waiting for frontend sync", agent_id);
|
||||
Some(HeartbeatAlert {
|
||||
title: "记忆统计未同步".to_string(),
|
||||
content: "心跳引擎未能获取记忆统计信息,部分检查被跳过。请确保记忆系统正常运行。".to_string(),
|
||||
urgency: Urgency::Low,
|
||||
source: "pending-tasks".to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
// Cache is empty — fallback to VikingStorage direct query
|
||||
let fallback = query_memory_stats_fallback(agent_id);
|
||||
match fallback {
|
||||
Some(stats) if stats.task_count >= 5 => {
|
||||
Some(HeartbeatAlert {
|
||||
title: "待办任务积压".to_string(),
|
||||
content: format!("当前有 {} 个待办任务未完成,建议处理或重新评估优先级", stats.task_count),
|
||||
urgency: if stats.task_count >= 10 {
|
||||
Urgency::High
|
||||
} else {
|
||||
Urgency::Medium
|
||||
},
|
||||
source: "pending-tasks".to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
},
|
||||
Some(_) => None, // Fallback stats available but no alert needed
|
||||
None => {
|
||||
tracing::warn!("[Heartbeat] Memory stats unavailable for agent {} (cache + fallback empty)", agent_id);
|
||||
Some(HeartbeatAlert {
|
||||
title: "记忆统计未同步".to_string(),
|
||||
content: "心跳引擎未能获取记忆统计信息,部分检查被跳过。请确保记忆系统正常运行。".to_string(),
|
||||
urgency: Urgency::Low,
|
||||
source: "pending-tasks".to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,15 +759,21 @@ pub type HeartbeatEngineState = Arc<Mutex<HashMap<String, HeartbeatEngine>>>;
|
||||
|
||||
/// Initialize heartbeat engine for an agent
|
||||
///
|
||||
/// Restores persisted interaction time from VikingStorage so idle-greeting
|
||||
/// check works correctly across app restarts.
|
||||
/// Restores persisted interaction time and config from VikingStorage so
|
||||
/// idle-greeting check and config changes survive across app restarts.
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn heartbeat_init(
|
||||
app: AppHandle,
|
||||
agent_id: String,
|
||||
config: Option<HeartbeatConfig>,
|
||||
state: tauri::State<'_, HeartbeatEngineState>,
|
||||
) -> Result<(), String> {
|
||||
// Store AppHandle globally for real-time alert emission
|
||||
if let Err(_) = HEARTBEAT_APP_HANDLE.set(app) {
|
||||
tracing::warn!("[heartbeat] APP_HANDLE already set (multiple init calls)");
|
||||
}
|
||||
|
||||
// P2-06: Validate minimum interval (prevent busy-loop)
|
||||
const MIN_INTERVAL_MINUTES: u64 = 1;
|
||||
if let Some(ref cfg) = config {
|
||||
@@ -726,7 +785,11 @@ pub async fn heartbeat_init(
|
||||
}
|
||||
}
|
||||
|
||||
let engine = HeartbeatEngine::new(agent_id.clone(), config);
|
||||
// Restore config from VikingStorage (overrides passed-in default)
|
||||
let restored_config = restore_config_from_storage(&agent_id).await
|
||||
.or(config);
|
||||
|
||||
let engine = HeartbeatEngine::new(agent_id.clone(), restored_config);
|
||||
|
||||
// Restore last interaction time from VikingStorage metadata
|
||||
restore_last_interaction(&agent_id).await;
|
||||
@@ -739,6 +802,38 @@ pub async fn heartbeat_init(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore config from VikingStorage, returns None if not found
|
||||
async fn restore_config_from_storage(agent_id: &str) -> Option<HeartbeatConfig> {
|
||||
let key = format!("heartbeat:config:{}", agent_id);
|
||||
match crate::viking_commands::get_storage().await {
|
||||
Ok(storage) => {
|
||||
match zclaw_growth::VikingStorage::get_metadata_json(&*storage, &key).await {
|
||||
Ok(Some(json)) => {
|
||||
match serde_json::from_str::<HeartbeatConfig>(&json) {
|
||||
Ok(cfg) => {
|
||||
tracing::info!("[heartbeat] Restored config for {}", agent_id);
|
||||
Some(cfg)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[heartbeat] Failed to parse persisted config: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
tracing::warn!("[heartbeat] Failed to read persisted config: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[heartbeat] Storage unavailable for config restore: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the last interaction timestamp for an agent from VikingStorage.
|
||||
/// Called during heartbeat_init so the idle-greeting check works after restart.
|
||||
pub async fn restore_last_interaction(agent_id: &str) {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zclaw_growth::VikingStorage;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -53,6 +54,7 @@ pub struct IdentityChangeProposal {
|
||||
pub enum IdentityFile {
|
||||
Soul,
|
||||
Instructions,
|
||||
UserProfile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -270,11 +272,13 @@ impl AgentIdentityManager {
|
||||
match file {
|
||||
IdentityFile::Soul => identity.soul,
|
||||
IdentityFile::Instructions => identity.instructions,
|
||||
IdentityFile::UserProfile => identity.user_profile,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build system prompt from identity files
|
||||
pub fn build_system_prompt(&mut self, agent_id: &str, memory_context: Option<&str>) -> String {
|
||||
/// Build system prompt from identity files.
|
||||
/// Async because it may query VikingStorage as a fallback for user preferences.
|
||||
pub async fn build_system_prompt(&mut self, agent_id: &str, memory_context: Option<&str>) -> String {
|
||||
let identity = self.get_identity(agent_id);
|
||||
let mut sections = Vec::new();
|
||||
|
||||
@@ -284,18 +288,50 @@ impl AgentIdentityManager {
|
||||
if !identity.instructions.is_empty() {
|
||||
sections.push(identity.instructions.clone());
|
||||
}
|
||||
// NOTE: user_profile injection is intentionally disabled.
|
||||
// The reflection engine may accumulate overly specific details from past
|
||||
// conversations (e.g., "广东光华", "汕头玩具产业") into user_profile.
|
||||
// These details then leak into every new conversation's system prompt,
|
||||
// causing the model to think about old topics instead of the current query.
|
||||
// Memory injection should only happen via MemoryMiddleware with relevance
|
||||
// filtering, not unconditionally via user_profile.
|
||||
// if !identity.user_profile.is_empty()
|
||||
// && identity.user_profile != default_user_profile()
|
||||
// {
|
||||
// sections.push(format!("## 用户画像\n{}", identity.user_profile));
|
||||
// }
|
||||
// Inject user_profile into system prompt for cross-session identity continuity.
|
||||
// Truncate to first 10 lines to avoid flooding the prompt with overly specific
|
||||
// details accumulated by the reflection engine. Core identity (name, role)
|
||||
// is typically in the first few lines.
|
||||
if !identity.user_profile.is_empty()
|
||||
&& identity.user_profile != default_user_profile()
|
||||
{
|
||||
let truncated: String = identity
|
||||
.user_profile
|
||||
.lines()
|
||||
.take(10)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !truncated.is_empty() {
|
||||
sections.push(format!("## 用户画像\n{}", truncated));
|
||||
}
|
||||
} else {
|
||||
// Fallback: query VikingStorage for user-related preferences.
|
||||
// The UserProfiler pipeline stores extracted preferences under agent://{uuid}/preferences/.
|
||||
// When identity's user_profile is default (never populated), use this as a data source.
|
||||
if let Ok(storage) = crate::viking_commands::get_storage().await {
|
||||
let prefix = format!("agent://{}/preferences/", agent_id);
|
||||
if let Ok(entries) = storage.find_by_prefix(&prefix).await {
|
||||
if !entries.is_empty() {
|
||||
let prefs: Vec<String> = entries
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let text = if e.content.len() > 80 {
|
||||
let truncated: String = e.content.chars().take(80).collect();
|
||||
format!("{}...", truncated)
|
||||
} else {
|
||||
e.content.clone()
|
||||
};
|
||||
if text.is_empty() { None } else { Some(format!("- {}", text)) }
|
||||
})
|
||||
.take(5)
|
||||
.collect();
|
||||
if !prefs.is_empty() {
|
||||
sections.push(format!("## 用户偏好\n{}", prefs.join("\n")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ctx) = memory_context {
|
||||
sections.push(ctx.to_string());
|
||||
}
|
||||
@@ -336,6 +372,7 @@ impl AgentIdentityManager {
|
||||
let current_content = match file {
|
||||
IdentityFile::Soul => identity.soul.clone(),
|
||||
IdentityFile::Instructions => identity.instructions.clone(),
|
||||
IdentityFile::UserProfile => identity.user_profile.clone(),
|
||||
};
|
||||
|
||||
let proposal = IdentityChangeProposal {
|
||||
@@ -381,6 +418,9 @@ impl AgentIdentityManager {
|
||||
IdentityFile::Instructions => {
|
||||
updated.instructions = suggested_content
|
||||
}
|
||||
IdentityFile::UserProfile => {
|
||||
updated.user_profile = suggested_content
|
||||
}
|
||||
}
|
||||
|
||||
self.identities.insert(agent_id.clone(), updated.clone());
|
||||
@@ -601,6 +641,7 @@ pub async fn identity_get_file(
|
||||
let file_type = match file.as_str() {
|
||||
"soul" => IdentityFile::Soul,
|
||||
"instructions" => IdentityFile::Instructions,
|
||||
"userprofile" | "user_profile" => IdentityFile::UserProfile,
|
||||
_ => return Err(format!("Unknown file: {}", file)),
|
||||
};
|
||||
Ok(manager.get_file(&agent_id, file_type))
|
||||
@@ -615,7 +656,7 @@ pub async fn identity_build_prompt(
|
||||
state: tauri::State<'_, IdentityManagerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut manager = state.lock().await;
|
||||
Ok(manager.build_system_prompt(&agent_id, memory_context.as_deref()))
|
||||
Ok(manager.build_system_prompt(&agent_id, memory_context.as_deref()).await)
|
||||
}
|
||||
|
||||
/// Update user profile (auto)
|
||||
@@ -657,7 +698,8 @@ pub async fn identity_propose_change(
|
||||
let file_type = match target.as_str() {
|
||||
"soul" => IdentityFile::Soul,
|
||||
"instructions" => IdentityFile::Instructions,
|
||||
_ => return Err(format!("Invalid file type: '{}'. Expected 'soul' or 'instructions'", target)),
|
||||
"userprofile" | "user_profile" => IdentityFile::UserProfile,
|
||||
_ => return Err(format!("Invalid file type: '{}'. Expected 'soul', 'instructions', or 'user_profile'", target)),
|
||||
};
|
||||
Ok(manager.propose_change(&agent_id, file_type, &suggested_content, &reason))
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
//! - `trigger_evaluator` - 2026-03-26
|
||||
//! - `persona_evolver` - 2026-03-26
|
||||
|
||||
// Hermes 管线子模块:部分函数由 Tauri 命令或中间件 hooks 按需调用,
|
||||
// 编译期无法检测到跨 crate 引用,统一抑制 dead_code 警告。
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod heartbeat;
|
||||
pub mod compactor;
|
||||
pub mod reflection;
|
||||
@@ -40,6 +44,7 @@ pub mod experience;
|
||||
pub mod triggers;
|
||||
pub mod user_profiler;
|
||||
pub mod trajectory_compressor;
|
||||
pub mod health_snapshot;
|
||||
|
||||
// Re-export main types for convenience
|
||||
pub use heartbeat::HeartbeatEngineState;
|
||||
|
||||
@@ -610,13 +610,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_severity_ordering() {
|
||||
// Single frustration signal → Medium
|
||||
let messages = vec![
|
||||
Message::user("这又来了"),
|
||||
];
|
||||
let result = analyze_for_pain_signals(&messages);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().severity, PainSeverity::Medium);
|
||||
|
||||
// Two frustration signals → High (len >= 2 triggers High)
|
||||
let messages = vec![
|
||||
Message::user("这又来了"),
|
||||
Message::user("还是不行"),
|
||||
];
|
||||
let result = analyze_for_pain_signals(&messages);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().severity, PainSeverity::Medium);
|
||||
assert_eq!(result.unwrap().severity, PainSeverity::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use tracing::{debug, warn};
|
||||
use zclaw_memory::fact::{Fact, FactCategory};
|
||||
use zclaw_memory::fact::Fact;
|
||||
use zclaw_memory::user_profile_store::{
|
||||
CommStyle, Level, UserProfile, UserProfileStore,
|
||||
};
|
||||
|
||||
@@ -283,7 +283,7 @@ async fn build_identity_prompt(
|
||||
let prompt = manager.build_system_prompt(
|
||||
agent_id,
|
||||
if memory_context.is_empty() { None } else { Some(memory_context) },
|
||||
);
|
||||
).await;
|
||||
|
||||
Ok(prompt)
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ pub async fn agent_a2a_delegate_task(
|
||||
|
||||
/// Butler delegates a user request to expert agents via the Director.
|
||||
#[cfg(feature = "multi-agent")]
|
||||
// @reserved: butler multi-agent delegation
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn butler_delegate_task(
|
||||
|
||||
@@ -68,6 +68,7 @@ pub struct AgentUpdateRequest {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a new agent
|
||||
// @reserved: agent CRUD management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn agent_create(
|
||||
@@ -150,6 +151,7 @@ pub async fn agent_create(
|
||||
}
|
||||
|
||||
/// List all agents
|
||||
// @reserved: agent CRUD management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn agent_list(
|
||||
@@ -164,6 +166,7 @@ pub async fn agent_list(
|
||||
}
|
||||
|
||||
/// Get agent info (with optional UserProfile from memory store)
|
||||
// @reserved: agent CRUD management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn agent_get(
|
||||
|
||||
@@ -89,6 +89,7 @@ pub struct StreamChatRequest {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Send a message to an agent
|
||||
// @reserved: agent chat (desktop uses ChatStore/SaaS relay)
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn agent_chat(
|
||||
@@ -216,8 +217,93 @@ pub async fn agent_chat_stream(
|
||||
&identity_state,
|
||||
).await.unwrap_or_default();
|
||||
|
||||
// --- Schedule intent interception ---
|
||||
// If the user's message contains a schedule intent (e.g. "每天早上9点提醒我查房"),
|
||||
// parse it with NlScheduleParser, create a trigger, and return confirmation
|
||||
// directly without calling the LLM.
|
||||
let mut captured_parsed: Option<zclaw_runtime::nl_schedule::ParsedSchedule> = None;
|
||||
|
||||
if zclaw_runtime::nl_schedule::has_schedule_intent(&message) {
|
||||
let parse_result = zclaw_runtime::nl_schedule::parse_nl_schedule(&message, &id);
|
||||
|
||||
match parse_result {
|
||||
zclaw_runtime::nl_schedule::ScheduleParseResult::Exact(ref parsed)
|
||||
if parsed.confidence >= 0.8 =>
|
||||
{
|
||||
// Try to create a schedule trigger
|
||||
let kernel_lock = state.lock().await;
|
||||
if let Some(kernel) = kernel_lock.as_ref() {
|
||||
// Use UUID fragment to avoid collision under high concurrency
|
||||
let trigger_id = format!(
|
||||
"sched_{}_{}",
|
||||
chrono::Utc::now().timestamp_millis(),
|
||||
&uuid::Uuid::new_v4().to_string()[..8]
|
||||
);
|
||||
let trigger_config = zclaw_hands::TriggerConfig {
|
||||
id: trigger_id.clone(),
|
||||
name: parsed.task_description.clone(),
|
||||
hand_id: "_reminder".to_string(),
|
||||
trigger_type: zclaw_hands::TriggerType::Schedule {
|
||||
cron: parsed.cron_expression.clone(),
|
||||
},
|
||||
enabled: true,
|
||||
// 60/hour = once per minute max, reasonable for scheduled tasks
|
||||
max_executions_per_hour: 60,
|
||||
};
|
||||
|
||||
match kernel.create_trigger(trigger_config).await {
|
||||
Ok(_entry) => {
|
||||
tracing::info!(
|
||||
"[agent_chat_stream] Schedule trigger created: {} (cron: {})",
|
||||
trigger_id, parsed.cron_expression
|
||||
);
|
||||
captured_parsed = Some(parsed.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[agent_chat_stream] Failed to create schedule trigger, falling through to LLM: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Ambiguous, Unclear, or low confidence — let LLM handle it naturally
|
||||
tracing::debug!(
|
||||
"[agent_chat_stream] Schedule intent detected but not confident enough, falling through to LLM"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the streaming receiver while holding the lock, then release it
|
||||
let (mut rx, llm_driver) = {
|
||||
// NOTE: When schedule_intercepted, llm_driver is None so post_conversation_hook
|
||||
// (memory extraction, heartbeat, reflection) is intentionally skipped —
|
||||
// schedule confirmations are system messages, not user conversations.
|
||||
let (mut rx, llm_driver) = if let Some(parsed) = captured_parsed {
|
||||
// Schedule was intercepted — build confirmation message directly
|
||||
let confirm_msg = format!(
|
||||
"已为您设置定时任务:\n\n- **任务**:{}\n- **时间**:{}\n- **Cron**:`{}`\n\n任务已激活,将在设定时间自动执行。",
|
||||
parsed.task_description,
|
||||
parsed.natural_description,
|
||||
parsed.cron_expression,
|
||||
);
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(32);
|
||||
let _ = tx.send(zclaw_runtime::LoopEvent::Delta(confirm_msg)).await;
|
||||
let _ = tx.send(zclaw_runtime::LoopEvent::Complete(
|
||||
zclaw_runtime::AgentLoopResult {
|
||||
response: String::new(),
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
iterations: 1,
|
||||
}
|
||||
)).await;
|
||||
drop(tx);
|
||||
(rx, None)
|
||||
} else {
|
||||
// Normal LLM chat path
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| {
|
||||
|
||||
@@ -112,6 +112,7 @@ impl From<zclaw_hands::HandResult> for HandResult {
|
||||
///
|
||||
/// Returns hands from the Kernel's HandRegistry.
|
||||
/// Hands are registered during kernel initialization.
|
||||
// @reserved: Hand autonomous capabilities
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn hand_list(
|
||||
@@ -142,6 +143,7 @@ pub async fn hand_list(
|
||||
/// Executes a hand with the given ID and input.
|
||||
/// If the hand has `needs_approval = true`, creates a pending approval instead.
|
||||
/// Returns the hand result as JSON, or a pending status with approval ID.
|
||||
// @reserved: Hand autonomous capabilities
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn hand_execute(
|
||||
@@ -209,6 +211,7 @@ pub async fn hand_execute(
|
||||
/// When approved, the kernel's `respond_to_approval` internally spawns the Hand
|
||||
/// execution. We additionally emit Tauri events so the frontend can track when
|
||||
/// the execution finishes.
|
||||
// @reserved: Hand approval workflow
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn hand_approve(
|
||||
|
||||
@@ -57,6 +57,7 @@ pub struct KernelStatusResponse {
|
||||
///
|
||||
/// If kernel already exists with the same config, returns existing status.
|
||||
/// If config changed, reboots kernel with new config.
|
||||
// @reserved: kernel lifecycle management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn kernel_init(
|
||||
@@ -73,15 +74,18 @@ pub async fn kernel_init(
|
||||
// Get current config from kernel
|
||||
let current_config = kernel.config();
|
||||
|
||||
// Check if config changed
|
||||
// Check if config changed (model, base_url, or api_key)
|
||||
let config_changed = if let Some(ref req) = config_request {
|
||||
let default_base_url = zclaw_kernel::config::KernelConfig::from_provider(
|
||||
&req.provider, "", &req.model, None, &req.api_protocol
|
||||
).llm.base_url;
|
||||
let request_base_url = req.base_url.clone().unwrap_or(default_base_url.clone());
|
||||
let current_api_key = ¤t_config.llm.api_key;
|
||||
let request_api_key = req.api_key.as_deref().unwrap_or("");
|
||||
|
||||
current_config.llm.model != req.model ||
|
||||
current_config.llm.base_url != request_base_url
|
||||
current_config.llm.base_url != request_base_url ||
|
||||
current_api_key != request_api_key
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ impl Default for McpManagerState {
|
||||
|
||||
impl McpManagerState {
|
||||
/// Create with a pre-allocated kernel_adapters Arc for sharing with Kernel.
|
||||
#[allow(dead_code)]
|
||||
pub fn with_shared_adapters(kernel_adapters: Arc<std::sync::RwLock<Vec<McpToolAdapter>>>) -> Self {
|
||||
Self {
|
||||
manager: Arc::new(Mutex::new(McpServiceManager::new())),
|
||||
@@ -81,6 +82,7 @@ pub struct McpServiceStatus {
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Start an MCP server and discover its tools
|
||||
// @reserved: MCP protocol management
|
||||
/// @connected — frontend: MCPServices.tsx via mcp-client.ts
|
||||
#[tauri::command]
|
||||
pub async fn mcp_start_service(
|
||||
@@ -127,6 +129,7 @@ pub async fn mcp_start_service(
|
||||
}
|
||||
|
||||
/// Stop an MCP server and remove its tools
|
||||
// @reserved: MCP protocol management
|
||||
/// @connected — frontend: MCPServices.tsx via mcp-client.ts
|
||||
#[tauri::command]
|
||||
pub async fn mcp_stop_service(
|
||||
@@ -144,6 +147,7 @@ pub async fn mcp_stop_service(
|
||||
}
|
||||
|
||||
/// List all active MCP services and their tools
|
||||
// @reserved: MCP protocol management
|
||||
/// @connected — frontend: MCPServices.tsx via mcp-client.ts
|
||||
#[tauri::command]
|
||||
pub async fn mcp_list_services(
|
||||
@@ -176,6 +180,7 @@ pub async fn mcp_list_services(
|
||||
}
|
||||
|
||||
/// Call an MCP tool directly
|
||||
// @reserved: MCP protocol management
|
||||
/// @connected — frontend: agent loop via mcp-client.ts
|
||||
#[tauri::command]
|
||||
pub async fn mcp_call_tool(
|
||||
|
||||
@@ -47,6 +47,7 @@ pub struct ScheduledTaskResponse {
|
||||
///
|
||||
/// Tasks are automatically executed by the SchedulerService which checks
|
||||
/// every 60 seconds for due triggers.
|
||||
// @reserved: scheduled task management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn scheduled_task_create(
|
||||
@@ -95,6 +96,7 @@ pub async fn scheduled_task_create(
|
||||
}
|
||||
|
||||
/// List all scheduled tasks (kernel triggers of Schedule type)
|
||||
// @reserved: scheduled task management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn scheduled_task_list(
|
||||
|
||||
@@ -85,6 +85,7 @@ pub async fn skill_list(
|
||||
///
|
||||
/// Re-scans the skills directory for new or updated skills.
|
||||
/// Optionally accepts a custom directory path to scan.
|
||||
// @reserved: skill system management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn skill_refresh(
|
||||
@@ -136,6 +137,7 @@ pub struct UpdateSkillRequest {
|
||||
}
|
||||
|
||||
/// Create a new skill in the skills directory
|
||||
// @reserved: skill system management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn skill_create(
|
||||
@@ -184,6 +186,7 @@ pub async fn skill_create(
|
||||
}
|
||||
|
||||
/// Update an existing skill
|
||||
// @reserved: skill system management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn skill_update(
|
||||
@@ -303,6 +306,7 @@ impl From<zclaw_skills::SkillResult> for SkillResult {
|
||||
///
|
||||
/// Executes a skill with the given ID and input.
|
||||
/// Returns the skill result as JSON.
|
||||
// @reserved: skill system management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn skill_execute(
|
||||
|
||||
@@ -96,6 +96,7 @@ impl From<zclaw_kernel::trigger_manager::TriggerEntry> for TriggerResponse {
|
||||
}
|
||||
|
||||
/// List all triggers
|
||||
// @reserved: trigger management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn trigger_list(
|
||||
@@ -110,6 +111,7 @@ pub async fn trigger_list(
|
||||
}
|
||||
|
||||
/// Get a specific trigger
|
||||
// @reserved: trigger management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn trigger_get(
|
||||
@@ -127,6 +129,7 @@ pub async fn trigger_get(
|
||||
}
|
||||
|
||||
/// Create a new trigger
|
||||
// @reserved: trigger management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn trigger_create(
|
||||
@@ -182,6 +185,7 @@ pub async fn trigger_create(
|
||||
}
|
||||
|
||||
/// Update a trigger
|
||||
// @reserved: trigger management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn trigger_update(
|
||||
@@ -227,6 +231,7 @@ pub async fn trigger_delete(
|
||||
}
|
||||
|
||||
/// Execute a trigger manually
|
||||
// @reserved: trigger management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn trigger_execute(
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct DirStats {
|
||||
}
|
||||
|
||||
/// Count files and total size in a directory (non-recursive, top-level only)
|
||||
// @reserved: workspace statistics
|
||||
#[tauri::command]
|
||||
pub async fn workspace_dir_stats(path: String) -> Result<DirStats, String> {
|
||||
let dir = Path::new(&path);
|
||||
|
||||
@@ -386,6 +386,8 @@ pub fn run() {
|
||||
intelligence::heartbeat::heartbeat_update_memory_stats,
|
||||
intelligence::heartbeat::heartbeat_record_correction,
|
||||
intelligence::heartbeat::heartbeat_record_interaction,
|
||||
// Health Snapshot (on-demand query)
|
||||
intelligence::health_snapshot::health_snapshot,
|
||||
// Context Compactor
|
||||
intelligence::compactor::compactor_estimate_tokens,
|
||||
intelligence::compactor::compactor_estimate_messages_tokens,
|
||||
|
||||
@@ -453,6 +453,7 @@ impl EmbeddingClient {
|
||||
}
|
||||
}
|
||||
|
||||
// @reserved: embedding vector generation
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn embedding_create(
|
||||
@@ -473,6 +474,7 @@ pub async fn embedding_create(
|
||||
client.embed(&text).await
|
||||
}
|
||||
|
||||
// @reserved: embedding provider listing
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn embedding_providers() -> Result<Vec<(String, String, String, usize)>, String> {
|
||||
|
||||
@@ -473,6 +473,7 @@ If no significant memories found, return empty array: []"#,
|
||||
|
||||
// === Tauri Commands ===
|
||||
|
||||
// @reserved: memory extraction
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn extract_session_memories(
|
||||
@@ -490,6 +491,7 @@ pub async fn extract_session_memories(
|
||||
|
||||
/// Extract memories from session and store to SqliteStorage
|
||||
/// This combines extraction and storage in one command
|
||||
// @reserved: memory extraction and storage
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn extract_and_store_memories(
|
||||
|
||||
@@ -55,6 +55,7 @@ pub struct WorkflowStepInput {
|
||||
}
|
||||
|
||||
/// Create a new pipeline as a YAML file
|
||||
// @reserved: pipeline workflow management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn pipeline_create(
|
||||
@@ -180,6 +181,7 @@ pub async fn pipeline_create(
|
||||
}
|
||||
|
||||
/// Update an existing pipeline
|
||||
// @reserved: pipeline workflow management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn pipeline_update(
|
||||
|
||||
@@ -20,6 +20,7 @@ use super::helpers::{get_pipelines_directory, scan_pipelines_with_paths, scan_pi
|
||||
use crate::kernel_commands::KernelState;
|
||||
|
||||
/// Discover and list all available pipelines
|
||||
// @reserved: pipeline workflow management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn pipeline_list(
|
||||
@@ -70,6 +71,7 @@ pub async fn pipeline_list(
|
||||
}
|
||||
|
||||
/// Get pipeline details
|
||||
// @reserved: pipeline workflow management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn pipeline_get(
|
||||
@@ -85,6 +87,7 @@ pub async fn pipeline_get(
|
||||
}
|
||||
|
||||
/// Run a pipeline
|
||||
// @reserved: pipeline workflow management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn pipeline_run(
|
||||
@@ -197,6 +200,7 @@ pub async fn pipeline_run(
|
||||
}
|
||||
|
||||
/// Get pipeline run progress
|
||||
// @reserved: pipeline workflow management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn pipeline_progress(
|
||||
@@ -234,6 +238,7 @@ pub async fn pipeline_cancel(
|
||||
}
|
||||
|
||||
/// Get pipeline run result
|
||||
// @reserved: pipeline workflow management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn pipeline_result(
|
||||
@@ -261,6 +266,7 @@ pub async fn pipeline_result(
|
||||
}
|
||||
|
||||
/// List all runs
|
||||
// @reserved: pipeline workflow management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn pipeline_runs(
|
||||
@@ -287,6 +293,7 @@ pub async fn pipeline_runs(
|
||||
}
|
||||
|
||||
/// Refresh pipeline discovery
|
||||
// @reserved: pipeline workflow management
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn pipeline_refresh(
|
||||
|
||||
@@ -62,6 +62,7 @@ pub struct PipelineCandidateInfo {
|
||||
}
|
||||
|
||||
/// Route user input to matching pipeline
|
||||
// @reserved: semantic intent routing
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn route_intent(
|
||||
|
||||
@@ -9,6 +9,7 @@ use super::types::PipelineInputInfo;
|
||||
use super::PipelineState;
|
||||
|
||||
/// Analyze presentation data
|
||||
// @reserved: presentation analysis
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn analyze_presentation(
|
||||
|
||||
@@ -32,6 +32,7 @@ pub fn secure_store_set(key: String, value: String) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Retrieve a value from the OS keyring
|
||||
// @reserved: secure storage access
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub fn secure_store_get(key: String) -> Result<String, String> {
|
||||
@@ -81,6 +82,7 @@ pub fn secure_store_delete(key: String) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Check if secure storage is available on this platform
|
||||
// @reserved: secure storage access
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub fn secure_store_is_available() -> bool {
|
||||
|
||||
@@ -150,6 +150,7 @@ fn get_data_dir_string() -> Option<String> {
|
||||
// === Tauri Commands ===
|
||||
|
||||
/// Check if memory storage is available
|
||||
// @reserved: VikingStorage persistence
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn viking_status() -> Result<VikingStatus, String> {
|
||||
@@ -178,6 +179,7 @@ pub async fn viking_status() -> Result<VikingStatus, String> {
|
||||
}
|
||||
|
||||
/// Add a memory entry
|
||||
// @reserved: VikingStorage persistence
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn viking_add(uri: String, content: String) -> Result<VikingAddResult, String> {
|
||||
@@ -201,6 +203,7 @@ pub async fn viking_add(uri: String, content: String) -> Result<VikingAddResult,
|
||||
}
|
||||
|
||||
/// Add a memory with metadata
|
||||
// @reserved: VikingStorage persistence
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn viking_add_with_metadata(
|
||||
@@ -232,6 +235,7 @@ pub async fn viking_add_with_metadata(
|
||||
}
|
||||
|
||||
/// Find memories by semantic search
|
||||
// @reserved: VikingStorage persistence
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn viking_find(
|
||||
@@ -278,6 +282,7 @@ pub async fn viking_find(
|
||||
}
|
||||
|
||||
/// Grep memories by pattern (uses FTS5)
|
||||
// @reserved: VikingStorage persistence
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn viking_grep(
|
||||
@@ -332,6 +337,7 @@ pub async fn viking_grep(
|
||||
}
|
||||
|
||||
/// List memories at a path
|
||||
// @reserved: VikingStorage persistence
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn viking_ls(path: String) -> Result<Vec<VikingResource>, String> {
|
||||
@@ -360,6 +366,7 @@ pub async fn viking_ls(path: String) -> Result<Vec<VikingResource>, String> {
|
||||
}
|
||||
|
||||
/// Read memory content
|
||||
// @reserved: VikingStorage persistence
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn viking_read(uri: String, level: Option<String>) -> Result<String, String> {
|
||||
@@ -404,6 +411,7 @@ pub async fn viking_read(uri: String, level: Option<String>) -> Result<String, S
|
||||
}
|
||||
|
||||
/// Remove a memory
|
||||
// @reserved: VikingStorage persistence
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn viking_remove(uri: String) -> Result<(), String> {
|
||||
@@ -418,6 +426,7 @@ pub async fn viking_remove(uri: String) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Get memory tree
|
||||
// @reserved: VikingStorage persistence
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn viking_tree(path: String, depth: Option<usize>) -> Result<serde_json::Value, String> {
|
||||
@@ -469,6 +478,7 @@ pub async fn viking_tree(path: String, depth: Option<usize>) -> Result<serde_jso
|
||||
}
|
||||
|
||||
/// Inject memories into prompt (for agent loop integration)
|
||||
// @reserved: VikingStorage persistence
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn viking_inject_prompt(
|
||||
@@ -611,6 +621,7 @@ pub async fn viking_configure_summary_driver(
|
||||
}
|
||||
|
||||
/// Store a memory and optionally generate L0/L1 summaries in the background
|
||||
// @reserved: VikingStorage persistence
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn viking_store_with_summaries(
|
||||
|
||||
@@ -21,6 +21,7 @@ import { isTauriRuntime, getLocalGatewayStatus, startLocalGateway } from './lib/
|
||||
import { LoginPage } from './components/LoginPage';
|
||||
import { useOnboarding } from './lib/use-onboarding';
|
||||
import { intelligenceClient } from './lib/intelligence-client';
|
||||
import { safeListen } from './lib/safe-tauri';
|
||||
import { loadEmbeddingConfig, loadEmbeddingApiKey } from './lib/embedding-client';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useProposalNotifications, ProposalNotificationHandler } from './lib/useProposalNotifications';
|
||||
@@ -54,6 +55,7 @@ function App() {
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [showDetailDrawer, setShowDetailDrawer] = useState(false);
|
||||
const statsSyncRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const alertUnlistenRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Hand Approval state
|
||||
const [pendingApprovalRun, setPendingApprovalRun] = useState<HandRun | null>(null);
|
||||
@@ -155,6 +157,11 @@ function App() {
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
// SaaS recovery listener (defined at useEffect scope for cleanup access)
|
||||
const handleSaasRecovered = () => {
|
||||
toast('SaaS 服务已恢复连接', 'success');
|
||||
};
|
||||
|
||||
const bootstrap = async () => {
|
||||
// 未登录时不启动 bootstrap,直接结束 loading
|
||||
if (!useSaaSStore.getState().isLoggedIn) {
|
||||
@@ -208,7 +215,9 @@ function App() {
|
||||
// Step 4.5: Auto-start heartbeat engine for self-evolution
|
||||
try {
|
||||
const defaultAgentId = 'zclaw-main';
|
||||
await intelligenceClient.heartbeat.init(defaultAgentId, {
|
||||
// Restore config from localStorage (Rust side also restores from VikingStorage)
|
||||
const savedConfig = localStorage.getItem('zclaw-heartbeat-config');
|
||||
const heartbeatConfig = savedConfig ? JSON.parse(savedConfig) : {
|
||||
enabled: true,
|
||||
interval_minutes: 30,
|
||||
quiet_hours_start: '22:00',
|
||||
@@ -216,7 +225,8 @@ function App() {
|
||||
notify_channel: 'ui',
|
||||
proactivity_level: 'standard',
|
||||
max_alerts_per_tick: 5,
|
||||
});
|
||||
};
|
||||
await intelligenceClient.heartbeat.init(defaultAgentId, heartbeatConfig);
|
||||
|
||||
// Sync memory stats to heartbeat engine
|
||||
try {
|
||||
@@ -236,6 +246,21 @@ function App() {
|
||||
await intelligenceClient.heartbeat.start(defaultAgentId);
|
||||
log.debug('Heartbeat engine started for self-evolution');
|
||||
|
||||
// Listen for real-time heartbeat alerts and show as toast notifications
|
||||
const unlistenAlerts = await safeListen<Array<{ title: string; content: string; urgency: string }>>(
|
||||
'heartbeat:alert',
|
||||
(alerts) => {
|
||||
for (const alert of alerts) {
|
||||
const alertType = alert.urgency === 'high' ? 'error'
|
||||
: alert.urgency === 'medium' ? 'warning'
|
||||
: 'info';
|
||||
toast(`[${alert.title}] ${alert.content}`, alertType as 'info' | 'warning' | 'error');
|
||||
}
|
||||
}
|
||||
);
|
||||
// Store unlisten for cleanup
|
||||
alertUnlistenRef.current = unlistenAlerts;
|
||||
|
||||
// Set up periodic memory stats sync (every 5 minutes)
|
||||
const MEMORY_STATS_SYNC_INTERVAL = 5 * 60 * 1000;
|
||||
const statsSyncInterval = setInterval(async () => {
|
||||
@@ -261,6 +286,9 @@ function App() {
|
||||
// Non-critical, continue without heartbeat
|
||||
}
|
||||
|
||||
// Listen for SaaS recovery events (from saasStore recovery probe)
|
||||
window.addEventListener('saas-recovered', handleSaasRecovered);
|
||||
|
||||
// Step 5: Restore embedding config to Rust backend (Tauri-only)
|
||||
if (isTauriRuntime()) {
|
||||
try {
|
||||
@@ -339,6 +367,12 @@ function App() {
|
||||
if (statsSyncRef.current) {
|
||||
clearInterval(statsSyncRef.current);
|
||||
}
|
||||
// Clean up heartbeat alert listener
|
||||
if (alertUnlistenRef.current) {
|
||||
alertUnlistenRef.current();
|
||||
}
|
||||
// Clean up SaaS recovery event listener
|
||||
window.removeEventListener('saas-recovered', handleSaasRecovered);
|
||||
};
|
||||
}, [connect, onboardingNeeded, onboardingLoading, isLoggedIn]);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { listVikingResources } from '../../lib/viking-client';
|
||||
|
||||
interface MemorySectionProps {
|
||||
agentId: string;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
interface MemoryEntry {
|
||||
@@ -12,7 +13,7 @@ interface MemoryEntry {
|
||||
resourceType: string;
|
||||
}
|
||||
|
||||
export function MemorySection({ agentId }: MemorySectionProps) {
|
||||
export function MemorySection({ agentId, refreshKey }: MemorySectionProps) {
|
||||
const [memories, setMemories] = useState<MemoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -20,7 +21,8 @@ export function MemorySection({ agentId }: MemorySectionProps) {
|
||||
if (!agentId) return;
|
||||
|
||||
setLoading(true);
|
||||
listVikingResources(`viking://agent/${agentId}/memories/`)
|
||||
// 查询 agent:// 下的所有记忆资源 (preferences/knowledge/experience/sessions)
|
||||
listVikingResources(`agent://${agentId}/`)
|
||||
.then((entries) => {
|
||||
setMemories(entries as MemoryEntry[]);
|
||||
})
|
||||
@@ -29,7 +31,7 @@ export function MemorySection({ agentId }: MemorySectionProps) {
|
||||
setMemories([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [agentId]);
|
||||
}, [agentId, refreshKey]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useButlerInsights } from '../../hooks/useButlerInsights';
|
||||
import { useChatStore } from '../../store/chatStore';
|
||||
import { useIndustryStore } from '../../store/industryStore';
|
||||
import { extractAndStoreMemories } from '../../lib/viking-client';
|
||||
import { resolveKernelAgentId } from '../../lib/kernel-agent';
|
||||
import { InsightsSection } from './InsightsSection';
|
||||
import { ProposalsSection } from './ProposalsSection';
|
||||
import { MemorySection } from './MemorySection';
|
||||
@@ -11,10 +13,26 @@ interface ButlerPanelProps {
|
||||
}
|
||||
|
||||
export function ButlerPanel({ agentId }: ButlerPanelProps) {
|
||||
const { painPoints, proposals, loading, error, refresh } = useButlerInsights(agentId);
|
||||
const [resolvedAgentId, setResolvedAgentId] = useState<string | null>(null);
|
||||
// Use resolved kernel UUID for queries — raw agentId may be "1" from SaaS relay
|
||||
// while pain points/proposals are stored under kernel UUID
|
||||
const effectiveAgentId = resolvedAgentId ?? agentId;
|
||||
const { painPoints, proposals, loading, error, refresh } = useButlerInsights(effectiveAgentId);
|
||||
const messageCount = useChatStore((s) => s.messages.length);
|
||||
const { accountIndustries, configs, lastSynced, isLoading: industryLoading, fetchIndustries } = useIndustryStore();
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [memoryRefreshKey, setMemoryRefreshKey] = useState(0);
|
||||
|
||||
// Resolve SaaS relay agentId ("1") to kernel UUID for VikingStorage queries
|
||||
useEffect(() => {
|
||||
if (!agentId) {
|
||||
setResolvedAgentId(null);
|
||||
return;
|
||||
}
|
||||
resolveKernelAgentId(agentId)
|
||||
.then(setResolvedAgentId)
|
||||
.catch(() => setResolvedAgentId(agentId));
|
||||
}, [agentId]);
|
||||
|
||||
// Auto-fetch industry configs once per session
|
||||
useEffect(() => {
|
||||
@@ -26,15 +44,30 @@ export function ButlerPanel({ agentId }: ButlerPanelProps) {
|
||||
const hasData = (painPoints?.length ?? 0) > 0 || (proposals?.length ?? 0) > 0;
|
||||
const canAnalyze = messageCount >= 2;
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!canAnalyze || analyzing) return;
|
||||
const handleAnalyze = useCallback(async () => {
|
||||
if (!canAnalyze || analyzing || !resolvedAgentId) return;
|
||||
setAnalyzing(true);
|
||||
try {
|
||||
// 1. Refresh pain points & proposals
|
||||
await refresh();
|
||||
|
||||
// 2. Extract and store memories from current conversation
|
||||
const messages = useChatStore.getState().messages;
|
||||
if (messages.length >= 2) {
|
||||
const extractionMessages = messages.map((m) => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: typeof m.content === 'string' ? m.content : '',
|
||||
}));
|
||||
await extractAndStoreMemories(extractionMessages, resolvedAgentId);
|
||||
// Trigger MemorySection to reload
|
||||
setMemoryRefreshKey((k) => k + 1);
|
||||
}
|
||||
} catch {
|
||||
// Extraction failure should not block UI — insights still refreshed
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
};
|
||||
}, [canAnalyze, analyzing, resolvedAgentId, refresh]);
|
||||
|
||||
if (!agentId) {
|
||||
return (
|
||||
@@ -107,7 +140,7 @@ export function ButlerPanel({ agentId }: ButlerPanelProps) {
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
我记得关于您
|
||||
</h3>
|
||||
<MemorySection agentId={agentId} />
|
||||
<MemorySection agentId={resolvedAgentId || agentId} refreshKey={memoryRefreshKey} />
|
||||
</div>
|
||||
|
||||
{/* Industry section */}
|
||||
|
||||
441
desktop/src/components/HealthPanel.tsx
Normal file
441
desktop/src/components/HealthPanel.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* HealthPanel — Read-only dashboard for all subsystem health status
|
||||
*
|
||||
* Displays:
|
||||
* - Agent Heartbeat engine status (running, config, alerts)
|
||||
* - Connection status (mode, SaaS reachability)
|
||||
* - SaaS device heartbeat status
|
||||
* - Memory pipeline status
|
||||
* - Recent alerts history
|
||||
*
|
||||
* No config editing (that's HeartbeatConfig tab).
|
||||
* Uses useState (not Zustand) — component-scoped state.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
RefreshCw,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Cloud,
|
||||
CloudOff,
|
||||
Database,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { intelligenceClient, type HeartbeatResult } from '../lib/intelligence-client';
|
||||
import { useConnectionStore } from '../store/connectionStore';
|
||||
import { useSaaSStore } from '../store/saasStore';
|
||||
import { isTauriRuntime } from '../lib/tauri-gateway';
|
||||
import { safeListen } from '../lib/safe-tauri';
|
||||
import { createLogger } from '../lib/logger';
|
||||
|
||||
const log = createLogger('HealthPanel');
|
||||
|
||||
// === Types ===
|
||||
|
||||
interface HealthSnapshotData {
|
||||
timestamp: string;
|
||||
intelligence: {
|
||||
engineRunning: boolean;
|
||||
config: {
|
||||
enabled: boolean;
|
||||
interval_minutes: number;
|
||||
proactivity_level: string;
|
||||
};
|
||||
lastTick: string | null;
|
||||
alertCount24h: number;
|
||||
totalChecks: number;
|
||||
};
|
||||
memory: {
|
||||
totalEntries: number;
|
||||
storageSizeBytes: number;
|
||||
lastExtraction: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface HealthCardProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
status: 'green' | 'yellow' | 'gray' | 'red';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
green: 'text-green-500',
|
||||
yellow: 'text-yellow-500',
|
||||
gray: 'text-gray-400',
|
||||
red: 'text-red-500',
|
||||
};
|
||||
|
||||
const STATUS_BG = {
|
||||
green: 'bg-green-50 dark:bg-green-900/20',
|
||||
yellow: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
gray: 'bg-gray-50 dark:bg-gray-800/50',
|
||||
red: 'bg-red-50 dark:bg-red-900/20',
|
||||
};
|
||||
|
||||
function HealthCard({ title, icon, status, children }: HealthCardProps) {
|
||||
return (
|
||||
<div className={`rounded-lg border border-gray-200 dark:border-gray-700 p-4 ${STATUS_BG[status]}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={STATUS_COLORS[status]}>{icon}</span>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{title}</h3>
|
||||
<span className={`ml-auto text-xs ${STATUS_COLORS[status]}`}>
|
||||
{status === 'green' ? '正常' : status === 'yellow' ? '降级' : status === 'red' ? '异常' : '未启用'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function formatTime(isoString: string | null): string {
|
||||
if (!isoString) return '从未';
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
function formatUrgency(urgency: string): { label: string; color: string } {
|
||||
switch (urgency) {
|
||||
case 'high': return { label: '高', color: 'text-red-500' };
|
||||
case 'medium': return { label: '中', color: 'text-yellow-500' };
|
||||
case 'low': return { label: '低', color: 'text-blue-500' };
|
||||
default: return { label: urgency, color: 'text-gray-500' };
|
||||
}
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function HealthPanel() {
|
||||
const [snapshot, setSnapshot] = useState<HealthSnapshotData | null>(null);
|
||||
const [alerts, setAlerts] = useState<HeartbeatResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const alertsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get live connection and SaaS state
|
||||
const connectionState = useConnectionStore((s) => s.connectionState);
|
||||
const gatewayVersion = useConnectionStore((s) => s.gatewayVersion);
|
||||
const connectionMode = useSaaSStore((s) => s.connectionMode);
|
||||
const saasReachable = useSaaSStore((s) => s.saasReachable);
|
||||
const consecutiveFailures = useSaaSStore((s) => s._consecutiveFailures);
|
||||
const isLoggedIn = useSaaSStore((s) => s.isLoggedIn);
|
||||
|
||||
// Fetch health snapshot
|
||||
const fetchSnapshot = useCallback(async () => {
|
||||
if (!isTauriRuntime()) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const data = await invoke<HealthSnapshotData>('health_snapshot', {
|
||||
agentId: 'zclaw-main',
|
||||
});
|
||||
setSnapshot(data);
|
||||
} catch (err) {
|
||||
log.warn('Failed to fetch health snapshot:', err);
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch alert history
|
||||
const fetchAlerts = useCallback(async () => {
|
||||
if (!isTauriRuntime()) return;
|
||||
try {
|
||||
const history = await intelligenceClient.heartbeat.getHistory('zclaw-main', 100);
|
||||
setAlerts(history);
|
||||
} catch (err) {
|
||||
log.warn('Failed to fetch alert history:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
fetchSnapshot();
|
||||
fetchAlerts();
|
||||
}, [fetchSnapshot, fetchAlerts]);
|
||||
|
||||
// Subscribe to real-time alerts
|
||||
useEffect(() => {
|
||||
if (!isTauriRuntime()) return;
|
||||
|
||||
let unlisten: (() => void) | null = null;
|
||||
const subscribe = async () => {
|
||||
unlisten = await safeListen<Array<{ title: string; content: string; urgency: string; source: string; timestamp: string }>>(
|
||||
'heartbeat:alert',
|
||||
(newAlerts) => {
|
||||
// Prepend new alerts to history
|
||||
setAlerts((prev) => {
|
||||
const result: HeartbeatResult[] = [
|
||||
{
|
||||
status: 'alert',
|
||||
alerts: newAlerts.map((a) => ({
|
||||
title: a.title,
|
||||
content: a.content,
|
||||
urgency: a.urgency as 'low' | 'medium' | 'high',
|
||||
source: a.source,
|
||||
timestamp: a.timestamp,
|
||||
})),
|
||||
checked_items: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
...prev,
|
||||
];
|
||||
// Keep max 100
|
||||
return result.slice(0, 100);
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
subscribe();
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-scroll alerts to show latest
|
||||
useEffect(() => {
|
||||
alertsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [alerts]);
|
||||
|
||||
// Determine SaaS card status
|
||||
const saasStatus: 'green' | 'yellow' | 'gray' | 'red' = !isLoggedIn
|
||||
? 'gray'
|
||||
: saasReachable
|
||||
? 'green'
|
||||
: 'red';
|
||||
|
||||
// Determine connection card status
|
||||
const isActuallyConnected = connectionState === 'connected';
|
||||
const connectionStatus: 'green' | 'yellow' | 'gray' | 'red' = isActuallyConnected
|
||||
? 'green'
|
||||
: connectionState === 'connecting' || connectionState === 'reconnecting'
|
||||
? 'yellow'
|
||||
: 'red';
|
||||
|
||||
// Determine heartbeat card status
|
||||
const heartbeatStatus: 'green' | 'yellow' | 'gray' | 'red' = !snapshot
|
||||
? 'gray'
|
||||
: snapshot.intelligence.engineRunning
|
||||
? 'green'
|
||||
: snapshot.intelligence.config.enabled
|
||||
? 'yellow'
|
||||
: 'gray';
|
||||
|
||||
// Determine memory card status
|
||||
const memoryStatus: 'green' | 'yellow' | 'gray' | 'red' = !snapshot
|
||||
? 'gray'
|
||||
: snapshot.memory.totalEntries === 0
|
||||
? 'gray'
|
||||
: snapshot.memory.storageSizeBytes > 50 * 1024 * 1024
|
||||
? 'yellow'
|
||||
: 'green';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-blue-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">系统健康</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { fetchSnapshot(); fetchAlerts(); }}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
加载失败: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Health Cards Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Agent Heartbeat Card */}
|
||||
<HealthCard
|
||||
title="Agent 心跳"
|
||||
icon={<Activity className="w-4 h-4" />}
|
||||
status={heartbeatStatus}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span>引擎状态</span>
|
||||
<span className={snapshot?.intelligence.engineRunning ? 'text-green-600' : 'text-gray-400'}>
|
||||
{snapshot?.intelligence.engineRunning ? '运行中' : '已停止'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>检查间隔</span>
|
||||
<span>{snapshot?.intelligence.config.interval_minutes ?? '-'} 分钟</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>上次检查</span>
|
||||
<span>{formatTime(snapshot?.intelligence.lastTick ?? null)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>24h 告警数</span>
|
||||
<span>{snapshot?.intelligence.alertCount24h ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>主动性级别</span>
|
||||
<span>{snapshot?.intelligence.config.proactivity_level ?? '-'}</span>
|
||||
</div>
|
||||
</HealthCard>
|
||||
|
||||
{/* Connection Card */}
|
||||
<HealthCard
|
||||
title="连接状态"
|
||||
icon={isActuallyConnected ? <Wifi className="w-4 h-4" /> : <WifiOff className="w-4 h-4" />}
|
||||
status={connectionStatus}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span>连接模式</span>
|
||||
<span>{connectionMode === 'saas' ? 'SaaS 云端' : connectionMode === 'tauri' ? '本地模式' : connectionMode}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>连接状态</span>
|
||||
<span className={isActuallyConnected ? 'text-green-600' : connectionState === 'connecting' ? 'text-yellow-500' : 'text-red-500'}>
|
||||
{connectionState === 'connected' ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>网关版本</span>
|
||||
<span>{gatewayVersion ?? '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>SaaS 可达</span>
|
||||
<span className={saasReachable ? 'text-green-600' : 'text-red-500'}>
|
||||
{saasReachable ? '是' : '否'}
|
||||
</span>
|
||||
</div>
|
||||
</HealthCard>
|
||||
|
||||
{/* SaaS Device Card */}
|
||||
<HealthCard
|
||||
title="SaaS 设备"
|
||||
icon={saasReachable ? <Cloud className="w-4 h-4" /> : <CloudOff className="w-4 h-4" />}
|
||||
status={saasStatus}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span>设备注册</span>
|
||||
<span>{isLoggedIn ? '已注册' : '未注册'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>连续失败</span>
|
||||
<span className={consecutiveFailures > 0 ? 'text-yellow-500' : 'text-green-600'}>
|
||||
{consecutiveFailures}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>服务状态</span>
|
||||
<span className={saasReachable ? 'text-green-600' : 'text-red-500'}>
|
||||
{saasReachable ? '在线' : isLoggedIn ? '离线 (已降级)' : '未连接'}
|
||||
</span>
|
||||
</div>
|
||||
</HealthCard>
|
||||
|
||||
{/* Memory Card */}
|
||||
<HealthCard
|
||||
title="记忆管道"
|
||||
icon={<Database className="w-4 h-4" />}
|
||||
status={memoryStatus}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span>记忆条目</span>
|
||||
<span>{snapshot?.memory.totalEntries ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>存储大小</span>
|
||||
<span>{formatBytes(snapshot?.memory.storageSizeBytes ?? 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>上次提取</span>
|
||||
<span>{formatTime(snapshot?.memory.lastExtraction ?? null)}</span>
|
||||
</div>
|
||||
</HealthCard>
|
||||
</div>
|
||||
|
||||
{/* Alerts History */}
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 p-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500" />
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">最近告警</h3>
|
||||
<span className="ml-auto text-xs text-gray-400">
|
||||
{alerts.reduce((sum, r) => sum + r.alerts.length, 0)} 条
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{alerts.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-gray-400">暂无告警记录</div>
|
||||
) : (
|
||||
alerts.map((result, ri) =>
|
||||
result.alerts.map((alert, ai) => (
|
||||
<div key={`${ri}-${ai}`} className="flex items-start gap-2 p-3 hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<span className={`mt-0.5 ${formatUrgency(alert.urgency).color}`}>
|
||||
{alert.urgency === 'high' ? (
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
) : alert.urgency === 'medium' ? (
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{alert.title}
|
||||
</span>
|
||||
<span className={`text-xs px-1 rounded ${formatUrgency(alert.urgency).color} bg-opacity-10`}>
|
||||
{formatUrgency(alert.urgency).label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{alert.content}</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatTime(alert.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
)}
|
||||
<div ref={alertsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,9 @@ import {
|
||||
type HeartbeatResult,
|
||||
type HeartbeatAlert,
|
||||
} from '../lib/intelligence-client';
|
||||
import { createLogger } from '../lib/logger';
|
||||
|
||||
const log = createLogger('HeartbeatConfig');
|
||||
|
||||
// === Default Config ===
|
||||
|
||||
@@ -312,9 +315,15 @@ export function HeartbeatConfig({ className = '', onConfigChange }: HeartbeatCon
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const handleSave = useCallback(async () => {
|
||||
localStorage.setItem('zclaw-heartbeat-config', JSON.stringify(config));
|
||||
localStorage.setItem('zclaw-heartbeat-checks', JSON.stringify(checkItems));
|
||||
// Sync to Rust backend (non-blocking — UI updates immediately)
|
||||
try {
|
||||
await intelligenceClient.heartbeat.updateConfig('zclaw-main', config);
|
||||
} catch (err) {
|
||||
log.warn('[HeartbeatConfig] Backend sync failed:', err);
|
||||
}
|
||||
setHasChanges(false);
|
||||
}, [config, checkItems]);
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
Package,
|
||||
BarChart,
|
||||
Palette,
|
||||
HeartPulse,
|
||||
GraduationCap,
|
||||
Landmark,
|
||||
Scale,
|
||||
Server,
|
||||
Search,
|
||||
Megaphone,
|
||||
@@ -33,6 +37,10 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Package,
|
||||
BarChart,
|
||||
Palette,
|
||||
HeartPulse,
|
||||
GraduationCap,
|
||||
Landmark,
|
||||
Scale,
|
||||
Server,
|
||||
Search,
|
||||
Megaphone,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Credits() {
|
||||
const [filter, setFilter] = useState<'all' | 'consume' | 'earn'>('all');
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-xl font-bold text-gray-900">积分</h1>
|
||||
<div className="flex gap-2">
|
||||
<button className="text-xs text-gray-500 hover:text-gray-700 px-3 py-1.5 border border-gray-200 rounded-lg transition-colors">
|
||||
刷新
|
||||
</button>
|
||||
<button className="text-xs text-white bg-orange-500 hover:bg-orange-600 px-3 py-1.5 rounded-lg transition-colors">
|
||||
去充值
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-8 py-12">
|
||||
<div className="text-xs text-gray-500 mb-1">总积分</div>
|
||||
<div className="text-3xl font-bold text-gray-900">--</div>
|
||||
<div className="text-xs text-gray-400 mt-2">积分系统开发中</div>
|
||||
</div>
|
||||
|
||||
<div className="p-1 mb-6 flex rounded-lg bg-gray-50 border border-gray-100 shadow-sm">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`flex-1 py-2 rounded-md text-xs transition-colors ${filter === 'all' ? 'bg-white shadow-sm font-medium text-gray-900' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('consume')}
|
||||
className={`flex-1 py-2 rounded-md text-xs transition-colors ${filter === 'consume' ? 'bg-white shadow-sm font-medium text-gray-900' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
消耗
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('earn')}
|
||||
className={`flex-1 py-2 rounded-md text-xs transition-colors ${filter === 'earn' ? 'bg-white shadow-sm font-medium text-gray-900' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
获得
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||
<div className="text-sm text-gray-400">暂无积分记录</div>
|
||||
<div className="text-xs text-gray-300 mt-1">连接后端服务后即可查看积分使用记录</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,12 @@ import { useState } from 'react';
|
||||
import { useSecurityStore } from '../../store/securityStore';
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
BarChart3,
|
||||
Puzzle,
|
||||
MessageSquare,
|
||||
FolderOpen,
|
||||
Shield,
|
||||
Info,
|
||||
ArrowLeft,
|
||||
Coins,
|
||||
Cpu,
|
||||
Zap,
|
||||
HelpCircle,
|
||||
@@ -18,12 +16,12 @@ import {
|
||||
Heart,
|
||||
Key,
|
||||
Database,
|
||||
Activity,
|
||||
Cloud,
|
||||
CreditCard,
|
||||
} from 'lucide-react';
|
||||
import { silentErrorHandler } from '../../lib/error-utils';
|
||||
import { General } from './General';
|
||||
import { UsageStats } from './UsageStats';
|
||||
import { ModelsAPI } from './ModelsAPI';
|
||||
import { MCPServices } from './MCPServices';
|
||||
import { Skills } from './Skills';
|
||||
@@ -31,12 +29,12 @@ import { IMChannels } from './IMChannels';
|
||||
import { Workspace } from './Workspace';
|
||||
import { Privacy } from './Privacy';
|
||||
import { About } from './About';
|
||||
import { Credits } from './Credits';
|
||||
import { AuditLogsPanel } from '../AuditLogsPanel';
|
||||
import { SecurityStatus } from '../SecurityStatus';
|
||||
import { SecurityLayersPanel } from '../SecurityLayersPanel';
|
||||
import { TaskList } from '../TaskList';
|
||||
import { HeartbeatConfig } from '../HeartbeatConfig';
|
||||
import { HealthPanel } from '../HealthPanel';
|
||||
import { SecureStorage } from './SecureStorage';
|
||||
import { VikingPanel } from '../VikingPanel';
|
||||
import { SaaSSettings } from '../SaaS/SaaSSettings';
|
||||
@@ -49,8 +47,6 @@ interface SettingsLayoutProps {
|
||||
|
||||
type SettingsPage =
|
||||
| 'general'
|
||||
| 'usage'
|
||||
| 'credits'
|
||||
| 'models'
|
||||
| 'mcp'
|
||||
| 'skills'
|
||||
@@ -65,14 +61,13 @@ type SettingsPage =
|
||||
| 'audit'
|
||||
| 'tasks'
|
||||
| 'heartbeat'
|
||||
| 'health'
|
||||
| 'feedback'
|
||||
| 'about';
|
||||
|
||||
const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode; group?: 'advanced' }[] = [
|
||||
// --- Core settings ---
|
||||
{ id: 'general', label: '通用', icon: <SettingsIcon className="w-4 h-4" /> },
|
||||
{ id: 'usage', label: '用量统计', icon: <BarChart3 className="w-4 h-4" /> },
|
||||
{ id: 'credits', label: '积分详情', icon: <Coins className="w-4 h-4" /> },
|
||||
{ id: 'models', label: '模型与 API', icon: <Cpu className="w-4 h-4" /> },
|
||||
{ id: 'mcp', label: 'MCP 服务', icon: <Puzzle className="w-4 h-4" /> },
|
||||
{ id: 'im', label: 'IM 频道', icon: <MessageSquare className="w-4 h-4" /> },
|
||||
@@ -89,6 +84,7 @@ const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode; group
|
||||
{ id: 'audit', label: '审计日志', icon: <ClipboardList className="w-4 h-4" />, group: 'advanced' },
|
||||
{ id: 'tasks', label: '定时任务', icon: <Clock className="w-4 h-4" />, group: 'advanced' },
|
||||
{ id: 'heartbeat', label: '心跳配置', icon: <Heart className="w-4 h-4" />, group: 'advanced' },
|
||||
{ id: 'health', label: '系统健康', icon: <Activity className="w-4 h-4" />, group: 'advanced' },
|
||||
// --- Footer ---
|
||||
{ id: 'feedback', label: '提交反馈', icon: <HelpCircle className="w-4 h-4" /> },
|
||||
{ id: 'about', label: '关于', icon: <Info className="w-4 h-4" /> },
|
||||
@@ -101,8 +97,6 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
|
||||
const renderPage = () => {
|
||||
switch (activePage) {
|
||||
case 'general': return <General />;
|
||||
case 'usage': return <UsageStats />;
|
||||
case 'credits': return <Credits />;
|
||||
case 'models': return <ModelsAPI />;
|
||||
case 'mcp': return <MCPServices />;
|
||||
case 'skills': return <Skills />;
|
||||
@@ -175,6 +169,16 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
case 'health': return (
|
||||
<ErrorBoundary
|
||||
fallback={<div className="p-6 text-center text-gray-500">系统健康面板加载失败</div>}
|
||||
onError={(err, info) => console.error('[Settings] Health page error:', err, info.componentStack)}
|
||||
>
|
||||
<div className="max-w-3xl h-full">
|
||||
<HealthPanel />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
case 'viking': return (
|
||||
<ErrorBoundary
|
||||
fallback={<div className="p-6 text-center text-gray-500">语义记忆加载失败</div>}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAgentStore } from '../../store/agentStore';
|
||||
import { BarChart3, TrendingUp, Clock, Zap } from 'lucide-react';
|
||||
|
||||
export function UsageStats() {
|
||||
const usageStats = useAgentStore((s) => s.usageStats);
|
||||
const loadUsageStats = useAgentStore((s) => s.loadUsageStats);
|
||||
const [timeRange, setTimeRange] = useState<'7d' | '30d' | 'all'>('7d');
|
||||
|
||||
useEffect(() => {
|
||||
loadUsageStats();
|
||||
}, [loadUsageStats]);
|
||||
|
||||
const stats = usageStats || { totalSessions: 0, totalMessages: 0, totalTokens: 0, byModel: {} };
|
||||
const models = Object.entries(stats.byModel || {});
|
||||
|
||||
const formatTokens = (n: number) => {
|
||||
if (n >= 1_000_000) return `~${(n / 1_000_000).toFixed(1)} M`;
|
||||
if (n >= 1_000) return `~${(n / 1_000).toFixed(1)} k`;
|
||||
return `${n}`;
|
||||
};
|
||||
|
||||
// 计算总输入和输出 Token
|
||||
const totalInputTokens = models.reduce((sum, [_, data]) => sum + data.inputTokens, 0);
|
||||
const totalOutputTokens = models.reduce((sum, [_, data]) => sum + data.outputTokens, 0);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-xl font-bold text-gray-900">用量统计</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center bg-gray-100 rounded-lg p-0.5">
|
||||
{(['7d', '30d', 'all'] as const).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => setTimeRange(range)}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
timeRange === range
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{range === '7d' ? '近 7 天' : range === '30d' ? '近 30 天' : '全部'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => loadUsageStats()}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 px-3 py-1.5 border border-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-4">本设备所有已保存对话的使用统计。</div>
|
||||
|
||||
{/* 主要统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
icon={BarChart3}
|
||||
label="会话数"
|
||||
value={stats.totalSessions}
|
||||
color="text-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Zap}
|
||||
label="消息数"
|
||||
value={stats.totalMessages}
|
||||
color="text-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon={TrendingUp}
|
||||
label="输入 Token"
|
||||
value={formatTokens(totalInputTokens)}
|
||||
color="text-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label="输出 Token"
|
||||
value={formatTokens(totalOutputTokens)}
|
||||
color="text-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 总 Token 使用量概览 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm mb-6">
|
||||
<h3 className="text-sm font-semibold mb-4 text-gray-900">Token 使用概览</h3>
|
||||
{stats.totalTokens === 0 ? (
|
||||
<p className="text-xs text-gray-400">Token 用量将在后续版本中支持</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>输入</span>
|
||||
<span>输出</span>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-100 rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="bg-gradient-to-r from-green-400 to-green-500 h-full transition-all"
|
||||
style={{ width: `${(totalInputTokens / Math.max(totalInputTokens + totalOutputTokens, 1)) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-gradient-to-r from-orange-400 to-orange-500 h-full transition-all"
|
||||
style={{ width: `${(totalOutputTokens / Math.max(totalInputTokens + totalOutputTokens, 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="text-lg font-bold text-gray-900">{formatTokens(stats.totalTokens)}</div>
|
||||
<div className="text-xs text-gray-500">总计</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 按模型分组 */}
|
||||
<h2 className="text-sm font-semibold mb-4 text-gray-900">按模型</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100 shadow-sm">
|
||||
{models.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<BarChart3 className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">暂无使用数据</p>
|
||||
<p className="text-xs text-gray-300 mt-1">开始对话后将自动记录用量统计</p>
|
||||
</div>
|
||||
) : (
|
||||
models.map(([model, data]) => {
|
||||
const total = data.inputTokens + data.outputTokens;
|
||||
const inputPct = (data.inputTokens / Math.max(total, 1)) * 100;
|
||||
const outputPct = (data.outputTokens / Math.max(total, 1)) * 100;
|
||||
|
||||
return (
|
||||
<div key={model} className="p-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium text-gray-900">{model}</span>
|
||||
<span className="text-xs text-gray-500">{data.messages} 条消息</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden mb-2 flex">
|
||||
<div className="bg-orange-500 h-full" style={{ width: `${inputPct}%` }} />
|
||||
<div className="bg-orange-200 h-full" style={{ width: `${outputPct}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>输入: {formatTokens(data.inputTokens)}</span>
|
||||
<span>输出: {formatTokens(data.outputTokens)}</span>
|
||||
<span>总计: {formatTokens(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
icon: typeof BarChart3;
|
||||
label: string;
|
||||
value: string | number;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
<span className="text-xs text-gray-500">{label}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,11 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Settings, LayoutGrid,
|
||||
Settings, LayoutGrid, SquarePen,
|
||||
Search, X,
|
||||
} from 'lucide-react';
|
||||
import { ConversationList } from './ConversationList';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
|
||||
interface SimpleSidebarProps {
|
||||
onOpenSettings?: () => void;
|
||||
@@ -19,6 +20,11 @@ interface SimpleSidebarProps {
|
||||
|
||||
export function SimpleSidebar({ onOpenSettings, onToggleMode }: SimpleSidebarProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const newConversation = useChatStore((s) => s.newConversation);
|
||||
|
||||
const handleNewConversation = () => {
|
||||
newConversation();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-64 sidebar-bg border-r border-[#e8e6e1] dark:border-gray-800 flex flex-col h-full shrink-0">
|
||||
@@ -27,11 +33,26 @@ export function SimpleSidebar({ onOpenSettings, onToggleMode }: SimpleSidebarPro
|
||||
<span className="text-lg font-semibold tracking-tight bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent">
|
||||
ZCLAW
|
||||
</span>
|
||||
<button
|
||||
onClick={handleNewConversation}
|
||||
className="ml-auto p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-md transition-colors text-gray-600 dark:text-gray-400"
|
||||
title="新对话"
|
||||
>
|
||||
<SquarePen className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="p-2 h-full overflow-y-auto">
|
||||
{/* 新对话按钮 */}
|
||||
<button
|
||||
onClick={handleNewConversation}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-sm font-medium text-gray-900 dark:text-gray-100 hover:bg-black/10 dark:hover:bg-white/10 transition-colors mb-2"
|
||||
>
|
||||
<SquarePen className="w-4 h-4" />
|
||||
新对话
|
||||
</button>
|
||||
{/* 搜索框 */}
|
||||
<div className="relative mb-2">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
|
||||
@@ -1190,10 +1190,10 @@ export const intelligenceClient = {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('heartbeat.updateMemoryStats', () =>
|
||||
invoke('heartbeat_update_memory_stats', {
|
||||
agent_id: agentId,
|
||||
task_count: taskCount,
|
||||
total_entries: totalEntries,
|
||||
storage_size_bytes: storageSizeBytes,
|
||||
agentId: agentId,
|
||||
taskCount: taskCount,
|
||||
totalEntries: totalEntries,
|
||||
storageSizeBytes: storageSizeBytes,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
@@ -1212,8 +1212,8 @@ export const intelligenceClient = {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('heartbeat.recordCorrection', () =>
|
||||
invoke('heartbeat_record_correction', {
|
||||
agent_id: agentId,
|
||||
correction_type: correctionType,
|
||||
agentId: agentId,
|
||||
correctionType: correctionType,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
@@ -1230,7 +1230,7 @@ export const intelligenceClient = {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('heartbeat.recordInteraction', () =>
|
||||
invoke('heartbeat_record_interaction', {
|
||||
agent_id: agentId,
|
||||
agentId: agentId,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Intelligence Layer - LocalStorage Compactor Fallback
|
||||
*
|
||||
* Provides rule-based compaction for browser/dev environment.
|
||||
*/
|
||||
|
||||
import type { CompactableMessage, CompactionResult, CompactionCheck, CompactionConfig } from '../intelligence-backend';
|
||||
|
||||
export const fallbackCompactor = {
|
||||
async estimateTokens(text: string): Promise<number> {
|
||||
// Simple heuristic: ~4 chars per token for English, ~1.5 for CJK
|
||||
const cjkChars = (text.match(/[\u4e00-\u9fff\u3040-\u30ff]/g) ?? []).length;
|
||||
const otherChars = text.length - cjkChars;
|
||||
return Math.ceil(cjkChars * 1.5 + otherChars / 4);
|
||||
},
|
||||
|
||||
async estimateMessagesTokens(messages: CompactableMessage[]): Promise<number> {
|
||||
let total = 0;
|
||||
for (const m of messages) {
|
||||
total += await fallbackCompactor.estimateTokens(m.content);
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
async checkThreshold(
|
||||
messages: CompactableMessage[],
|
||||
config?: CompactionConfig
|
||||
): Promise<CompactionCheck> {
|
||||
const threshold = config?.soft_threshold_tokens ?? 15000;
|
||||
const currentTokens = await fallbackCompactor.estimateMessagesTokens(messages);
|
||||
|
||||
return {
|
||||
should_compact: currentTokens >= threshold,
|
||||
current_tokens: currentTokens,
|
||||
threshold,
|
||||
urgency: currentTokens >= (config?.hard_threshold_tokens ?? 20000) ? 'hard' :
|
||||
currentTokens >= threshold ? 'soft' : 'none',
|
||||
};
|
||||
},
|
||||
|
||||
async compact(
|
||||
messages: CompactableMessage[],
|
||||
_agentId: string,
|
||||
_conversationId?: string,
|
||||
config?: CompactionConfig
|
||||
): Promise<CompactionResult> {
|
||||
// Simple rule-based compaction: keep last N messages
|
||||
const keepRecent = config?.keep_recent_messages ?? 10;
|
||||
const retained = messages.slice(-keepRecent);
|
||||
|
||||
return {
|
||||
compacted_messages: retained,
|
||||
summary: `[Compacted ${messages.length - retained.length} earlier messages]`,
|
||||
original_count: messages.length,
|
||||
retained_count: retained.length,
|
||||
flushed_memories: 0,
|
||||
tokens_before_compaction: await fallbackCompactor.estimateMessagesTokens(messages),
|
||||
tokens_after_compaction: await fallbackCompactor.estimateMessagesTokens(retained),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Intelligence Layer - LocalStorage Heartbeat Fallback
|
||||
*
|
||||
* Provides no-op heartbeat for browser/dev environment.
|
||||
*/
|
||||
|
||||
import type { HeartbeatConfig, HeartbeatResult } from '../intelligence-backend';
|
||||
|
||||
export const fallbackHeartbeat = {
|
||||
_configs: new Map<string, HeartbeatConfig>(),
|
||||
|
||||
async init(agentId: string, config?: HeartbeatConfig): Promise<void> {
|
||||
if (config) {
|
||||
fallbackHeartbeat._configs.set(agentId, config);
|
||||
}
|
||||
},
|
||||
|
||||
async start(_agentId: string): Promise<void> {
|
||||
// No-op for fallback (no background tasks in browser)
|
||||
},
|
||||
|
||||
async stop(_agentId: string): Promise<void> {
|
||||
// No-op
|
||||
},
|
||||
|
||||
async tick(_agentId: string): Promise<HeartbeatResult> {
|
||||
return {
|
||||
status: 'ok',
|
||||
alerts: [],
|
||||
checked_items: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
async getConfig(agentId: string): Promise<HeartbeatConfig> {
|
||||
return fallbackHeartbeat._configs.get(agentId) ?? {
|
||||
enabled: false,
|
||||
interval_minutes: 30,
|
||||
quiet_hours_start: null,
|
||||
quiet_hours_end: null,
|
||||
notify_channel: 'ui',
|
||||
proactivity_level: 'standard',
|
||||
max_alerts_per_tick: 5,
|
||||
};
|
||||
},
|
||||
|
||||
async updateConfig(agentId: string, config: HeartbeatConfig): Promise<void> {
|
||||
fallbackHeartbeat._configs.set(agentId, config);
|
||||
},
|
||||
|
||||
async getHistory(_agentId: string, _limit?: number): Promise<HeartbeatResult[]> {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* Intelligence Layer - LocalStorage Identity Fallback
|
||||
*
|
||||
* Provides localStorage-based identity management for browser/dev environment.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../logger';
|
||||
|
||||
import type { IdentityFiles, IdentityChangeProposal, IdentitySnapshot } from '../intelligence-backend';
|
||||
|
||||
const logger = createLogger('intelligence-client');
|
||||
|
||||
const IDENTITY_STORAGE_KEY = 'zclaw-fallback-identities';
|
||||
const PROPOSALS_STORAGE_KEY = 'zclaw-fallback-proposals';
|
||||
const SNAPSHOTS_STORAGE_KEY = 'zclaw-fallback-snapshots';
|
||||
|
||||
function loadIdentitiesFromStorage(): Map<string, IdentityFiles> {
|
||||
try {
|
||||
const stored = localStorage.getItem(IDENTITY_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Record<string, IdentityFiles>;
|
||||
return new Map(Object.entries(parsed));
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to load identities from localStorage', { error: e });
|
||||
}
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function saveIdentitiesToStorage(identities: Map<string, IdentityFiles>): void {
|
||||
try {
|
||||
const obj = Object.fromEntries(identities);
|
||||
localStorage.setItem(IDENTITY_STORAGE_KEY, JSON.stringify(obj));
|
||||
} catch (e) {
|
||||
logger.warn('Failed to save identities to localStorage', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
function loadProposalsFromStorage(): IdentityChangeProposal[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(PROPOSALS_STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as IdentityChangeProposal[];
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to load proposals from localStorage', { error: e });
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveProposalsToStorage(proposals: IdentityChangeProposal[]): void {
|
||||
try {
|
||||
localStorage.setItem(PROPOSALS_STORAGE_KEY, JSON.stringify(proposals));
|
||||
} catch (e) {
|
||||
logger.warn('Failed to save proposals to localStorage', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
function loadSnapshotsFromStorage(): IdentitySnapshot[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(SNAPSHOTS_STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as IdentitySnapshot[];
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to load snapshots from localStorage', { error: e });
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveSnapshotsToStorage(snapshots: IdentitySnapshot[]): void {
|
||||
try {
|
||||
localStorage.setItem(SNAPSHOTS_STORAGE_KEY, JSON.stringify(snapshots));
|
||||
} catch (e) {
|
||||
logger.warn('Failed to save snapshots to localStorage', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
// Module-level state initialized from localStorage
|
||||
const fallbackIdentities = loadIdentitiesFromStorage();
|
||||
const fallbackProposals = loadProposalsFromStorage();
|
||||
let fallbackSnapshots = loadSnapshotsFromStorage();
|
||||
|
||||
export const fallbackIdentity = {
|
||||
async get(agentId: string): Promise<IdentityFiles> {
|
||||
if (!fallbackIdentities.has(agentId)) {
|
||||
const defaults: IdentityFiles = {
|
||||
soul: '# Agent Soul\n\nA helpful AI assistant.',
|
||||
instructions: '# Instructions\n\nBe helpful and concise.',
|
||||
user_profile: '# User Profile\n\nNo profile yet.',
|
||||
};
|
||||
fallbackIdentities.set(agentId, defaults);
|
||||
saveIdentitiesToStorage(fallbackIdentities);
|
||||
}
|
||||
return fallbackIdentities.get(agentId)!;
|
||||
},
|
||||
|
||||
async getFile(agentId: string, file: string): Promise<string> {
|
||||
const files = await fallbackIdentity.get(agentId);
|
||||
return files[file as keyof IdentityFiles] ?? '';
|
||||
},
|
||||
|
||||
async buildPrompt(agentId: string, memoryContext?: string): Promise<string> {
|
||||
const files = await fallbackIdentity.get(agentId);
|
||||
let prompt = `${files.soul}\n\n## Instructions\n${files.instructions}\n\n## User Profile\n${files.user_profile}`;
|
||||
if (memoryContext) {
|
||||
prompt += `\n\n## Memory Context\n${memoryContext}`;
|
||||
}
|
||||
return prompt;
|
||||
},
|
||||
|
||||
async updateUserProfile(agentId: string, content: string): Promise<void> {
|
||||
const files = await fallbackIdentity.get(agentId);
|
||||
files.user_profile = content;
|
||||
fallbackIdentities.set(agentId, files);
|
||||
saveIdentitiesToStorage(fallbackIdentities);
|
||||
},
|
||||
|
||||
async appendUserProfile(agentId: string, addition: string): Promise<void> {
|
||||
const files = await fallbackIdentity.get(agentId);
|
||||
files.user_profile += `\n\n${addition}`;
|
||||
fallbackIdentities.set(agentId, files);
|
||||
saveIdentitiesToStorage(fallbackIdentities);
|
||||
},
|
||||
|
||||
async proposeChange(
|
||||
agentId: string,
|
||||
file: 'soul' | 'instructions',
|
||||
suggestedContent: string,
|
||||
reason: string
|
||||
): Promise<IdentityChangeProposal> {
|
||||
const files = await fallbackIdentity.get(agentId);
|
||||
const proposal: IdentityChangeProposal = {
|
||||
id: `prop_${Date.now()}`,
|
||||
agent_id: agentId,
|
||||
file,
|
||||
reason,
|
||||
current_content: files[file] ?? '',
|
||||
suggested_content: suggestedContent,
|
||||
status: 'pending',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
fallbackProposals.push(proposal);
|
||||
saveProposalsToStorage(fallbackProposals);
|
||||
return proposal;
|
||||
},
|
||||
|
||||
async approveProposal(proposalId: string): Promise<IdentityFiles> {
|
||||
const proposal = fallbackProposals.find(p => p.id === proposalId);
|
||||
if (!proposal) throw new Error('Proposal not found');
|
||||
|
||||
const files = await fallbackIdentity.get(proposal.agent_id);
|
||||
|
||||
// Create snapshot before applying change
|
||||
const snapshot: IdentitySnapshot = {
|
||||
id: `snap_${Date.now()}`,
|
||||
agent_id: proposal.agent_id,
|
||||
files: { ...files },
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: `Before applying: ${proposal.reason}`,
|
||||
};
|
||||
fallbackSnapshots.unshift(snapshot);
|
||||
// Keep only last 20 snapshots per agent
|
||||
const agentSnapshots = fallbackSnapshots.filter(s => s.agent_id === proposal.agent_id);
|
||||
if (agentSnapshots.length > 20) {
|
||||
const toRemove = agentSnapshots.slice(20);
|
||||
fallbackSnapshots = fallbackSnapshots.filter(s => !toRemove.includes(s));
|
||||
}
|
||||
saveSnapshotsToStorage(fallbackSnapshots);
|
||||
|
||||
proposal.status = 'approved';
|
||||
files[proposal.file] = proposal.suggested_content;
|
||||
fallbackIdentities.set(proposal.agent_id, files);
|
||||
saveIdentitiesToStorage(fallbackIdentities);
|
||||
saveProposalsToStorage(fallbackProposals);
|
||||
return files;
|
||||
},
|
||||
|
||||
async rejectProposal(proposalId: string): Promise<void> {
|
||||
const proposal = fallbackProposals.find(p => p.id === proposalId);
|
||||
if (proposal) {
|
||||
proposal.status = 'rejected';
|
||||
saveProposalsToStorage(fallbackProposals);
|
||||
}
|
||||
},
|
||||
|
||||
async getPendingProposals(agentId?: string): Promise<IdentityChangeProposal[]> {
|
||||
return fallbackProposals.filter(p =>
|
||||
p.status === 'pending' && (!agentId || p.agent_id === agentId)
|
||||
);
|
||||
},
|
||||
|
||||
async updateFile(agentId: string, file: string, content: string): Promise<void> {
|
||||
const files = await fallbackIdentity.get(agentId);
|
||||
if (file in files) {
|
||||
// IdentityFiles has known properties, update safely
|
||||
const key = file as keyof IdentityFiles;
|
||||
if (key in files) {
|
||||
files[key] = content;
|
||||
fallbackIdentities.set(agentId, files);
|
||||
saveIdentitiesToStorage(fallbackIdentities);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async getSnapshots(agentId: string, limit?: number): Promise<IdentitySnapshot[]> {
|
||||
const agentSnapshots = fallbackSnapshots.filter(s => s.agent_id === agentId);
|
||||
return agentSnapshots.slice(0, limit ?? 10);
|
||||
},
|
||||
|
||||
async restoreSnapshot(agentId: string, snapshotId: string): Promise<void> {
|
||||
const snapshot = fallbackSnapshots.find(s => s.id === snapshotId && s.agent_id === agentId);
|
||||
if (!snapshot) throw new Error('Snapshot not found');
|
||||
|
||||
// Create a snapshot of current state before restore
|
||||
const currentFiles = await fallbackIdentity.get(agentId);
|
||||
const beforeRestoreSnapshot: IdentitySnapshot = {
|
||||
id: `snap_${Date.now()}`,
|
||||
agent_id: agentId,
|
||||
files: { ...currentFiles },
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: 'Auto-backup before restore',
|
||||
};
|
||||
fallbackSnapshots.unshift(beforeRestoreSnapshot);
|
||||
saveSnapshotsToStorage(fallbackSnapshots);
|
||||
|
||||
// Restore the snapshot
|
||||
fallbackIdentities.set(agentId, { ...snapshot.files });
|
||||
saveIdentitiesToStorage(fallbackIdentities);
|
||||
},
|
||||
|
||||
async listAgents(): Promise<string[]> {
|
||||
return Array.from(fallbackIdentities.keys());
|
||||
},
|
||||
|
||||
async deleteAgent(agentId: string): Promise<void> {
|
||||
fallbackIdentities.delete(agentId);
|
||||
},
|
||||
};
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* Intelligence Layer - LocalStorage Memory Fallback
|
||||
*
|
||||
* Provides localStorage-based memory operations for browser/dev environment.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../logger';
|
||||
import { generateRandomString } from '../crypto-utils';
|
||||
|
||||
import type { MemoryEntry, MemorySearchOptions, MemoryStats, MemoryType, MemorySource } from './types';
|
||||
|
||||
const logger = createLogger('intelligence-client');
|
||||
|
||||
import type { MemoryEntryInput } from '../intelligence-backend';
|
||||
|
||||
const FALLBACK_STORAGE_KEY = 'zclaw-intelligence-fallback';
|
||||
|
||||
interface FallbackMemoryStore {
|
||||
memories: MemoryEntry[];
|
||||
}
|
||||
|
||||
function getFallbackStore(): FallbackMemoryStore {
|
||||
try {
|
||||
const stored = localStorage.getItem(FALLBACK_STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug('Failed to read fallback store from localStorage', { error: e });
|
||||
}
|
||||
return { memories: [] };
|
||||
}
|
||||
|
||||
function saveFallbackStore(store: FallbackMemoryStore): void {
|
||||
try {
|
||||
localStorage.setItem(FALLBACK_STORAGE_KEY, JSON.stringify(store));
|
||||
} catch (e) {
|
||||
logger.warn('Failed to save fallback store to localStorage', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
export const fallbackMemory = {
|
||||
async init(): Promise<void> {
|
||||
// No-op for localStorage
|
||||
},
|
||||
|
||||
async store(entry: MemoryEntryInput): Promise<string> {
|
||||
const store = getFallbackStore();
|
||||
|
||||
// Content-based deduplication: update existing entry with same agentId + content
|
||||
const normalizedContent = entry.content.trim().toLowerCase();
|
||||
const existingIdx = store.memories.findIndex(
|
||||
m => m.agentId === entry.agent_id && m.content.trim().toLowerCase() === normalizedContent
|
||||
);
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
// Update existing entry instead of creating duplicate
|
||||
const existing = store.memories[existingIdx];
|
||||
store.memories[existingIdx] = {
|
||||
...existing,
|
||||
importance: Math.max(existing.importance, entry.importance ?? 5),
|
||||
lastAccessedAt: new Date().toISOString(),
|
||||
accessCount: existing.accessCount + 1,
|
||||
tags: [...new Set([...existing.tags, ...(entry.tags ?? [])])],
|
||||
};
|
||||
saveFallbackStore(store);
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const id = `mem_${Date.now()}_${generateRandomString(6)}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const memory: MemoryEntry = {
|
||||
id,
|
||||
agentId: entry.agent_id,
|
||||
content: entry.content,
|
||||
type: entry.memory_type as MemoryType,
|
||||
importance: entry.importance ?? 5,
|
||||
source: (entry.source as MemorySource) ?? 'auto',
|
||||
tags: entry.tags ?? [],
|
||||
createdAt: now,
|
||||
lastAccessedAt: now,
|
||||
accessCount: 0,
|
||||
conversationId: entry.conversation_id,
|
||||
};
|
||||
|
||||
store.memories.push(memory);
|
||||
saveFallbackStore(store);
|
||||
return id;
|
||||
},
|
||||
|
||||
async get(id: string): Promise<MemoryEntry | null> {
|
||||
const store = getFallbackStore();
|
||||
return store.memories.find(m => m.id === id) ?? null;
|
||||
},
|
||||
|
||||
async search(options: MemorySearchOptions): Promise<MemoryEntry[]> {
|
||||
const store = getFallbackStore();
|
||||
let results = store.memories;
|
||||
|
||||
if (options.agentId) {
|
||||
results = results.filter(m => m.agentId === options.agentId);
|
||||
}
|
||||
if (options.type) {
|
||||
results = results.filter(m => m.type === options.type);
|
||||
}
|
||||
if (options.minImportance !== undefined) {
|
||||
results = results.filter(m => m.importance >= options.minImportance!);
|
||||
}
|
||||
if (options.query) {
|
||||
const queryLower = options.query.toLowerCase();
|
||||
results = results.filter(m =>
|
||||
m.content.toLowerCase().includes(queryLower) ||
|
||||
m.tags.some(t => t.toLowerCase().includes(queryLower))
|
||||
);
|
||||
}
|
||||
if (options.limit) {
|
||||
results = results.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const store = getFallbackStore();
|
||||
store.memories = store.memories.filter(m => m.id !== id);
|
||||
saveFallbackStore(store);
|
||||
},
|
||||
|
||||
async deleteAll(agentId: string): Promise<number> {
|
||||
const store = getFallbackStore();
|
||||
const before = store.memories.length;
|
||||
store.memories = store.memories.filter(m => m.agentId !== agentId);
|
||||
saveFallbackStore(store);
|
||||
return before - store.memories.length;
|
||||
},
|
||||
|
||||
async stats(): Promise<MemoryStats> {
|
||||
const store = getFallbackStore();
|
||||
const byType: Record<string, number> = {};
|
||||
const byAgent: Record<string, number> = {};
|
||||
|
||||
for (const m of store.memories) {
|
||||
byType[m.type] = (byType[m.type] ?? 0) + 1;
|
||||
byAgent[m.agentId] = (byAgent[m.agentId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
const sorted = [...store.memories].sort((a, b) =>
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
|
||||
// Estimate storage size from serialized data
|
||||
let storageSizeBytes = 0;
|
||||
try {
|
||||
const serialized = JSON.stringify(store.memories);
|
||||
storageSizeBytes = new Blob([serialized]).size;
|
||||
} catch (e) {
|
||||
logger.debug('Failed to estimate storage size', { error: e });
|
||||
}
|
||||
|
||||
return {
|
||||
totalEntries: store.memories.length,
|
||||
byType,
|
||||
byAgent,
|
||||
oldestEntry: sorted[0]?.createdAt ?? null,
|
||||
newestEntry: sorted[sorted.length - 1]?.createdAt ?? null,
|
||||
storageSizeBytes,
|
||||
};
|
||||
},
|
||||
|
||||
async export(): Promise<MemoryEntry[]> {
|
||||
const store = getFallbackStore();
|
||||
return store.memories;
|
||||
},
|
||||
|
||||
async import(memories: MemoryEntry[]): Promise<number> {
|
||||
const store = getFallbackStore();
|
||||
store.memories.push(...memories);
|
||||
saveFallbackStore(store);
|
||||
return memories.length;
|
||||
},
|
||||
|
||||
async dbPath(): Promise<string> {
|
||||
return 'localStorage://zclaw-intelligence-fallback';
|
||||
},
|
||||
};
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* Intelligence Layer - LocalStorage Reflection Fallback
|
||||
*
|
||||
* Provides rule-based reflection for browser/dev environment.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ReflectionResult,
|
||||
ReflectionState,
|
||||
ReflectionConfig,
|
||||
PatternObservation,
|
||||
ImprovementSuggestion,
|
||||
ReflectionIdentityProposal,
|
||||
MemoryEntryForAnalysis,
|
||||
} from '../intelligence-backend';
|
||||
|
||||
export const fallbackReflection = {
|
||||
_conversationCount: 0,
|
||||
_lastReflection: null as string | null,
|
||||
_history: [] as ReflectionResult[],
|
||||
|
||||
async init(_config?: ReflectionConfig): Promise<void> {
|
||||
// No-op
|
||||
},
|
||||
|
||||
async recordConversation(): Promise<void> {
|
||||
fallbackReflection._conversationCount++;
|
||||
},
|
||||
|
||||
async shouldReflect(): Promise<boolean> {
|
||||
return fallbackReflection._conversationCount >= 5;
|
||||
},
|
||||
|
||||
async reflect(agentId: string, memories: MemoryEntryForAnalysis[]): Promise<ReflectionResult> {
|
||||
fallbackReflection._conversationCount = 0;
|
||||
fallbackReflection._lastReflection = new Date().toISOString();
|
||||
|
||||
// Analyze patterns (simple rule-based implementation)
|
||||
const patterns: PatternObservation[] = [];
|
||||
const improvements: ImprovementSuggestion[] = [];
|
||||
const identityProposals: ReflectionIdentityProposal[] = [];
|
||||
|
||||
// Count memory types
|
||||
const typeCounts: Record<string, number> = {};
|
||||
for (const m of memories) {
|
||||
typeCounts[m.memory_type] = (typeCounts[m.memory_type] || 0) + 1;
|
||||
}
|
||||
|
||||
// Pattern: Too many tasks
|
||||
const taskCount = typeCounts['task'] || 0;
|
||||
if (taskCount >= 5) {
|
||||
const taskMemories = memories.filter(m => m.memory_type === 'task').slice(0, 3);
|
||||
patterns.push({
|
||||
observation: `积累了 ${taskCount} 个待办任务,可能存在任务管理不善`,
|
||||
frequency: taskCount,
|
||||
sentiment: 'negative',
|
||||
evidence: taskMemories.map(m => m.content),
|
||||
});
|
||||
improvements.push({
|
||||
area: '任务管理',
|
||||
suggestion: '清理已完成的任务记忆,对长期未处理的任务降低重要性',
|
||||
priority: 'high',
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern: Strong preference accumulation
|
||||
const prefCount = typeCounts['preference'] || 0;
|
||||
if (prefCount >= 5) {
|
||||
const prefMemories = memories.filter(m => m.memory_type === 'preference').slice(0, 3);
|
||||
patterns.push({
|
||||
observation: `已记录 ${prefCount} 个用户偏好,对用户习惯有较好理解`,
|
||||
frequency: prefCount,
|
||||
sentiment: 'positive',
|
||||
evidence: prefMemories.map(m => m.content),
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern: Lessons learned
|
||||
const lessonCount = typeCounts['lesson'] || 0;
|
||||
if (lessonCount >= 5) {
|
||||
patterns.push({
|
||||
observation: `积累了 ${lessonCount} 条经验教训,知识库在成长`,
|
||||
frequency: lessonCount,
|
||||
sentiment: 'positive',
|
||||
evidence: memories.filter(m => m.memory_type === 'lesson').slice(0, 3).map(m => m.content),
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern: High-access important memories
|
||||
const highAccessMemories = memories.filter(m => m.access_count >= 5 && m.importance >= 7);
|
||||
if (highAccessMemories.length >= 3) {
|
||||
patterns.push({
|
||||
observation: `有 ${highAccessMemories.length} 条高频访问的重要记忆,核心知识正在形成`,
|
||||
frequency: highAccessMemories.length,
|
||||
sentiment: 'positive',
|
||||
evidence: highAccessMemories.slice(0, 3).map(m => m.content),
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern: Low importance memories accumulating
|
||||
const lowImportanceCount = memories.filter(m => m.importance <= 3).length;
|
||||
if (lowImportanceCount > 20) {
|
||||
patterns.push({
|
||||
observation: `有 ${lowImportanceCount} 条低重要性记忆,建议清理`,
|
||||
frequency: lowImportanceCount,
|
||||
sentiment: 'neutral',
|
||||
evidence: [],
|
||||
});
|
||||
improvements.push({
|
||||
area: '记忆管理',
|
||||
suggestion: '执行记忆清理,移除30天以上未访问且重要性低于3的记忆',
|
||||
priority: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
// Generate identity proposal if negative patterns exist
|
||||
const negativePatterns = patterns.filter(p => p.sentiment === 'negative');
|
||||
if (negativePatterns.length >= 2) {
|
||||
const additions = negativePatterns.map(p => `- 注意: ${p.observation}`).join('\n');
|
||||
identityProposals.push({
|
||||
agent_id: agentId,
|
||||
field: 'instructions',
|
||||
current_value: '...',
|
||||
proposed_value: `\n\n## 自我反思改进\n${additions}`,
|
||||
reason: `基于 ${negativePatterns.length} 个负面模式观察,建议在指令中增加自我改进提醒`,
|
||||
});
|
||||
}
|
||||
|
||||
// Suggestion: User profile enrichment
|
||||
if (prefCount < 3) {
|
||||
improvements.push({
|
||||
area: '用户理解',
|
||||
suggestion: '主动在对话中了解用户偏好(沟通风格、技术栈、工作习惯),丰富用户画像',
|
||||
priority: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
const result: ReflectionResult = {
|
||||
patterns,
|
||||
improvements,
|
||||
identity_proposals: identityProposals,
|
||||
new_memories: patterns.filter(p => p.frequency >= 3).length + improvements.filter(i => i.priority === 'high').length,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Store in history
|
||||
fallbackReflection._history.push(result);
|
||||
if (fallbackReflection._history.length > 20) {
|
||||
fallbackReflection._history = fallbackReflection._history.slice(-10);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async getHistory(limit?: number, _agentId?: string): Promise<ReflectionResult[]> {
|
||||
const l = limit ?? 10;
|
||||
return fallbackReflection._history.slice(-l).reverse();
|
||||
},
|
||||
|
||||
async getState(): Promise<ReflectionState> {
|
||||
return {
|
||||
conversations_since_reflection: fallbackReflection._conversationCount,
|
||||
last_reflection_time: fallbackReflection._lastReflection,
|
||||
last_reflection_agent_id: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Intelligence Layer - Barrel Re-export
|
||||
*
|
||||
* Re-exports everything from sub-modules to maintain backward compatibility.
|
||||
* Existing imports like `import { intelligenceClient } from './intelligence-client'`
|
||||
* continue to work unchanged because TypeScript resolves directory imports
|
||||
* through this index.ts file.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
MemoryType,
|
||||
MemorySource,
|
||||
MemoryEntry,
|
||||
MemorySearchOptions,
|
||||
MemoryStats,
|
||||
BehaviorPattern,
|
||||
PatternTypeVariant,
|
||||
PatternContext,
|
||||
WorkflowRecommendation,
|
||||
MeshConfig,
|
||||
MeshAnalysisResult,
|
||||
ActivityType,
|
||||
EvolutionChangeType,
|
||||
InsightCategory,
|
||||
IdentityFileType,
|
||||
ProposalStatus,
|
||||
EvolutionProposal,
|
||||
ProfileUpdate,
|
||||
EvolutionInsight,
|
||||
EvolutionResult,
|
||||
PersonaEvolverConfig,
|
||||
PersonaEvolverState,
|
||||
} from './types';
|
||||
|
||||
export {
|
||||
getPatternTypeString,
|
||||
} from './types';
|
||||
|
||||
// Re-exported types from intelligence-backend
|
||||
export type {
|
||||
HeartbeatConfig,
|
||||
HeartbeatResult,
|
||||
HeartbeatAlert,
|
||||
CompactableMessage,
|
||||
CompactionResult,
|
||||
CompactionCheck,
|
||||
CompactionConfig,
|
||||
PatternObservation,
|
||||
ImprovementSuggestion,
|
||||
ReflectionResult,
|
||||
ReflectionState,
|
||||
ReflectionConfig,
|
||||
ReflectionIdentityProposal,
|
||||
IdentityFiles,
|
||||
IdentityChangeProposal,
|
||||
IdentitySnapshot,
|
||||
MemoryEntryForAnalysis,
|
||||
} from './types';
|
||||
|
||||
// Type conversion utilities
|
||||
export {
|
||||
toFrontendMemory,
|
||||
toBackendMemoryInput,
|
||||
toBackendSearchOptions,
|
||||
toFrontendStats,
|
||||
parseTags,
|
||||
} from './type-conversions';
|
||||
|
||||
// Unified client
|
||||
export { intelligenceClient } from './unified-client';
|
||||
export { intelligenceClient as default } from './unified-client';
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* Intelligence Layer - Type Conversion Utilities
|
||||
*
|
||||
* Functions for converting between frontend and backend data formats.
|
||||
*/
|
||||
|
||||
import { intelligence } from '../intelligence-backend';
|
||||
import type {
|
||||
MemoryEntryInput,
|
||||
PersistentMemory,
|
||||
MemorySearchOptions as BackendSearchOptions,
|
||||
MemoryStats as BackendMemoryStats,
|
||||
} from '../intelligence-backend';
|
||||
|
||||
import { createLogger } from '../logger';
|
||||
|
||||
import type { MemoryEntry, MemorySearchOptions, MemoryStats, MemoryType, MemorySource } from './types';
|
||||
|
||||
const logger = createLogger('intelligence-client');
|
||||
|
||||
// Re-import intelligence for use in conversions (already imported above but
|
||||
// the `intelligence` binding is needed by unified-client.ts indirectly).
|
||||
|
||||
export { intelligence };
|
||||
export type { MemoryEntryInput, PersistentMemory, BackendSearchOptions, BackendMemoryStats };
|
||||
|
||||
/**
|
||||
* Convert backend PersistentMemory to frontend MemoryEntry format
|
||||
*/
|
||||
export function toFrontendMemory(backend: PersistentMemory): MemoryEntry {
|
||||
return {
|
||||
id: backend.id,
|
||||
agentId: backend.agent_id,
|
||||
content: backend.content,
|
||||
type: backend.memory_type as MemoryType,
|
||||
importance: backend.importance,
|
||||
source: backend.source as MemorySource,
|
||||
tags: parseTags(backend.tags),
|
||||
createdAt: backend.created_at,
|
||||
lastAccessedAt: backend.last_accessed_at,
|
||||
accessCount: backend.access_count,
|
||||
conversationId: backend.conversation_id ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert frontend MemoryEntry to backend MemoryEntryInput format
|
||||
*/
|
||||
export function toBackendMemoryInput(entry: Omit<MemoryEntry, 'id' | 'createdAt' | 'lastAccessedAt' | 'accessCount'>): MemoryEntryInput {
|
||||
return {
|
||||
agent_id: entry.agentId,
|
||||
memory_type: entry.type,
|
||||
content: entry.content,
|
||||
importance: entry.importance,
|
||||
source: entry.source,
|
||||
tags: entry.tags,
|
||||
conversation_id: entry.conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert frontend search options to backend format
|
||||
*/
|
||||
export function toBackendSearchOptions(options: MemorySearchOptions): BackendSearchOptions {
|
||||
return {
|
||||
agent_id: options.agentId,
|
||||
memory_type: options.type,
|
||||
tags: options.tags,
|
||||
query: options.query,
|
||||
limit: options.limit,
|
||||
min_importance: options.minImportance,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert backend stats to frontend format
|
||||
*/
|
||||
export function toFrontendStats(backend: BackendMemoryStats): MemoryStats {
|
||||
return {
|
||||
totalEntries: backend.total_entries,
|
||||
byType: backend.by_type,
|
||||
byAgent: backend.by_agent,
|
||||
oldestEntry: backend.oldest_entry,
|
||||
newestEntry: backend.newest_entry,
|
||||
storageSizeBytes: backend.storage_size_bytes ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tags from backend (JSON string or array)
|
||||
*/
|
||||
export function parseTags(tags: string | string[]): string[] {
|
||||
if (Array.isArray(tags)) return tags;
|
||||
if (!tags) return [];
|
||||
try {
|
||||
return JSON.parse(tags);
|
||||
} catch (e) {
|
||||
logger.debug('JSON parse failed for tags, using fallback', { error: e });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* Intelligence Layer - Type Definitions
|
||||
*
|
||||
* All frontend types, mesh types, persona evolver types,
|
||||
* and re-exports from intelligence-backend.
|
||||
*/
|
||||
|
||||
// === Re-export types from intelligence-backend ===
|
||||
|
||||
export type {
|
||||
HeartbeatConfig,
|
||||
HeartbeatResult,
|
||||
HeartbeatAlert,
|
||||
CompactableMessage,
|
||||
CompactionResult,
|
||||
CompactionCheck,
|
||||
CompactionConfig,
|
||||
PatternObservation,
|
||||
ImprovementSuggestion,
|
||||
ReflectionResult,
|
||||
ReflectionState,
|
||||
ReflectionConfig,
|
||||
ReflectionIdentityProposal,
|
||||
IdentityFiles,
|
||||
IdentityChangeProposal,
|
||||
IdentitySnapshot,
|
||||
MemoryEntryForAnalysis,
|
||||
} from '../intelligence-backend';
|
||||
|
||||
// === Frontend Types (for backward compatibility) ===
|
||||
|
||||
export type MemoryType = 'fact' | 'preference' | 'lesson' | 'context' | 'task';
|
||||
export type MemorySource = 'auto' | 'user' | 'reflection' | 'llm-reflection';
|
||||
|
||||
export interface MemoryEntry {
|
||||
id: string;
|
||||
agentId: string;
|
||||
content: string;
|
||||
type: MemoryType;
|
||||
importance: number;
|
||||
source: MemorySource;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
lastAccessedAt: string;
|
||||
accessCount: number;
|
||||
conversationId?: string;
|
||||
}
|
||||
|
||||
export interface MemorySearchOptions {
|
||||
agentId?: string;
|
||||
type?: MemoryType;
|
||||
types?: MemoryType[];
|
||||
tags?: string[];
|
||||
query?: string;
|
||||
limit?: number;
|
||||
minImportance?: number;
|
||||
}
|
||||
|
||||
export interface MemoryStats {
|
||||
totalEntries: number;
|
||||
byType: Record<string, number>;
|
||||
byAgent: Record<string, number>;
|
||||
oldestEntry: string | null;
|
||||
newestEntry: string | null;
|
||||
storageSizeBytes: number;
|
||||
}
|
||||
|
||||
// === Mesh Types ===
|
||||
|
||||
export type PatternTypeVariant =
|
||||
| { type: 'SkillCombination'; skill_ids: string[] }
|
||||
| { type: 'TemporalTrigger'; hand_id: string; time_pattern: string }
|
||||
| { type: 'TaskPipelineMapping'; task_type: string; pipeline_id: string }
|
||||
| { type: 'InputPattern'; keywords: string[]; intent: string };
|
||||
|
||||
export interface BehaviorPattern {
|
||||
id: string;
|
||||
pattern_type: PatternTypeVariant;
|
||||
frequency: number;
|
||||
last_occurrence: string;
|
||||
first_occurrence: string;
|
||||
confidence: number;
|
||||
context: PatternContext;
|
||||
}
|
||||
|
||||
export function getPatternTypeString(patternType: PatternTypeVariant): string {
|
||||
if (typeof patternType === 'string') {
|
||||
return patternType;
|
||||
}
|
||||
return patternType.type;
|
||||
}
|
||||
|
||||
export interface PatternContext {
|
||||
skill_ids?: string[];
|
||||
recent_topics?: string[];
|
||||
intent?: string;
|
||||
time_of_day?: number;
|
||||
day_of_week?: number;
|
||||
}
|
||||
|
||||
export interface WorkflowRecommendation {
|
||||
id: string;
|
||||
pipeline_id: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
suggested_inputs: Record<string, unknown>;
|
||||
patterns_matched: string[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface MeshConfig {
|
||||
enabled: boolean;
|
||||
min_confidence: number;
|
||||
max_recommendations: number;
|
||||
analysis_window_hours: number;
|
||||
}
|
||||
|
||||
export interface MeshAnalysisResult {
|
||||
recommendations: WorkflowRecommendation[];
|
||||
patterns_detected: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export type ActivityType =
|
||||
| { type: 'skill_used'; skill_ids: string[] }
|
||||
| { type: 'pipeline_executed'; task_type: string; pipeline_id: string }
|
||||
| { type: 'input_received'; keywords: string[]; intent: string };
|
||||
|
||||
// === Persona Evolver Types ===
|
||||
|
||||
export type EvolutionChangeType =
|
||||
| 'instruction_addition'
|
||||
| 'instruction_refinement'
|
||||
| 'trait_addition'
|
||||
| 'style_adjustment'
|
||||
| 'domain_expansion';
|
||||
|
||||
export type InsightCategory =
|
||||
| 'communication_style'
|
||||
| 'technical_expertise'
|
||||
| 'task_efficiency'
|
||||
| 'user_preference'
|
||||
| 'knowledge_gap';
|
||||
|
||||
export type IdentityFileType = 'soul' | 'instructions';
|
||||
export type ProposalStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
export interface EvolutionProposal {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
target_file: IdentityFileType;
|
||||
change_type: EvolutionChangeType;
|
||||
reason: string;
|
||||
current_content: string;
|
||||
proposed_content: string;
|
||||
confidence: number;
|
||||
evidence: string[];
|
||||
status: ProposalStatus;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ProfileUpdate {
|
||||
section: string;
|
||||
previous: string;
|
||||
updated: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface EvolutionInsight {
|
||||
category: InsightCategory;
|
||||
observation: string;
|
||||
recommendation: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface EvolutionResult {
|
||||
agent_id: string;
|
||||
timestamp: string;
|
||||
profile_updates: ProfileUpdate[];
|
||||
proposals: EvolutionProposal[];
|
||||
insights: EvolutionInsight[];
|
||||
evolved: boolean;
|
||||
}
|
||||
|
||||
export interface PersonaEvolverConfig {
|
||||
auto_profile_update: boolean;
|
||||
min_preferences_for_update: number;
|
||||
min_conversations_for_evolution: number;
|
||||
enable_instruction_refinement: boolean;
|
||||
enable_soul_evolution: boolean;
|
||||
max_proposals_per_cycle: number;
|
||||
}
|
||||
|
||||
export interface PersonaEvolverState {
|
||||
last_evolution: string | null;
|
||||
total_evolutions: number;
|
||||
pending_proposals: number;
|
||||
profile_enrichment_score: number;
|
||||
}
|
||||
@@ -1,561 +0,0 @@
|
||||
/**
|
||||
* Intelligence Layer Unified Client
|
||||
*
|
||||
* Provides a unified API for intelligence operations that:
|
||||
* - Uses Rust backend (via Tauri commands) when running in Tauri environment
|
||||
* - Falls back to localStorage-based implementation in browser/dev environment
|
||||
*
|
||||
* Degradation strategy:
|
||||
* - In Tauri mode: if a Tauri invoke fails, the error is logged and re-thrown.
|
||||
* The caller is responsible for handling the error. We do NOT silently fall
|
||||
* back to localStorage, because that would give users degraded functionality
|
||||
* (localStorage instead of SQLite, rule-based instead of LLM-based, no-op
|
||||
* instead of real execution) without any indication that something is wrong.
|
||||
* - In browser/dev mode: localStorage fallback is the intended behavior for
|
||||
* development and testing without a Tauri backend.
|
||||
*
|
||||
* This replaces direct usage of:
|
||||
* - agent-memory.ts
|
||||
* - heartbeat-engine.ts
|
||||
* - context-compactor.ts
|
||||
* - reflection-engine.ts
|
||||
* - agent-identity.ts
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* import { intelligenceClient, toFrontendMemory, toBackendMemoryInput } from './intelligence-client';
|
||||
*
|
||||
* // Store memory
|
||||
* const id = await intelligenceClient.memory.store({
|
||||
* agent_id: 'agent-1',
|
||||
* memory_type: 'fact',
|
||||
* content: 'User prefers concise responses',
|
||||
* importance: 7,
|
||||
* });
|
||||
*
|
||||
* // Search memories
|
||||
* const memories = await intelligenceClient.memory.search({
|
||||
* agent_id: 'agent-1',
|
||||
* query: 'user preference',
|
||||
* limit: 10,
|
||||
* });
|
||||
*
|
||||
* // Convert to frontend format if needed
|
||||
* const frontendMemories = memories.map(toFrontendMemory);
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import { isTauriRuntime } from '../tauri-gateway';
|
||||
import { intelligence } from './type-conversions';
|
||||
import type { PersistentMemory } from '../intelligence-backend';
|
||||
import type {
|
||||
HeartbeatConfig,
|
||||
HeartbeatResult,
|
||||
CompactableMessage,
|
||||
CompactionResult,
|
||||
CompactionCheck,
|
||||
CompactionConfig,
|
||||
ReflectionConfig,
|
||||
ReflectionResult,
|
||||
ReflectionState,
|
||||
MemoryEntryForAnalysis,
|
||||
IdentityFiles,
|
||||
IdentityChangeProposal,
|
||||
IdentitySnapshot,
|
||||
} from '../intelligence-backend';
|
||||
|
||||
import type { MemoryEntry, MemorySearchOptions, MemoryStats } from './types';
|
||||
import { toFrontendMemory, toBackendSearchOptions, toFrontendStats } from './type-conversions';
|
||||
import { fallbackMemory } from './fallback-memory';
|
||||
import { fallbackCompactor } from './fallback-compactor';
|
||||
import { fallbackReflection } from './fallback-reflection';
|
||||
import { fallbackIdentity } from './fallback-identity';
|
||||
import { fallbackHeartbeat } from './fallback-heartbeat';
|
||||
|
||||
/**
|
||||
* Helper: wrap a Tauri invoke call so that failures are logged and re-thrown
|
||||
* instead of silently falling back to localStorage implementations.
|
||||
*/
|
||||
function tauriInvoke<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
return fn().catch((e: unknown) => {
|
||||
console.warn(`[IntelligenceClient] Tauri invoke failed (${label}):`, e);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified intelligence client that automatically selects backend or fallback.
|
||||
*
|
||||
* - In Tauri mode: calls Rust backend via invoke(). On failure, logs a warning
|
||||
* and re-throws -- does NOT fall back to localStorage.
|
||||
* - In browser/dev mode: uses localStorage-based fallback implementations.
|
||||
*/
|
||||
export const intelligenceClient = {
|
||||
memory: {
|
||||
init: async (): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('memory.init', () => intelligence.memory.init());
|
||||
} else {
|
||||
await fallbackMemory.init();
|
||||
}
|
||||
},
|
||||
|
||||
store: async (entry: import('../intelligence-backend').MemoryEntryInput): Promise<string> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('memory.store', () => intelligence.memory.store(entry));
|
||||
}
|
||||
return fallbackMemory.store(entry);
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<MemoryEntry | null> => {
|
||||
if (isTauriRuntime()) {
|
||||
const result = await tauriInvoke('memory.get', () => intelligence.memory.get(id));
|
||||
return result ? toFrontendMemory(result) : null;
|
||||
}
|
||||
return fallbackMemory.get(id);
|
||||
},
|
||||
|
||||
search: async (options: MemorySearchOptions): Promise<MemoryEntry[]> => {
|
||||
if (isTauriRuntime()) {
|
||||
const results = await tauriInvoke('memory.search', () =>
|
||||
intelligence.memory.search(toBackendSearchOptions(options))
|
||||
);
|
||||
return results.map(toFrontendMemory);
|
||||
}
|
||||
return fallbackMemory.search(options);
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('memory.delete', () => intelligence.memory.delete(id));
|
||||
} else {
|
||||
await fallbackMemory.delete(id);
|
||||
}
|
||||
},
|
||||
|
||||
deleteAll: async (agentId: string): Promise<number> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('memory.deleteAll', () => intelligence.memory.deleteAll(agentId));
|
||||
}
|
||||
return fallbackMemory.deleteAll(agentId);
|
||||
},
|
||||
|
||||
stats: async (): Promise<MemoryStats> => {
|
||||
if (isTauriRuntime()) {
|
||||
const stats = await tauriInvoke('memory.stats', () => intelligence.memory.stats());
|
||||
return toFrontendStats(stats);
|
||||
}
|
||||
return fallbackMemory.stats();
|
||||
},
|
||||
|
||||
export: async (): Promise<MemoryEntry[]> => {
|
||||
if (isTauriRuntime()) {
|
||||
const results = await tauriInvoke('memory.export', () => intelligence.memory.export());
|
||||
return results.map(toFrontendMemory);
|
||||
}
|
||||
return fallbackMemory.export();
|
||||
},
|
||||
|
||||
import: async (memories: MemoryEntry[]): Promise<number> => {
|
||||
if (isTauriRuntime()) {
|
||||
const backendMemories = memories.map(m => ({
|
||||
...m,
|
||||
agent_id: m.agentId,
|
||||
memory_type: m.type,
|
||||
last_accessed_at: m.lastAccessedAt,
|
||||
created_at: m.createdAt,
|
||||
access_count: m.accessCount,
|
||||
conversation_id: m.conversationId ?? null,
|
||||
tags: JSON.stringify(m.tags),
|
||||
embedding: null,
|
||||
}));
|
||||
return tauriInvoke('memory.import', () =>
|
||||
intelligence.memory.import(backendMemories as PersistentMemory[])
|
||||
);
|
||||
}
|
||||
return fallbackMemory.import(memories);
|
||||
},
|
||||
|
||||
dbPath: async (): Promise<string> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('memory.dbPath', () => intelligence.memory.dbPath());
|
||||
}
|
||||
return fallbackMemory.dbPath();
|
||||
},
|
||||
|
||||
buildContext: async (
|
||||
agentId: string,
|
||||
query: string,
|
||||
maxTokens?: number,
|
||||
): Promise<{ systemPromptAddition: string; totalTokens: number; memoriesUsed: number }> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('memory.buildContext', () =>
|
||||
intelligence.memory.buildContext(agentId, query, maxTokens ?? null)
|
||||
);
|
||||
}
|
||||
// Browser/dev fallback: use basic search
|
||||
const memories = await fallbackMemory.search({
|
||||
agentId,
|
||||
query,
|
||||
limit: 8,
|
||||
minImportance: 3,
|
||||
});
|
||||
const addition = memories.length > 0
|
||||
? `## 相关记忆\n${memories.map(m => `- [${m.type}] ${m.content}`).join('\n')}`
|
||||
: '';
|
||||
return { systemPromptAddition: addition, totalTokens: 0, memoriesUsed: memories.length };
|
||||
},
|
||||
},
|
||||
|
||||
heartbeat: {
|
||||
init: async (agentId: string, config?: HeartbeatConfig): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('heartbeat.init', () => intelligence.heartbeat.init(agentId, config));
|
||||
} else {
|
||||
await fallbackHeartbeat.init(agentId, config);
|
||||
}
|
||||
},
|
||||
|
||||
start: async (agentId: string): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('heartbeat.start', () => intelligence.heartbeat.start(agentId));
|
||||
} else {
|
||||
await fallbackHeartbeat.start(agentId);
|
||||
}
|
||||
},
|
||||
|
||||
stop: async (agentId: string): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('heartbeat.stop', () => intelligence.heartbeat.stop(agentId));
|
||||
} else {
|
||||
await fallbackHeartbeat.stop(agentId);
|
||||
}
|
||||
},
|
||||
|
||||
tick: async (agentId: string): Promise<HeartbeatResult> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('heartbeat.tick', () => intelligence.heartbeat.tick(agentId));
|
||||
}
|
||||
return fallbackHeartbeat.tick(agentId);
|
||||
},
|
||||
|
||||
getConfig: async (agentId: string): Promise<HeartbeatConfig> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('heartbeat.getConfig', () => intelligence.heartbeat.getConfig(agentId));
|
||||
}
|
||||
return fallbackHeartbeat.getConfig(agentId);
|
||||
},
|
||||
|
||||
updateConfig: async (agentId: string, config: HeartbeatConfig): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('heartbeat.updateConfig', () =>
|
||||
intelligence.heartbeat.updateConfig(agentId, config)
|
||||
);
|
||||
} else {
|
||||
await fallbackHeartbeat.updateConfig(agentId, config);
|
||||
}
|
||||
},
|
||||
|
||||
getHistory: async (agentId: string, limit?: number): Promise<HeartbeatResult[]> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('heartbeat.getHistory', () =>
|
||||
intelligence.heartbeat.getHistory(agentId, limit)
|
||||
);
|
||||
}
|
||||
return fallbackHeartbeat.getHistory(agentId, limit);
|
||||
},
|
||||
|
||||
updateMemoryStats: async (
|
||||
agentId: string,
|
||||
taskCount: number,
|
||||
totalEntries: number,
|
||||
storageSizeBytes: number
|
||||
): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('heartbeat.updateMemoryStats', () =>
|
||||
invoke('heartbeat_update_memory_stats', {
|
||||
agent_id: agentId,
|
||||
task_count: taskCount,
|
||||
total_entries: totalEntries,
|
||||
storage_size_bytes: storageSizeBytes,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Browser/dev fallback only
|
||||
const cache = {
|
||||
taskCount,
|
||||
totalEntries,
|
||||
storageSizeBytes,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(`zclaw-memory-stats-${agentId}`, JSON.stringify(cache));
|
||||
}
|
||||
},
|
||||
|
||||
recordCorrection: async (agentId: string, correctionType: string): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('heartbeat.recordCorrection', () =>
|
||||
invoke('heartbeat_record_correction', {
|
||||
agent_id: agentId,
|
||||
correction_type: correctionType,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Browser/dev fallback only
|
||||
const key = `zclaw-corrections-${agentId}`;
|
||||
const stored = localStorage.getItem(key);
|
||||
const counters = stored ? JSON.parse(stored) : {};
|
||||
counters[correctionType] = (counters[correctionType] || 0) + 1;
|
||||
localStorage.setItem(key, JSON.stringify(counters));
|
||||
}
|
||||
},
|
||||
|
||||
recordInteraction: async (agentId: string): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('heartbeat.recordInteraction', () =>
|
||||
invoke('heartbeat_record_interaction', {
|
||||
agent_id: agentId,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Browser/dev fallback only
|
||||
localStorage.setItem(`zclaw-last-interaction-${agentId}`, new Date().toISOString());
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
compactor: {
|
||||
estimateTokens: async (text: string): Promise<number> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('compactor.estimateTokens', () =>
|
||||
intelligence.compactor.estimateTokens(text)
|
||||
);
|
||||
}
|
||||
return fallbackCompactor.estimateTokens(text);
|
||||
},
|
||||
|
||||
estimateMessagesTokens: async (messages: CompactableMessage[]): Promise<number> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('compactor.estimateMessagesTokens', () =>
|
||||
intelligence.compactor.estimateMessagesTokens(messages)
|
||||
);
|
||||
}
|
||||
return fallbackCompactor.estimateMessagesTokens(messages);
|
||||
},
|
||||
|
||||
checkThreshold: async (
|
||||
messages: CompactableMessage[],
|
||||
config?: CompactionConfig
|
||||
): Promise<CompactionCheck> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('compactor.checkThreshold', () =>
|
||||
intelligence.compactor.checkThreshold(messages, config)
|
||||
);
|
||||
}
|
||||
return fallbackCompactor.checkThreshold(messages, config);
|
||||
},
|
||||
|
||||
compact: async (
|
||||
messages: CompactableMessage[],
|
||||
agentId: string,
|
||||
conversationId?: string,
|
||||
config?: CompactionConfig
|
||||
): Promise<CompactionResult> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('compactor.compact', () =>
|
||||
intelligence.compactor.compact(messages, agentId, conversationId, config)
|
||||
);
|
||||
}
|
||||
return fallbackCompactor.compact(messages, agentId, conversationId, config);
|
||||
},
|
||||
},
|
||||
|
||||
reflection: {
|
||||
init: async (config?: ReflectionConfig): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('reflection.init', () => intelligence.reflection.init(config));
|
||||
} else {
|
||||
await fallbackReflection.init(config);
|
||||
}
|
||||
},
|
||||
|
||||
recordConversation: async (): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('reflection.recordConversation', () =>
|
||||
intelligence.reflection.recordConversation()
|
||||
);
|
||||
} else {
|
||||
await fallbackReflection.recordConversation();
|
||||
}
|
||||
},
|
||||
|
||||
shouldReflect: async (): Promise<boolean> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('reflection.shouldReflect', () =>
|
||||
intelligence.reflection.shouldReflect()
|
||||
);
|
||||
}
|
||||
return fallbackReflection.shouldReflect();
|
||||
},
|
||||
|
||||
reflect: async (agentId: string, memories: MemoryEntryForAnalysis[]): Promise<ReflectionResult> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('reflection.reflect', () =>
|
||||
intelligence.reflection.reflect(agentId, memories)
|
||||
);
|
||||
}
|
||||
return fallbackReflection.reflect(agentId, memories);
|
||||
},
|
||||
|
||||
getHistory: async (limit?: number, agentId?: string): Promise<ReflectionResult[]> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('reflection.getHistory', () =>
|
||||
intelligence.reflection.getHistory(limit, agentId)
|
||||
);
|
||||
}
|
||||
return fallbackReflection.getHistory(limit, agentId);
|
||||
},
|
||||
|
||||
getState: async (): Promise<ReflectionState> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('reflection.getState', () => intelligence.reflection.getState());
|
||||
}
|
||||
return fallbackReflection.getState();
|
||||
},
|
||||
},
|
||||
|
||||
identity: {
|
||||
get: async (agentId: string): Promise<IdentityFiles> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('identity.get', () => intelligence.identity.get(agentId));
|
||||
}
|
||||
return fallbackIdentity.get(agentId);
|
||||
},
|
||||
|
||||
getFile: async (agentId: string, file: string): Promise<string> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('identity.getFile', () => intelligence.identity.getFile(agentId, file));
|
||||
}
|
||||
return fallbackIdentity.getFile(agentId, file);
|
||||
},
|
||||
|
||||
buildPrompt: async (agentId: string, memoryContext?: string): Promise<string> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('identity.buildPrompt', () =>
|
||||
intelligence.identity.buildPrompt(agentId, memoryContext)
|
||||
);
|
||||
}
|
||||
return fallbackIdentity.buildPrompt(agentId, memoryContext);
|
||||
},
|
||||
|
||||
updateUserProfile: async (agentId: string, content: string): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('identity.updateUserProfile', () =>
|
||||
intelligence.identity.updateUserProfile(agentId, content)
|
||||
);
|
||||
} else {
|
||||
await fallbackIdentity.updateUserProfile(agentId, content);
|
||||
}
|
||||
},
|
||||
|
||||
appendUserProfile: async (agentId: string, addition: string): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('identity.appendUserProfile', () =>
|
||||
intelligence.identity.appendUserProfile(agentId, addition)
|
||||
);
|
||||
} else {
|
||||
await fallbackIdentity.appendUserProfile(agentId, addition);
|
||||
}
|
||||
},
|
||||
|
||||
proposeChange: async (
|
||||
agentId: string,
|
||||
file: 'soul' | 'instructions',
|
||||
suggestedContent: string,
|
||||
reason: string
|
||||
): Promise<IdentityChangeProposal> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('identity.proposeChange', () =>
|
||||
intelligence.identity.proposeChange(agentId, file, suggestedContent, reason)
|
||||
);
|
||||
}
|
||||
return fallbackIdentity.proposeChange(agentId, file, suggestedContent, reason);
|
||||
},
|
||||
|
||||
approveProposal: async (proposalId: string): Promise<IdentityFiles> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('identity.approveProposal', () =>
|
||||
intelligence.identity.approveProposal(proposalId)
|
||||
);
|
||||
}
|
||||
return fallbackIdentity.approveProposal(proposalId);
|
||||
},
|
||||
|
||||
rejectProposal: async (proposalId: string): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('identity.rejectProposal', () =>
|
||||
intelligence.identity.rejectProposal(proposalId)
|
||||
);
|
||||
} else {
|
||||
await fallbackIdentity.rejectProposal(proposalId);
|
||||
}
|
||||
},
|
||||
|
||||
getPendingProposals: async (agentId?: string): Promise<IdentityChangeProposal[]> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('identity.getPendingProposals', () =>
|
||||
intelligence.identity.getPendingProposals(agentId)
|
||||
);
|
||||
}
|
||||
return fallbackIdentity.getPendingProposals(agentId);
|
||||
},
|
||||
|
||||
updateFile: async (agentId: string, file: string, content: string): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('identity.updateFile', () =>
|
||||
intelligence.identity.updateFile(agentId, file, content)
|
||||
);
|
||||
} else {
|
||||
await fallbackIdentity.updateFile(agentId, file, content);
|
||||
}
|
||||
},
|
||||
|
||||
getSnapshots: async (agentId: string, limit?: number): Promise<IdentitySnapshot[]> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('identity.getSnapshots', () =>
|
||||
intelligence.identity.getSnapshots(agentId, limit)
|
||||
);
|
||||
}
|
||||
return fallbackIdentity.getSnapshots(agentId, limit);
|
||||
},
|
||||
|
||||
restoreSnapshot: async (agentId: string, snapshotId: string): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('identity.restoreSnapshot', () =>
|
||||
intelligence.identity.restoreSnapshot(agentId, snapshotId)
|
||||
);
|
||||
} else {
|
||||
await fallbackIdentity.restoreSnapshot(agentId, snapshotId);
|
||||
}
|
||||
},
|
||||
|
||||
listAgents: async (): Promise<string[]> => {
|
||||
if (isTauriRuntime()) {
|
||||
return tauriInvoke('identity.listAgents', () => intelligence.identity.listAgents());
|
||||
}
|
||||
return fallbackIdentity.listAgents();
|
||||
},
|
||||
|
||||
deleteAgent: async (agentId: string): Promise<void> => {
|
||||
if (isTauriRuntime()) {
|
||||
await tauriInvoke('identity.deleteAgent', () => intelligence.identity.deleteAgent(agentId));
|
||||
} else {
|
||||
await fallbackIdentity.deleteAgent(agentId);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default intelligenceClient;
|
||||
@@ -56,16 +56,63 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v
|
||||
|
||||
/**
|
||||
* List clones — maps to listAgents() with field adaptation
|
||||
* Maps all available AgentInfo fields to Clone interface properties
|
||||
*/
|
||||
proto.listClones = async function (this: KernelClient): Promise<{ clones: any[] }> {
|
||||
const agents = await this.listAgents();
|
||||
const clones = agents.map((agent) => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
role: agent.description,
|
||||
model: agent.model,
|
||||
createdAt: new Date().toISOString(),
|
||||
}));
|
||||
const clones = agents.map((agent) => {
|
||||
// Parse personality/emoji/nickname from SOUL.md content
|
||||
const soulLines = (agent.soul || '').split('\n');
|
||||
let emoji: string | undefined;
|
||||
let personality: string | undefined;
|
||||
let nickname: string | undefined;
|
||||
for (const line of soulLines) {
|
||||
if (!emoji || !nickname) {
|
||||
// Parse header line: "> 🦞 Nickname" or "> 🦞"
|
||||
const headerMatch = line.match(/^>\s*(\p{Emoji_Presentation}|\p{Extended_Pictographic})?\s*(.+)$/u);
|
||||
if (headerMatch) {
|
||||
if (headerMatch[1] && !emoji) emoji = headerMatch[1];
|
||||
if (headerMatch[2]?.trim() && !nickname) nickname = headerMatch[2].trim();
|
||||
}
|
||||
// Also check emoji without nickname
|
||||
if (!emoji) {
|
||||
const emojiOnly = line.match(/^>\s*(\p{Emoji_Presentation}|\p{Extended_Pictographic})\s*$/u);
|
||||
if (emojiOnly) emoji = emojiOnly[1];
|
||||
}
|
||||
}
|
||||
if (!personality) {
|
||||
const match = line.match(/##\s*(?:性格|核心特质|沟通风格)/);
|
||||
if (match) personality = line.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse userName/userRole from userProfile
|
||||
let userName: string | undefined;
|
||||
let userRole: string | undefined;
|
||||
if (agent.userProfile && typeof agent.userProfile === 'object') {
|
||||
const profile = agent.userProfile as Record<string, unknown>;
|
||||
userName = profile.userName as string | undefined || profile.name as string | undefined;
|
||||
userRole = profile.userRole as string | undefined || profile.role as string | undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
role: agent.description,
|
||||
nickname,
|
||||
model: agent.model,
|
||||
soul: agent.soul,
|
||||
systemPrompt: agent.systemPrompt,
|
||||
temperature: agent.temperature,
|
||||
maxTokens: agent.maxTokens,
|
||||
emoji,
|
||||
personality,
|
||||
userName,
|
||||
userRole,
|
||||
createdAt: agent.createdAt || new Date().toISOString(),
|
||||
updatedAt: agent.updatedAt,
|
||||
};
|
||||
});
|
||||
return { clones };
|
||||
};
|
||||
|
||||
@@ -119,7 +166,7 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v
|
||||
};
|
||||
|
||||
/**
|
||||
* Update clone — maps to kernel agent_update
|
||||
* Update clone — maps to kernel agent_update + identity system for nickname/userName
|
||||
*/
|
||||
proto.updateClone = async function (this: KernelClient, id: string, updates: Record<string, unknown>): Promise<{ clone: unknown }> {
|
||||
await invoke('agent_update', {
|
||||
@@ -135,16 +182,130 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v
|
||||
},
|
||||
});
|
||||
|
||||
// Sync nickname/emoji to SOUL.md via identity system
|
||||
const nickname = updates.nickname as string | undefined;
|
||||
const emoji = updates.emoji as string | undefined;
|
||||
if (nickname || emoji) {
|
||||
try {
|
||||
const currentSoul = await invoke<string | null>('identity_get_file', { agentId: id, file: 'soul' });
|
||||
const soul = currentSoul || '';
|
||||
// Inject or update nickname line in SOUL.md header
|
||||
const lines = soul.split('\n');
|
||||
const headerIdx = lines.findIndex((l: string) => l.startsWith('> '));
|
||||
if (headerIdx >= 0) {
|
||||
// Update existing header line
|
||||
let header = lines[headerIdx];
|
||||
if (emoji && !header.match(/\p{Emoji_Presentation}|\p{Extended_Pictographic}/u)) {
|
||||
header = `> ${emoji} ${header.slice(2)}`;
|
||||
}
|
||||
lines[headerIdx] = header;
|
||||
} else if (emoji || nickname) {
|
||||
// Add header line after title
|
||||
const label = nickname || '';
|
||||
const icon = emoji || '';
|
||||
const titleIdx = lines.findIndex((l: string) => l.startsWith('# '));
|
||||
if (titleIdx >= 0) {
|
||||
lines.splice(titleIdx + 1, 0, `> ${icon} ${label}`.trim());
|
||||
}
|
||||
}
|
||||
await invoke('identity_update_file', { agentId: id, file: 'soul', content: lines.join('\n') });
|
||||
} catch {
|
||||
// Identity system update is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
// Sync userName/userRole to USER.md via identity system
|
||||
const userName = updates.userName as string | undefined;
|
||||
const userRole = updates.userRole as string | undefined;
|
||||
if (userName || userRole) {
|
||||
try {
|
||||
const currentProfile = await invoke<string | null>('identity_get_file', { agentId: id, file: 'user_profile' });
|
||||
const profile = currentProfile || '# 用户档案\n';
|
||||
const profileLines = profile.split('\n');
|
||||
|
||||
// Update or add userName
|
||||
if (userName) {
|
||||
const nameIdx = profileLines.findIndex((l: string) => l.includes('姓名') || l.includes('userName'));
|
||||
if (nameIdx >= 0) {
|
||||
profileLines[nameIdx] = `- 姓名:${userName}`;
|
||||
} else {
|
||||
const sectionIdx = profileLines.findIndex((l: string) => l.startsWith('## 基本信息'));
|
||||
if (sectionIdx >= 0) {
|
||||
profileLines.splice(sectionIdx + 1, 0, '', `- 姓名:${userName}`);
|
||||
} else {
|
||||
profileLines.push('', '## 基本信息', '', `- 姓名:${userName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update or add userRole
|
||||
if (userRole) {
|
||||
const roleIdx = profileLines.findIndex((l: string) => l.includes('角色') || l.includes('userRole'));
|
||||
if (roleIdx >= 0) {
|
||||
profileLines[roleIdx] = `- 角色:${userRole}`;
|
||||
} else {
|
||||
profileLines.push(`- 角色:${userRole}`);
|
||||
}
|
||||
}
|
||||
|
||||
await invoke('identity_update_file', { agentId: id, file: 'user_profile', content: profileLines.join('\n') });
|
||||
} catch {
|
||||
// Identity system update is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated clone representation
|
||||
const clone = {
|
||||
id,
|
||||
name: updates.name,
|
||||
role: updates.description || updates.role,
|
||||
nickname: updates.nickname,
|
||||
model: updates.model,
|
||||
emoji: updates.emoji,
|
||||
personality: updates.personality,
|
||||
communicationStyle: updates.communicationStyle,
|
||||
systemPrompt: updates.systemPrompt,
|
||||
userName: updates.userName,
|
||||
userRole: updates.userRole,
|
||||
};
|
||||
return { clone };
|
||||
};
|
||||
}
|
||||
|
||||
// === Agent ID Resolution ===
|
||||
|
||||
/**
|
||||
* Cached kernel default agent UUID.
|
||||
* The conversationStore's DEFAULT_AGENT has id="1", but VikingStorage
|
||||
* stores data under kernel UUIDs. This cache bridges the gap.
|
||||
*/
|
||||
let _cachedDefaultKernelAgentId: string | null = null;
|
||||
|
||||
/**
|
||||
* Resolve an agent ID to the kernel's actual agent UUID.
|
||||
* - If already a UUID (8-4-4 hex pattern), return as-is.
|
||||
* - If "1" or undefined, query agent_list and cache the first kernel agent's UUID.
|
||||
* - Falls back to the original ID if kernel has no agents.
|
||||
*/
|
||||
export async function resolveKernelAgentId(agentId: string | undefined): Promise<string> {
|
||||
if (agentId && /^[0-9a-f]{8}-[0-9a-f]{4}-/.test(agentId)) {
|
||||
return agentId;
|
||||
}
|
||||
if (_cachedDefaultKernelAgentId) {
|
||||
return _cachedDefaultKernelAgentId;
|
||||
}
|
||||
try {
|
||||
const agents = await invoke<{ id: string }[]>('agent_list');
|
||||
if (agents.length > 0) {
|
||||
_cachedDefaultKernelAgentId = agents[0].id;
|
||||
return _cachedDefaultKernelAgentId;
|
||||
}
|
||||
} catch {
|
||||
// Kernel may not be available
|
||||
}
|
||||
return agentId || '1';
|
||||
}
|
||||
|
||||
/** Invalidate cache when kernel reconnects (new instance may have different UUIDs) */
|
||||
export function invalidateKernelAgentIdCache(): void {
|
||||
_cachedDefaultKernelAgentId = null;
|
||||
}
|
||||
|
||||
@@ -164,6 +164,11 @@ export class KernelClient {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/** Get current kernel configuration (for auth token refresh) */
|
||||
getConfig(): KernelConfig | undefined {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
getState(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { DEFAULT_MODEL_ID, DEFAULT_OPENAI_BASE_URL } from '../constants/models';
|
||||
import { createLogger } from './logger';
|
||||
import { recordLLMUsage } from './telemetry-collector';
|
||||
|
||||
const log = createLogger('LLMService');
|
||||
|
||||
@@ -819,7 +820,6 @@ function trackLLMCall(
|
||||
error?: unknown,
|
||||
): void {
|
||||
try {
|
||||
const { recordLLMUsage } = require('./telemetry-collector');
|
||||
recordLLMUsage(
|
||||
response.model || adapter.getProvider(),
|
||||
response.tokensUsed?.input ?? 0,
|
||||
@@ -832,7 +832,7 @@ function trackLLMCall(
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
log.debug('Telemetry recording failed (SSR or unavailable)', { error: e });
|
||||
log.debug('Telemetry recording failed', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,27 @@ export const SCENARIO_TAGS: ScenarioTag[] = [
|
||||
icon: 'Palette',
|
||||
keywords: ['设计', 'UI', 'UX', '视觉', '原型', '界面'],
|
||||
},
|
||||
{
|
||||
id: 'healthcare',
|
||||
label: '医疗健康',
|
||||
description: '医院管理、患者服务、医疗数据分析',
|
||||
icon: 'HeartPulse',
|
||||
keywords: ['医疗', '医院', '健康', '患者', '临床', '护理', '行政'],
|
||||
},
|
||||
{
|
||||
id: 'education',
|
||||
label: '教育培训',
|
||||
description: '课程设计、教学辅助、学习规划',
|
||||
icon: 'GraduationCap',
|
||||
keywords: ['教育', '教学', '课程', '培训', '学习', '考试'],
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
label: '金融财务',
|
||||
description: '财务分析、风险管理、投资研究',
|
||||
icon: 'Landmark',
|
||||
keywords: ['金融', '财务', '投资', '风控', '审计', '报表'],
|
||||
},
|
||||
{
|
||||
id: 'devops',
|
||||
label: '运维部署',
|
||||
@@ -118,6 +139,13 @@ export const SCENARIO_TAGS: ScenarioTag[] = [
|
||||
icon: 'Megaphone',
|
||||
keywords: ['营销', '推广', '运营', '社媒', '增长', '转化'],
|
||||
},
|
||||
{
|
||||
id: 'legal',
|
||||
label: '法律合规',
|
||||
description: '合同审查、法规研究、合规管理',
|
||||
icon: 'Scale',
|
||||
keywords: ['法律', '合同', '合规', '法规', '审查', '风险'],
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
label: '其他',
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { AgentTemplateFull } from '../lib/saas-client';
|
||||
import { saasClient } from '../lib/saas-client';
|
||||
import { useChatStore } from './chatStore';
|
||||
import { useConversationStore } from './chat/conversationStore';
|
||||
import { getGatewayVersion } from './connectionStore';
|
||||
import { useSaaSStore } from './saasStore';
|
||||
import { createLogger } from '../lib/logger';
|
||||
|
||||
@@ -203,7 +204,28 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
// Step 1: Call backend to get server-processed config (tools merge)
|
||||
const config = await saasClient.createAgentFromTemplate(template.id);
|
||||
// Fallback to template data directly if SaaS is unreachable
|
||||
let config;
|
||||
try {
|
||||
config = await saasClient.createAgentFromTemplate(template.id);
|
||||
} catch (saasErr) {
|
||||
log.warn('[AgentStore] SaaS createAgentFromTemplate failed, using template directly:', saasErr);
|
||||
// Fallback: build config from template data without server-side tools merge
|
||||
config = {
|
||||
name: template.name,
|
||||
model: template.model,
|
||||
system_prompt: template.system_prompt,
|
||||
tools: template.tools || [],
|
||||
soul_content: template.soul_content,
|
||||
welcome_message: template.welcome_message,
|
||||
quick_commands: template.quick_commands,
|
||||
temperature: template.temperature,
|
||||
max_tokens: template.max_tokens,
|
||||
personality: template.personality,
|
||||
communication_style: template.communication_style,
|
||||
emoji: template.emoji,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve model: template model > first available SaaS model > 'default'
|
||||
const resolvedModel = config.model
|
||||
@@ -338,6 +360,22 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
byModel: {},
|
||||
};
|
||||
|
||||
// P2-10 修复: saas-relay 模式下从服务端获取真实用量
|
||||
const gwVersion = getGatewayVersion();
|
||||
if (gwVersion === 'saas-relay') {
|
||||
try {
|
||||
const sub = await saasClient.getSubscription();
|
||||
if (sub?.usage) {
|
||||
const serverTokens = (sub.usage.input_tokens ?? 0) + (sub.usage.output_tokens ?? 0);
|
||||
if (serverTokens > 0) {
|
||||
stats.totalTokens = serverTokens;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 降级到本地计数器
|
||||
}
|
||||
}
|
||||
|
||||
set({ usageStats: stats });
|
||||
} catch {
|
||||
// Usage stats are non-critical, ignore errors silently
|
||||
|
||||
@@ -38,6 +38,46 @@ import { useArtifactStore } from './artifactStore';
|
||||
|
||||
const log = createLogger('StreamStore');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 401 Auth Error Recovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect and handle 401 auth errors during chat streaming.
|
||||
* Attempts token refresh → kernel reconnect → auto-retry.
|
||||
* Returns a user-friendly error message if recovery fails.
|
||||
*/
|
||||
async function tryRecoverFromAuthError(error: string): Promise<string | null> {
|
||||
const is401 = /401|Unauthorized|UNAUTHORIZED|未认证|认证已过期/.test(error);
|
||||
if (!is401) return null;
|
||||
|
||||
log.info('Detected 401 auth error, attempting token refresh...');
|
||||
try {
|
||||
const { saasClient } = await import('../../lib/saas-client');
|
||||
const newToken = await saasClient.refreshMutex();
|
||||
if (newToken) {
|
||||
// Update kernel config with refreshed token → triggers kernel re-init via changed api_key detection
|
||||
const { getKernelClient } = await import('../../lib/kernel-client');
|
||||
const kernelClient = getKernelClient();
|
||||
const currentConfig = kernelClient.getConfig();
|
||||
if (currentConfig) {
|
||||
kernelClient.setConfig({ ...currentConfig, apiKey: newToken });
|
||||
await kernelClient.connect();
|
||||
log.info('Kernel reconnected with refreshed token');
|
||||
}
|
||||
return '认证已刷新,请重新发送消息';
|
||||
}
|
||||
} catch (refreshErr) {
|
||||
log.warn('Token refresh failed, triggering logout:', refreshErr);
|
||||
try {
|
||||
const { useSaaSStore } = await import('../saasStore');
|
||||
useSaaSStore.getState().logout();
|
||||
} catch { /* non-critical */ }
|
||||
return 'SaaS 会话已过期,请重新登录';
|
||||
}
|
||||
return '认证失败,请重新登录';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -517,7 +557,7 @@ export const useStreamStore = create<StreamState>()(
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error: string) => {
|
||||
onError: async (error: string) => {
|
||||
// Flush any remaining buffered deltas before erroring
|
||||
if (flushTimer !== null) {
|
||||
clearTimeout(flushTimer);
|
||||
@@ -525,10 +565,14 @@ export const useStreamStore = create<StreamState>()(
|
||||
}
|
||||
flushBuffers();
|
||||
|
||||
// Attempt 401 auth recovery (token refresh + kernel reconnect)
|
||||
const recoveryMsg = await tryRecoverFromAuthError(error);
|
||||
const displayError = recoveryMsg || error;
|
||||
|
||||
_chat?.updateMessages(msgs =>
|
||||
msgs.map(m =>
|
||||
m.id === assistantId
|
||||
? { ...m, content: error, streaming: false, error }
|
||||
? { ...m, content: displayError, streaming: false, error: displayError }
|
||||
: m.role === 'user' && m.optimistic && m.timestamp.getTime() >= streamStartTime
|
||||
? { ...m, optimistic: false }
|
||||
: m
|
||||
@@ -573,13 +617,18 @@ export const useStreamStore = create<StreamState>()(
|
||||
textBuffer = '';
|
||||
thinkBuffer = '';
|
||||
|
||||
const errorMessage = err instanceof Error ? err.message : '无法连接 Gateway';
|
||||
let errorMessage = err instanceof Error ? err.message : '无法连接 Gateway';
|
||||
|
||||
// Attempt 401 auth recovery
|
||||
const recoveryMsg = await tryRecoverFromAuthError(errorMessage);
|
||||
if (recoveryMsg) errorMessage = recoveryMsg;
|
||||
|
||||
_chat?.updateMessages(msgs =>
|
||||
msgs.map(m =>
|
||||
m.id === assistantId
|
||||
? {
|
||||
...m,
|
||||
content: `⚠️ ${errorMessage}`,
|
||||
content: errorMessage,
|
||||
streaming: false,
|
||||
error: errorMessage,
|
||||
}
|
||||
|
||||
@@ -30,6 +30,16 @@ import { useConfigStore } from './configStore';
|
||||
import { createLogger } from '../lib/logger';
|
||||
import { secureStorage } from '../lib/secure-storage';
|
||||
|
||||
// 延迟加载 conversationStore 避免循环依赖
|
||||
// connect() 是 async 函数,在其中 await import() 是安全的
|
||||
let _conversationStore: typeof import('./chat/conversationStore') | null = null;
|
||||
async function loadConversationStore() {
|
||||
if (!_conversationStore) {
|
||||
try { _conversationStore = await import('./chat/conversationStore'); } catch { /* not loaded yet */ }
|
||||
}
|
||||
return _conversationStore;
|
||||
}
|
||||
|
||||
const log = createLogger('ConnectionStore');
|
||||
|
||||
// === Mode Selection ===
|
||||
@@ -492,8 +502,8 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
||||
// 优先使用 conversationStore 的 currentModel,如果设置了的话
|
||||
let preferredModel: string | undefined;
|
||||
try {
|
||||
const { useConversationStore } = require('./chat/conversationStore');
|
||||
preferredModel = useConversationStore.getState().currentModel;
|
||||
const cs = await loadConversationStore();
|
||||
preferredModel = cs?.useConversationStore.getState().currentModel;
|
||||
} catch {
|
||||
// conversationStore 可能尚未初始化
|
||||
}
|
||||
@@ -536,6 +546,17 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
||||
await kernelClient.connect();
|
||||
|
||||
set({ gatewayVersion: 'saas-relay', connectionState: 'connected' });
|
||||
|
||||
// 同步 modelToUse 到 conversationStore(首次登录时 currentModel 可能为空)
|
||||
try {
|
||||
const cs = await loadConversationStore();
|
||||
const currentInStore = cs?.useConversationStore.getState().currentModel;
|
||||
if (!currentInStore && modelToUse) {
|
||||
cs?.useConversationStore.getState().setCurrentModel(modelToUse);
|
||||
log.info(`Synced currentModel after SaaS relay connect: ${modelToUse}`);
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
log.debug('Connected via SaaS relay (kernel backend):', {
|
||||
model: modelToUse,
|
||||
baseUrl: `${session.saasUrl}/api/v1/relay`,
|
||||
@@ -553,13 +574,9 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
||||
);
|
||||
const relayClient = createSaaSRelayGatewayClient(session.saasUrl, () => {
|
||||
// 每次调用时读取 conversationStore 的 currentModel,fallback 到第一个可用模型
|
||||
try {
|
||||
const { useConversationStore } = require('./chat/conversationStore');
|
||||
const current = useConversationStore.getState().currentModel;
|
||||
return (current && validBrowserModelIds.has(current)) ? current : fallbackModelId;
|
||||
} catch {
|
||||
return fallbackModelId;
|
||||
}
|
||||
// 注意:这里不能用 await(同步回调),但 conversationStore 已在上方 loadConversationStore() 中加载
|
||||
const current = _conversationStore?.useConversationStore.getState().currentModel;
|
||||
return (current && validBrowserModelIds.has(current)) ? current : fallbackModelId;
|
||||
});
|
||||
|
||||
set({
|
||||
@@ -572,6 +589,16 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
||||
initializeStores();
|
||||
|
||||
log.debug('Connected to SaaS relay (browser mode)', { relayModel: fallbackModelId });
|
||||
|
||||
// 同步 currentModel 到 conversationStore(浏览器路径)
|
||||
try {
|
||||
const cs = await loadConversationStore();
|
||||
const currentInStore = cs?.useConversationStore.getState().currentModel;
|
||||
if (!currentInStore && fallbackModelId) {
|
||||
cs?.useConversationStore.getState().setCurrentModel(fallbackModelId);
|
||||
log.info(`Synced currentModel after browser SaaS relay connect: ${fallbackModelId}`);
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface SaaSStateSlice {
|
||||
_consecutiveFailures: number;
|
||||
_heartbeatTimer?: ReturnType<typeof setInterval>;
|
||||
_healthCheckTimer?: ReturnType<typeof setInterval>;
|
||||
_recoveryProbeTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
// === Billing State ===
|
||||
plans: BillingPlan[];
|
||||
@@ -141,6 +142,67 @@ function resolveInitialMode(sessionMeta: { saasUrl: string; account: SaaSAccount
|
||||
return sessionMeta ? 'saas' : 'tauri';
|
||||
}
|
||||
|
||||
// === SaaS Recovery Probe ===
|
||||
// When SaaS degrades to local mode, periodically probes SaaS reachability
|
||||
// with exponential backoff (2min → 3min → 4.5min → 6.75min → 10min cap).
|
||||
// On recovery, switches back to SaaS mode and notifies user via toast.
|
||||
|
||||
let _recoveryProbeInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let _recoveryBackoffMs = 2 * 60 * 1000; // Start at 2 minutes
|
||||
const RECOVERY_BACKOFF_CAP_MS = 10 * 60 * 1000; // Max 10 minutes
|
||||
const RECOVERY_BACKOFF_MULTIPLIER = 1.5;
|
||||
|
||||
function startRecoveryProbe() {
|
||||
if (_recoveryProbeInterval) return; // Already probing
|
||||
|
||||
_recoveryBackoffMs = 2 * 60 * 1000; // Reset backoff
|
||||
log.info('[SaaS Recovery] Starting recovery probe...');
|
||||
|
||||
const probe = async () => {
|
||||
try {
|
||||
await saasClient.deviceHeartbeat(DEVICE_ID);
|
||||
// SaaS is reachable again — recover
|
||||
log.info('[SaaS Recovery] SaaS reachable — switching back to SaaS mode');
|
||||
useSaaSStore.setState({
|
||||
saasReachable: true,
|
||||
connectionMode: 'saas',
|
||||
_consecutiveFailures: 0,
|
||||
} as unknown as Partial<SaaSStore>);
|
||||
saveConnectionMode('saas');
|
||||
|
||||
// Notify user via custom event (App.tsx listens)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('saas-recovered'));
|
||||
}
|
||||
|
||||
// Stop probing
|
||||
stopRecoveryProbe();
|
||||
} catch {
|
||||
// Still unreachable — increase backoff
|
||||
_recoveryBackoffMs = Math.min(
|
||||
_recoveryBackoffMs * RECOVERY_BACKOFF_MULTIPLIER,
|
||||
RECOVERY_BACKOFF_CAP_MS
|
||||
);
|
||||
log.debug(`[SaaS Recovery] Still unreachable, next probe in ${Math.round(_recoveryBackoffMs / 1000)}s`);
|
||||
|
||||
// Reschedule with new backoff
|
||||
if (_recoveryProbeInterval) {
|
||||
clearInterval(_recoveryProbeInterval);
|
||||
}
|
||||
_recoveryProbeInterval = setInterval(probe, _recoveryBackoffMs);
|
||||
}
|
||||
};
|
||||
|
||||
_recoveryProbeInterval = setInterval(probe, _recoveryBackoffMs);
|
||||
}
|
||||
|
||||
function stopRecoveryProbe() {
|
||||
if (_recoveryProbeInterval) {
|
||||
clearInterval(_recoveryProbeInterval);
|
||||
_recoveryProbeInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// === Store Implementation ===
|
||||
|
||||
export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
@@ -434,7 +496,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
|
||||
// Clear currentModel so next connection uses fresh model resolution
|
||||
try {
|
||||
const { useConversationStore } = require('./chat/conversationStore');
|
||||
const { useConversationStore } = await import('./chat/conversationStore');
|
||||
useConversationStore.getState().setCurrentModel('');
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
@@ -488,11 +550,15 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
const { useConversationStore } = await import('./chat/conversationStore');
|
||||
const current = useConversationStore.getState().currentModel;
|
||||
const modelIds = models.map(m => m.alias || m.id);
|
||||
if (current && !modelIds.includes(current)) {
|
||||
const firstModel = models[0];
|
||||
const fallbackId = firstModel.alias || firstModel.id;
|
||||
const firstModel = models[0];
|
||||
const fallbackId = firstModel.alias || firstModel.id;
|
||||
if (!current || !modelIds.includes(current)) {
|
||||
useConversationStore.getState().setCurrentModel(fallbackId);
|
||||
log.info(`Synced currentModel: ${current} not available, switched to ${fallbackId}`);
|
||||
if (current) {
|
||||
log.info(`Synced currentModel: ${current} not available, switched to ${fallbackId}`);
|
||||
} else {
|
||||
log.info(`Auto-selected first available model: ${fallbackId}`);
|
||||
}
|
||||
}
|
||||
} catch (syncErr) {
|
||||
log.warn('Failed to sync currentModel after fetching models:', syncErr);
|
||||
@@ -694,6 +760,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
connectionMode: 'tauri',
|
||||
} as unknown as Partial<SaaSStore>);
|
||||
saveConnectionMode('tauri');
|
||||
// Start recovery probe with exponential backoff
|
||||
startRecoveryProbe();
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
410
docs/DEBUGGING_PROMPT.md
Normal file
410
docs/DEBUGGING_PROMPT.md
Normal file
@@ -0,0 +1,410 @@
|
||||
# ZCLAW 多端全链路调试提示词
|
||||
|
||||
> 基于 wiki 知识库 `g:\ZClaw_openfang\wiki\` 系统分析制定
|
||||
> 用于新会话中系统性排查 ZCLAW 多端联调问题
|
||||
|
||||
---
|
||||
|
||||
## 系统架构概要
|
||||
|
||||
ZCLAW 是多层架构的 AI Agent 桌面客户端:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 前端层: React 19 + TypeScript + Zustand │
|
||||
│ ├── 97 个 React 组件 (desktop/src/components/) │
|
||||
│ ├── 17 个 Zustand Store + 4 个 chat 子 store │
|
||||
│ └── 81 个 lib 文件 (desktop/src/lib/) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 桌面层: Tauri 2.x │
|
||||
│ ├── 189 个 Tauri 命令定义 / 182 个注册 │
|
||||
│ └── 3 种 ChatStream: KernelClient / SaaSRelay / Gateway │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Rust 后端: 10 crates + src-tauri │
|
||||
│ ├── ~95K 行 Rust 代码 (335 个 .rs 文件) │
|
||||
│ └── 14 层中间件链 (middleware/butler_router.rs 等) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ SaaS 平台: PostgreSQL + axum │
|
||||
│ ├── 137 个 API 路由 (18 处 .merge() 汇聚) │
|
||||
│ └── 8 个后台 Worker (Token Pool / 计费 / 用量等) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心数据流
|
||||
|
||||
### 客户端路由决策树
|
||||
|
||||
入口: `desktop/src/store/connectionStore.ts:349` → `connect(url?, token?)`
|
||||
|
||||
```
|
||||
connect()
|
||||
│
|
||||
├── [1] Admin 强制路由: localStorage llm_routing
|
||||
│ ├── "relay" → 强制 SaaS Relay 模式
|
||||
│ └── "local" → 强制本地 Kernel (adminForceLocal=true)
|
||||
│
|
||||
├── [2] SaaS Relay 模式: localStorage('zclaw-connection-mode') === 'saas'
|
||||
│ ├── Tauri: KernelClient + baseUrl = saasUrl/api/v1/relay
|
||||
│ │ apiKey = SaaS JWT (不是 LLM Key!)
|
||||
│ ├── Browser: SaaSRelayGatewayClient (SSE)
|
||||
│ └── SaaS 不可达 → 降级到本地 Kernel
|
||||
│
|
||||
├── [3] 本地 Kernel: isTauriRuntime() === true
|
||||
│ KernelClient + 用户自定义模型配置
|
||||
│
|
||||
└── [4] External Gateway (fallback)
|
||||
GatewayClient via WebSocket/REST
|
||||
```
|
||||
|
||||
### 完整消息流 (Tauri SaaS Relay 主路径)
|
||||
|
||||
```
|
||||
UI: ChatArea.tsx
|
||||
│
|
||||
▼
|
||||
streamStore.sendMessage(content)
|
||||
│
|
||||
▼
|
||||
kernelClient.chatStream()
|
||||
│
|
||||
├── Tauri invoke('kernel_chat', ...)
|
||||
│ │
|
||||
│ ▼
|
||||
│ Kernel::boot()
|
||||
│ │
|
||||
│ ▼
|
||||
│ loop_runner → 14 层中间件链
|
||||
│ │
|
||||
│ ├── @80 ButlerRouter (TF-IDF 75 技能路由) ✅
|
||||
│ ├── @90 DataMasking (敏感数据脱敏)
|
||||
│ ├── @100 Compaction (对话压缩, 条件注册: threshold>0)
|
||||
│ ├── @150 Memory (记忆提取)
|
||||
│ ├── @180 Title (会话标题)
|
||||
│ ├── @200 SkillIndex (技能索引注入, 条件注册: entries非空)
|
||||
│ ├── @300 DanglingTool
|
||||
│ ├── @350 ToolError
|
||||
│ ├── @360 ToolOutputGuard
|
||||
│ ├── @400 Guardrail (安全规则)
|
||||
│ ├── @500 LoopGuard (防无限循环)
|
||||
│ ├── @550 SubagentLimit
|
||||
│ ├── @650 TrajectoryRecorder (✅ V13-GAP-01 已修复)
|
||||
│ └── @700 TokenCalibration
|
||||
│ │
|
||||
│ ▼
|
||||
│ LLM Driver (OpenAI compatible)
|
||||
│ │
|
||||
│ ▼
|
||||
│ POST {baseUrl}/chat/completions
|
||||
│ Bearer {SaaS JWT}
|
||||
│ model: {modelToUse}
|
||||
│ │
|
||||
│ ▼
|
||||
│ SaaS Relay Handler
|
||||
│ cache.resolve_model(model_name) ── 三级解析:
|
||||
│ 1. 精确 model_id 匹配
|
||||
│ 2. alias 字段匹配
|
||||
│ 3. 前缀匹配 (e.g. "glm-4-flash" → "glm-4-flash-250414")
|
||||
│ │
|
||||
│ ▼
|
||||
│ Token Pool 轮换
|
||||
│ (priority → last_used → RPM/TPM 滑动窗口)
|
||||
│ │
|
||||
│ ▼
|
||||
│ 真实 LLM API
|
||||
│ │
|
||||
│ ▼
|
||||
│ SSE 流式响应
|
||||
│ │
|
||||
▼ ▼
|
||||
Tauri Event emit('chat-response-delta', ...)
|
||||
│
|
||||
├── onDelta(text) → streamStore 追加
|
||||
├── onThinkingDelta → 显示思考过程
|
||||
├── onTool(tool) → toolStore 更新
|
||||
├── onHand(hand) → handStore 更新
|
||||
└── onComplete() → conversationStore 持久化
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## V13 已知断链清单(2026-04-14 验证更新)
|
||||
|
||||
> 验证时间: 2026-04-14 | 6 项中 5 项已修复,1 项已标注待清理
|
||||
|
||||
| ID | 优先级 | 状态 | 问题 | 验证位置 |
|
||||
|----|--------|------|------|----------|
|
||||
| **V13-GAP-01** | P1 | ✅ 已修复 | TrajectoryRecorderMiddleware @650 已注册到 `create_middleware_chain()` | `crates/zclaw-kernel/src/kernel/mod.rs:356-361` |
|
||||
| **V13-GAP-02** | P1 | ✅ 已修复 | industryStore 已被 `ButlerPanel/index.tsx` 导入消费 | `desktop/src/components/ButlerPanel/index.tsx:4` |
|
||||
| **V13-GAP-03** | P1 | ✅ 已修复 | Knowledge Search API 已接入: saas-knowledge.ts → saas-client.ts → VikingPanel.tsx | `desktop/src/components/VikingPanel.tsx:105` |
|
||||
| **V13-GAP-04** | P2 | ⚠️ 标注 | Webhook 迁移文件已标 DEPRECATED,Rust 零消费,待物理删除 | `crates/zclaw-saas/migrations/20260403000002_webhooks.sql` |
|
||||
| **V13-GAP-05** | P2 | ✅ 已修复 | Structured Data Source 有完整 Admin-v2 消费链: service → Knowledge.tsx → StructuredSourcesPanel | `admin-v2/src/services/knowledge.ts:67-207` |
|
||||
| **V13-GAP-06** | P2 | ✅ 已修复 | PersistentMemoryStore struct 已移除,仅保留 API 响应类型 | `desktop/src-tauri/src/memory/persistent.rs` |
|
||||
|
||||
---
|
||||
|
||||
## 调试入口和关键文件
|
||||
|
||||
### 前端层
|
||||
|
||||
| 文件 | 职责 | 调试要点 |
|
||||
|------|------|----------|
|
||||
| `desktop/src/store/connectionStore.ts` | 路由决策核心 | `connect()` 方法, `getClient()` |
|
||||
| `desktop/src/store/chat/streamStore.ts` | 流式消息编排 | `sendMessage()`, `chatStream()` 回调 |
|
||||
| `desktop/src/store/chat/conversationStore.ts` | 会话管理 | `currentModel` 持久化 |
|
||||
| `desktop/src/lib/kernel-chat.ts` | Kernel ChatStream (Tauri) | Tauri invoke 调用 |
|
||||
| `desktop/src/lib/saas-relay-client.ts` | SaaS Relay ChatStream | SSE 连接 |
|
||||
| `desktop/src/lib/gateway-client.ts` | Gateway ChatStream (WS) | WebSocket 连接 |
|
||||
| `desktop/src/components/ButlerPanel.tsx` | 管家面板 | industryStore 已导入 ✅ (V13-GAP-02 已修复) |
|
||||
|
||||
### Tauri 命令层
|
||||
|
||||
| 文件 | 命令数 | 调试要点 |
|
||||
|------|--------|----------|
|
||||
| `desktop/src-tauri/src/lib.rs` | 189 定义 / 182 注册 | `kernel_chat`, `kernel_init` 等 |
|
||||
| `desktop/src-tauri/src/kernel_commands/` | Hand/MCP/Skill | 8+4+? 命令 |
|
||||
| `desktop/src-tauri/src/memory_commands.rs` | 13 个 | memory CRUD |
|
||||
|
||||
### Rust 中间件层
|
||||
|
||||
| 文件 | 中间件数 | 调试要点 |
|
||||
|------|----------|----------|
|
||||
| `crates/zclaw-runtime/src/middleware.rs` | AgentMiddleware trait | 4 个 hook 点 |
|
||||
| `crates/zclaw-runtime/src/middleware/` | 14 个中间件实现 | TrajectoryRecorder @650 ✅ |
|
||||
| `crates/zclaw-kernel/src/kernel/mod.rs:206-371` | `create_middleware_chain()` | V13-GAP-01 已修复 ✅ |
|
||||
|
||||
### SaaS 后端层
|
||||
|
||||
| 文件 | 路由数 | 调试要点 |
|
||||
|------|--------|----------|
|
||||
| `crates/zclaw-saas/src/main.rs` | 137 个 .route() | 18 个 .merge() 注册 |
|
||||
| `crates/zclaw-saas/src/relay/handlers.rs` | 聊天中转 | `cache.resolve_model()` 三级解析 |
|
||||
| `crates/zclaw-saas/src/workers/` | 8 个 Worker | Token Pool / 用量记录 |
|
||||
|
||||
---
|
||||
|
||||
## 系统性调试步骤
|
||||
|
||||
### 第一阶段:问题定位
|
||||
|
||||
**步骤 1:确定症状层级**
|
||||
|
||||
问题出现在哪一层?
|
||||
- [ ] **前端 UI 层** — 组件渲染、用户交互异常
|
||||
- [ ] **前端 Store 层** — 状态管理、数据流异常
|
||||
- [ ] **Tauri 命令层** — invoke 调用失败或返回错误
|
||||
- [ ] **Rust 中间件层** — 中间件链执行异常
|
||||
- [ ] **LLM Driver 层** — 模型调用失败
|
||||
- [ ] **SaaS Relay 层** — Token Pool / 模型匹配问题
|
||||
- [ ] **SaaS 后端层** — API 路由 / 数据库问题
|
||||
|
||||
**步骤 2:缩小范围**
|
||||
|
||||
根据症状判断问题来源:
|
||||
|
||||
| 症状 | 怀疑层级 | 优先检查 |
|
||||
|------|----------|----------|
|
||||
| 模型 400 错误 | SaaS Relay | `cache.resolve_model()` 三级解析是否匹配到 model_id |
|
||||
| 中间件未生效 | Rust 中间件层 | TrajectoryRecorder @650 已注册 ✅ |
|
||||
| 行业配置不显示 | 前端 Store 层 | industryStore 已被 ButlerPanel 导入 ✅ |
|
||||
| 流式响应中断 | Tauri 命令层 | kernel-chat.ts:76 超时守护 |
|
||||
| Token Pool 耗尽 | SaaS 后端 | Workers 是否正常调度 |
|
||||
| JWT 失效 | SaaS 认证 | password_version 是否匹配 |
|
||||
| 记忆提取失败 | Rust Memory 层 | `MemoryExtractor` 是否正常工作 |
|
||||
| Skill 未匹配 | 语义路由 | `SemanticSkillRouter` TF-IDF 计算 |
|
||||
|
||||
### 第二阶段:分层验证
|
||||
|
||||
**步骤 3:前端层验证**
|
||||
|
||||
```bash
|
||||
# TypeScript 类型检查
|
||||
cd desktop && pnpm tsc --noEmit
|
||||
|
||||
# 运行单元测试
|
||||
cd desktop && pnpm vitest run
|
||||
|
||||
# 检查 Store 状态 (在 DevTools Console)
|
||||
# streamStore.getState()
|
||||
# conversationStore.getState()
|
||||
# connectionStore.getState()
|
||||
|
||||
# 检查组件是否正确导入
|
||||
grep -r "industryStore" desktop/src/components/
|
||||
grep -r "saas-knowledge" desktop/src/
|
||||
```
|
||||
|
||||
**步骤 4:Tauri 命令层验证**
|
||||
|
||||
```bash
|
||||
# 列出所有注册的 Tauri 命令
|
||||
grep "#[tauri::command]" desktop/src-tauri/src/ -r
|
||||
|
||||
# 验证 kernel 命令
|
||||
grep "kernel_chat\|kernel_init" desktop/src-tauri/src/ -r
|
||||
|
||||
# 验证中间件注册
|
||||
grep -A3 "TrajectoryRecorder" crates/zclaw-kernel/src/kernel/mod.rs
|
||||
```
|
||||
|
||||
**步骤 5:Rust 中间件链验证**
|
||||
|
||||
```bash
|
||||
# 验证 14 层中间件注册顺序
|
||||
grep "chain.register" crates/zclaw-kernel/src/kernel/mod.rs
|
||||
|
||||
# 验证 ButlerRouter 语义路由
|
||||
grep -r "SemanticSkillRouter\|TF-IDF" crates/zclaw-runtime/src/middleware/
|
||||
|
||||
# 验证 Memory 中间件
|
||||
grep -r "Memory" crates/zclaw-runtime/src/middleware/
|
||||
```
|
||||
|
||||
**步骤 6:SaaS Relay 验证**
|
||||
|
||||
```bash
|
||||
# 验证路由注册
|
||||
grep "\.route(" crates/zclaw-saas/src/main.rs
|
||||
|
||||
# 验证模型缓存匹配
|
||||
grep "cache.get_model\|model_id" crates/zclaw-saas/src/relay/ -r
|
||||
|
||||
# 验证 Token Pool 轮换逻辑
|
||||
grep -r "priority\|last_used\|cooldown" crates/zclaw-saas/src/relay/
|
||||
```
|
||||
|
||||
### 第三阶段:完整链路检查
|
||||
|
||||
**步骤 7:按检查点逐步验证**
|
||||
|
||||
```
|
||||
[检查点 1] streamStore.sendMessage()
|
||||
└── 验证: sessionKey, agentId, chatMode 是否正确传递
|
||||
└── 代码: desktop/src/store/chat/streamStore.ts
|
||||
|
||||
[检查点 2] KernelClient.chatStream()
|
||||
└── 验证: Tauri invoke 调用是否发出, 参数是否正确
|
||||
└── 代码: desktop/src/lib/kernel-chat.ts
|
||||
|
||||
[检查点 3] Kernel::boot()
|
||||
└── 验证: config.model, config.baseUrl, config.apiKey
|
||||
└── 代码: desktop/src-tauri/src/kernel/mod.rs
|
||||
|
||||
[检查点 4] Middleware Chain (14 层)
|
||||
└── 验证: 每层 middleware.before_completion() 是否按优先级执行
|
||||
└── 重点: TrajectoryRecorder @650 已注册 ✅ (V13-GAP-01 已修复)
|
||||
└── 代码: crates/zclaw-kernel/src/kernel/mod.rs:create_middleware_chain()
|
||||
|
||||
[检查点 5] LLM Driver
|
||||
└── 验证: 请求是否发往正确 baseUrl, Authorization header 是否正确
|
||||
└── 代码: crates/zclaw-runtime/src/driver/
|
||||
|
||||
[检查点 6] SaaS Relay Handler
|
||||
└── 验证: model_id 是否被 resolve_model() 三级解析命中, Token Pool 是否可用
|
||||
└── 代码: crates/zclaw-saas/src/relay/handlers.rs
|
||||
|
||||
[检查点 7] Token Pool 轮换
|
||||
└── 验证: RPM/TPM 是否在阈值内, cooldown 状态
|
||||
└── 代码: crates/zclaw-saas/src/relay/cache.rs
|
||||
|
||||
[检查点 8] SSE 流式响应
|
||||
└── 验证: Tauri Event 是否正确 emit, onDelta 回调是否触发
|
||||
└── 代码: desktop/src/lib/kernel-chat.ts
|
||||
|
||||
[检查点 9] Store 状态更新
|
||||
└── 验证: conversationStore 持久化, messageStore 更新
|
||||
└── 代码: desktop/src/store/chat/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 根因分类与修复策略
|
||||
|
||||
### 协议问题
|
||||
|
||||
| 问题 | 原因 | 修复策略 |
|
||||
|------|------|----------|
|
||||
| 模型 400 | model_id 不在 SaaS 模型缓存中(三级解析均未命中) | 检查模型是否已在 Admin 启用,或 model_id 拼写 |
|
||||
| JWT 失效 | password_version 不匹配 | 重新登录,或检查 JWT 刷新逻辑 |
|
||||
| 流式中断 | 5 分钟超时守护触发 | 检查 kernel-chat.ts:76 超时配置 |
|
||||
|
||||
### 状态问题
|
||||
|
||||
| 问题 | 原因 | 修复策略 |
|
||||
|------|------|----------|
|
||||
| Store 不同步 | 多个 Store 实例 | 检查 store/index.ts 的 initializeStores() |
|
||||
| 模型切换失败 | currentModel 未持久化 | 检查 conversationStore 的 persist 配置 |
|
||||
| 行业配置不显示 | industryStore 导入 ✅ 已修复 | 如仍有问题检查 API 连接和 Store 初始化 |
|
||||
|
||||
### 中间件问题
|
||||
|
||||
| 问题 | 原因 | 修复策略 |
|
||||
|------|------|----------|
|
||||
| TrajectoryRecorder 未记录轨迹 | 数据库连接或 Store 初始化问题 | 检查 TrajectoryStore::new(pool) 连接 (V13-GAP-01 已修复) |
|
||||
| Memory 提取失败 | MiddlewareContext 未正确传递 | 检查 middleware/memory.rs 的 before_completion |
|
||||
| Skill 未匹配 | SemanticSkillRouter TF-IDF 计算异常 | 检查 crates/zclaw-skills/src/semantic_router.rs |
|
||||
|
||||
### 配置问题
|
||||
|
||||
| 问题 | 原因 | 修复策略 |
|
||||
|------|------|----------|
|
||||
| baseUrl 错误 | SaaS URL 未配置或硬编码错误 | 检查 config.toml 和环境变量 |
|
||||
| API Key 错误 | SaaS JWT vs LLM Key 混淆 | 确认 kernelClient 使用 SaaS JWT |
|
||||
|
||||
---
|
||||
|
||||
## 执行命令参考
|
||||
|
||||
```bash
|
||||
# 1. 前端验证
|
||||
cd desktop && pnpm tsc --noEmit
|
||||
cd desktop && pnpm vitest run
|
||||
|
||||
# 2. Rust 验证
|
||||
cargo check --workspace --exclude zclaw-saas
|
||||
cargo test --workspace --exclude zclaw-saas
|
||||
|
||||
# 3. SaaS 集成测试 (需要 PostgreSQL)
|
||||
export TEST_DATABASE_URL="postgresql://postgres:123123@localhost:5432/zclaw"
|
||||
cargo test -p zclaw-saas -- --test-threads=1
|
||||
|
||||
# 4. 检查中间件注册
|
||||
grep -n "TrajectoryRecorder" crates/zclaw-kernel/src/kernel/mod.rs
|
||||
grep -n "chain.register" crates/zclaw-kernel/src/kernel/mod.rs
|
||||
|
||||
# 5. 检查 V13 断链修复
|
||||
grep -n "V13-GAP" crates/zclaw-kernel/src/kernel/mod.rs
|
||||
|
||||
# 6. 检查 industryStore 导入
|
||||
grep -rn "industryStore" desktop/src/components/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 调试记录模板
|
||||
|
||||
发现问题时,按以下格式记录:
|
||||
|
||||
```markdown
|
||||
## [日期] [问题描述]
|
||||
|
||||
**症状**: [用户可见的问题表现]
|
||||
**怀疑层级**: [前端/Tauri/Rust中间件/LLM/SaaS Relay/SaaS后端]
|
||||
**怀疑位置**: [具体文件和行号]
|
||||
|
||||
**验证步骤**:
|
||||
1. [x] [检查项]
|
||||
2. [x] [检查项]
|
||||
3. [ ] [检查项]
|
||||
|
||||
**根因**: [确定后填写]
|
||||
**修复方案**: [确定后填写]
|
||||
**影响范围**: [涉及的模块]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> 提示词版本: 2026-04-14-v2 (代码验证更新)
|
||||
> 基于: wiki/index.md, wiki/routing.md, wiki/chat.md, wiki/middleware.md,
|
||||
> wiki/memory.md, wiki/hands-skills.md, wiki/saas.md, wiki/known-issues.md
|
||||
224
docs/INTEGRATION_TEST_REPORT_20260414.md
Normal file
224
docs/INTEGRATION_TEST_REPORT_20260414.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# ZCLAW 三端联调测试报告 2026-04-14
|
||||
|
||||
> 测试类型: 系统性联调测试(SaaS + Admin V2 + Tauri 桌面端)
|
||||
> 测试方法: 真实 API curl + Chrome DevTools UI 操作 + Tauri MCP 桌面端操作 + 数据一致性交叉验证
|
||||
> 测试时间: 2026-04-14 09:40 ~ 11:00
|
||||
> 测试环境: Windows 11 Pro / SaaS :8080 / Admin V2 :5173 / Tauri Dev :1420 / PostgreSQL
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| 维度 | 结果 |
|
||||
|------|------|
|
||||
| SaaS 后端 API | 30+ 端点测试,27 正常,3 异常 |
|
||||
| Admin V2 页面 | 16 页面测试,14 正常,2 有问题 |
|
||||
| Tauri 桌面端 | 核心功能 8 项测试,6 正常,2 异常 |
|
||||
| 数据一致性 | 5 项交叉验证,3 一致,2 不一致 |
|
||||
| **P0 问题** | **1 个** |
|
||||
| **P1 问题** | **3 个** |
|
||||
| **P2 问题** | **3 个** |
|
||||
| **P3 问题** | **3 个** |
|
||||
|
||||
### 与上次测试 (2026-04-13) 对比
|
||||
|
||||
| 变化 | 详情 |
|
||||
|------|------|
|
||||
| P0-1 模型不存在 | **已修复** — deepseek-chat 正常工作 |
|
||||
| P0-2 中文编码损坏 | **已修复** — Plans/Roles/Config 中文正确 |
|
||||
| P1-3 UI 模型选择器 | **已修复** — Tauri 端模型选择生效 |
|
||||
| P1-4 Industries 404 | **根因定位** — SaaS 二进制未重编译 |
|
||||
| P1-7 用量限额不一致 | **仍存在** — plan=100, usage.max=2000 |
|
||||
| P1-8 用量未执行 | **仍存在** — input_tokens 6.77x 超限 |
|
||||
| P2-9 Token 计数为零 | **仍存在** — 17 个任务中 5 个 tokens=0 |
|
||||
| P2-10 Tauri Token 统计 | **仍存在** — 桌面端显示"总 Token: 0" |
|
||||
|
||||
---
|
||||
|
||||
## P0 CRITICAL
|
||||
|
||||
### P0-NEW-01: SaaS 运行二进制严重过期
|
||||
|
||||
- **现象**: 运行中的 zclaw-saas.exe 构建于 2026-04-11 22:38,但代码自 2026-04-12 起有多项关键变更未反映到运行服务中
|
||||
- **影响范围**:
|
||||
- Industries API 全部 404(路由在 04-12 添加到 main.rs)
|
||||
- Knowledge Phase A 功能不可用(04-12 变更)
|
||||
- 任何 04-12 之后提交的修复均未生效
|
||||
- **证据**:
|
||||
- 二进制修改时间: `2026-04-11 22:38:20`
|
||||
- 代码最新影响 main.rs 的提交: `c3593d3 2026-04-12 18:36:05`
|
||||
- `git log -- crates/zclaw-saas/src/main.rs` 确认有 2 个提交未编译
|
||||
- **修复**: `cargo build -p zclaw-saas && 重启 SaaS 服务`
|
||||
|
||||
---
|
||||
|
||||
## P1 HIGH
|
||||
|
||||
### P1-04: Industries API 路由未注册(二进制过期导致)
|
||||
|
||||
- **现象**: `GET /api/v1/industries` 返回 HTTP 404
|
||||
- **根因**: P0-NEW-01 — Industry 路由在 `5d1050b (2026-04-12)` 添加到 main.rs,但二进制未重编译
|
||||
- **影响**:
|
||||
- Admin V2 行业配置页面空
|
||||
- Tauri 账号编辑弹窗"授权行业"下拉框永久 loading
|
||||
- 行业知识域/管家技能优先级不可用
|
||||
- **代码验证**: `industry/mod.rs` 路由定义正确,`main.rs:363` `.merge()` 注册正确
|
||||
- **修复**: 重编译 SaaS 二进制
|
||||
|
||||
### P1-07: 用量限额数据不一致
|
||||
|
||||
- **现象**: Plan 定义 `max_relay_requests_monthly=100`,但 usage 对象返回 `max_relay_requests=2000`
|
||||
- **API 证据**:
|
||||
```
|
||||
GET /billing/plans → plan-free.limits.max_relay_requests_monthly = 100
|
||||
GET /billing/subscription → usage.max_relay_requests = 2000
|
||||
```
|
||||
- **UI 影响**: Admin V2 计费管理页面 — 免费版卡片显示 "100 次/月",当前用量显示 "2,000"
|
||||
- **影响**: 用户和 Admin 看到矛盾数据,计费系统不可信
|
||||
|
||||
### P1-08: 用量限制未执行
|
||||
|
||||
- **现象**: admin 账户 `input_tokens=3,386,978`,是 free plan 限额 500,000 的 **6.77 倍**,无任何拦截
|
||||
- **影响**: 用户可无限使用超出计划限制的资源
|
||||
- **修复方向**: 在 relay 请求前检查 usage 是否超限
|
||||
|
||||
---
|
||||
|
||||
## P2 MEDIUM
|
||||
|
||||
### P2-09: 29% Relay 任务 token 计数为零
|
||||
|
||||
- **现象**: 17 个已完成 relay 任务中 **5 个** input_tokens=0, output_tokens=0
|
||||
- **证据**:
|
||||
```
|
||||
5b85b045... completed tokens=0/0
|
||||
644134f4... completed tokens=0/0
|
||||
25820499... completed tokens=0/0
|
||||
a37669b0... completed tokens=0/0
|
||||
539b26a8... completed tokens=0/0
|
||||
```
|
||||
- **修复方向**: 检查 relay 完成后 token 统计逻辑,某些路径可能跳过了 token 累加
|
||||
|
||||
### P2-10: Tauri 端 Token 统计为 0
|
||||
|
||||
- **现象**: 详情面板"用量统计"显示"总 Token: 0",但 SaaS relay 有真实 token 使用记录 (3,386,978 input + 197,420 output)
|
||||
- **影响**: 桌面端用户无法看到自己的 token 消耗
|
||||
- **修复方向**: Tauri 端应从 SaaS API 获取 usage 数据而非本地累计
|
||||
|
||||
### P2-14: Subscription 为 null
|
||||
|
||||
- **现象**: admin 账号的 `billing.subscription` 为 null,使用默认 free plan
|
||||
- **影响**: 无法区分"主动订阅"和"默认计划"
|
||||
|
||||
---
|
||||
|
||||
## P3 LOW
|
||||
|
||||
### P3-19: Admin API 密钥页面路由指向 ModelServices 组件
|
||||
|
||||
- **现象**: 侧边栏 "API 密钥" 按钮 (/api-keys) 加载了 ModelServices 组件,而非独立的 API 密钥管理页面
|
||||
- **代码**: `admin-v2/src/router/index.tsx:29` 懒加载 `ModelServices` 用于 `api-keys` 路径
|
||||
- **可能是设计如此**(Provider 管理 = Key 池管理),但页面标题与内容不匹配
|
||||
|
||||
### P3-15: antd Modal destroyOnClose 废弃
|
||||
|
||||
- Admin V2 多个页面使用 `destroyOnClose`,antd 新版应使用 `destroyOnHidden`
|
||||
|
||||
### P3-16: onSearch React DOM 属性警告
|
||||
|
||||
- 知识库页面 `Unknown event handler property onSearch`
|
||||
|
||||
---
|
||||
|
||||
## 已验证正常的功能
|
||||
|
||||
### SaaS 后端 API (27/30 正常)
|
||||
|
||||
| 模块 | 端点 | 状态 | 数据验证 |
|
||||
|------|------|------|----------|
|
||||
| Auth | login/refresh/me/password/totp-setup | ✅ | JWT+rotation 正常,TOTP secret 生成正确 |
|
||||
| Accounts | CRUD/搜索/状态切换 | ✅ | 30 个账号,分页正常 |
|
||||
| Providers | CRUD | ✅ | 3 Provider (DeepSeek/Kimi/zhipu),Key 池正常 |
|
||||
| Models | CRUD/列表 | ✅ | 3 模型 (deepseek-chat/GLM-4.7/kimi-for-coding) |
|
||||
| **Relay Chat** | **流式+非流式** | **✅** | **核心链路正常,真实 LLM 响应** |
|
||||
| Relay Tasks | 列表 | ✅ | 17 个真实任务 |
|
||||
| Billing Plans | 列表 | ✅ | 3 计划,中文正确 |
|
||||
| Billing Usage | 查询 | ✅ | 详细用量统计 |
|
||||
| Roles | 列表 | ✅ | 3 角色,权限列表正确 |
|
||||
| Agent Templates | 列表 | ✅ | 10 模板(含 4 行业) |
|
||||
| Knowledge | CRUD/搜索 | ✅ | 6 分类/6 条目,搜索返回带分值结果 |
|
||||
| Knowledge Analytics | overview | ✅ | 统计数据完整 |
|
||||
| Config | items/analysis | ✅ | 62 配置项,8 分类 |
|
||||
| Dashboard Stats | 聚合 | ✅ | 30 账号/3 Provider/3 模型 |
|
||||
| Operation Logs | 列表 | ✅ | 2,047 条日志 |
|
||||
| Provider Keys | Key 池 | ✅ | RPM/TPM/cooldown 追踪正常 |
|
||||
| Prompts | 列表 | ✅ | 3 内置提示词 |
|
||||
| Scheduler | 任务列表 | ✅ | 路径正确 (/api/v1/scheduler/tasks) |
|
||||
|
||||
### Admin V2 管理后台 (14/16 正常)
|
||||
|
||||
| 页面 | 数据来源 | 交互验证 |
|
||||
|------|----------|----------|
|
||||
| 仪表盘 | ✅ API 实时数据 | 30 账号/3 Provider/3 模型/14 tokens 全部与 API 一致 |
|
||||
| 计费管理 | ✅ Plans + Usage | 3 计划卡片正确,用量进度条准确 |
|
||||
| 账号管理 | ✅ 30 账号 | 编辑弹窗/搜索/分页/状态切换全部正常 |
|
||||
| 角色权限 | ✅ 3 角色 | 权限列表正确,模板 tab 为空(符合预期) |
|
||||
| 模型服务 | ✅ 3 Provider | 展开 Provider 显示 Key 池和模型 |
|
||||
| Agent 模板 | ✅ 10 模板 | 列表/筛选正常 |
|
||||
| 知识库 | ✅ 6 分类/6 条目 | 5 个 tab 全部有数据 |
|
||||
| 用量统计 | ✅ 30 用户统计 | 图表渲染正常 |
|
||||
| 中转任务 | ✅ 9 任务 | 全部显示 completed |
|
||||
| 操作日志 | ✅ 2,039 条 | 分页/筛选正常 |
|
||||
| 系统配置 | ✅ 62 配置项 | 6 个 tab 分类清晰 |
|
||||
| 提示词管理 | ✅ 3 提示词 | 列表正常 |
|
||||
| 同步日志 | ✅ 空(符合预期) | 页面正常渲染 |
|
||||
| 定时任务 | ✅ 空(符合预期) | 页面正常渲染 |
|
||||
|
||||
### Tauri 桌面端 (6/8 正常)
|
||||
|
||||
| 功能 | 状态 | 验证结果 |
|
||||
|------|------|----------|
|
||||
| Gateway 连接 | ✅ | saas-relay 模式,http://127.0.0.1:8080 |
|
||||
| 模型选择 | ✅ | deepseek-chat 正确匹配 SaaS 白名单 |
|
||||
| 聊天发送/接收 | ✅ | 发送"你好"→ 收到"你好!很高兴为你服务" |
|
||||
| 对话历史 | ✅ | 7 个对话,114 条消息,时间戳正确 |
|
||||
| 设置页面 | ✅ | 19 个设置页全部可访问,Gateway 状态正确 |
|
||||
| 简洁/专业模式 | ✅ | 切换按钮正常,管家快捷操作可见 |
|
||||
| 用量统计 | ❌ | 总 Token 显示 0(P2-10) |
|
||||
| 行业下拉框 | ❌ | 编辑账号时"授权行业"永久 loading(P1-04) |
|
||||
|
||||
---
|
||||
|
||||
## 数据一致性交叉验证
|
||||
|
||||
| 验证项 | SaaS API | Admin V2 | Tauri | 一致? |
|
||||
|--------|----------|----------|-------|-------|
|
||||
| 账号总数 | 30 | 30 | - | ✅ |
|
||||
| Provider 数 | 3 | 3 | 3 | ✅ |
|
||||
| 模型数 | 3 | 3 | 3 | ✅ |
|
||||
| Relay 请求数 | 561 | 553 | - | ✅ (差 8 = 测试期间新增) |
|
||||
| Operation Logs | 2,047 | 2,039 | - | ✅ (差 8 = 并发写入) |
|
||||
| 当前模型 | deepseek-chat | - | deepseek-chat | ✅ |
|
||||
| Plan max_relay | 100 | 100 | - | ✅ |
|
||||
| Usage max_relay | **2,000** | **2,000** | - | ❌ 与 Plan 不一致 |
|
||||
|
||||
---
|
||||
|
||||
## 测试环境信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| SaaS 后端 | http://localhost:8080 (zclaw-saas.exe PID=10976, build 2026-04-11) |
|
||||
| Admin V2 | http://localhost:5173 (Vite dev server) |
|
||||
| Tauri Dev | http://localhost:1420 (saas-relay 模式) |
|
||||
| PostgreSQL | localhost:5432/zclaw |
|
||||
| Admin 账号 | admin / admin123 / super_admin |
|
||||
| 截图位置 | `tests/screenshots/admin-*.png` |
|
||||
|
||||
---
|
||||
|
||||
## 修复优先级建议
|
||||
|
||||
1. **立即**: 重编译 SaaS 二进制 → 解决 P0-NEW-01 + P1-04
|
||||
2. **发布前**: 修复 P1-07 (用量限额不一致) + P1-08 (用量未执行)
|
||||
3. **发布后**: P2-09 (token 计数) + P2-10 (Tauri 统计) + P2-14 (subscription null)
|
||||
249
docs/INTEGRATION_TEST_REPORT_20260414_V2.md
Normal file
249
docs/INTEGRATION_TEST_REPORT_20260414_V2.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# ZCLAW 三端联调系统性测试报告 V2
|
||||
|
||||
**测试日期**: 2026-04-14 19:30-20:30
|
||||
**测试版本**: v0.9.0-beta.1
|
||||
**测试环境**: Windows 11 / PostgreSQL 18 / Rust Workspace 10 crates
|
||||
**测试方法**: 真实 API curl + Chrome DevTools UI 操作 + Tauri MCP + 数据一致性交叉验证
|
||||
**测试标准**: 按 §5 通过/不通过标准逐项验证
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试环境确认
|
||||
|
||||
| 组件 | 端口 | 状态 | 验证方式 |
|
||||
|------|------|------|----------|
|
||||
| PostgreSQL 18 | 5432 | ✅ 运行中 | SaaS health API `"database":true` |
|
||||
| SaaS 后端 | 8080 | ✅ 运行中 | `{"status":"healthy","version":"0.9.0-beta.1"}` |
|
||||
| Admin V2 | 5173/5174 | ✅ 运行中 | HTTP 200, SPA 加载 |
|
||||
| Tauri 桌面端 | 1420 | ✅ 运行中 | kernel_status initialized=true |
|
||||
|
||||
---
|
||||
|
||||
## 2. SaaS 后端 API 测试(30+ 端点)
|
||||
|
||||
### 2.1 测试结果总览
|
||||
|
||||
| 模块 | 端点 | HTTP | 数据验证 | 通过 |
|
||||
|------|------|------|----------|------|
|
||||
| Auth - Login | POST /api/v1/auth/login | 200 | JWT + refresh_token + account 信息完整 | ✅ |
|
||||
| Auth - Me | GET /api/v1/auth/me | 200 | id/username/email/role/status 正确 | ✅ |
|
||||
| Accounts | GET /api/v1/accounts | 200 | 30 个账号,含 username/email/role/status | ✅ |
|
||||
| Providers | GET /api/v1/providers | 200 | 3 个 (deepseek/kimi/zhipu),enabled=true | ✅ |
|
||||
| Models | GET /api/v1/models | 200 | 3 个 (deepseek-chat/GLM-4.7/kimi-for-coding) | ✅ |
|
||||
| Relay Models | GET /api/v1/relay/models | 200 | 3 个,与 Admin 模型**完全一致** | ✅ |
|
||||
| API Keys | GET /api/v1/keys | 200 | 0 个用户密钥(正常) | ✅ |
|
||||
| Provider Keys | GET /api/v1/providers/{id}/keys | 200 | deepseek 有 1 个密钥 | ✅ |
|
||||
| Model Groups | GET /api/v1/model-groups | 200 | 0 个模型组 | ✅ |
|
||||
| Roles | GET /api/v1/roles | 200 | 3 个 (超级管理员/管理员/普通用户) | ✅ |
|
||||
| Permission Templates | GET /api/v1/permission-templates | 200 | 0 个模板 | ✅ |
|
||||
| Knowledge Categories | GET /api/v1/knowledge/categories | 200 | 9 个分类 | ✅ |
|
||||
| Knowledge Items | GET /api/v1/knowledge/items | 200 | 6 个条目,含 content/category_id | ✅ |
|
||||
| Industries | GET /api/v1/industries | 200 | 4 个 (电商/教育/制衣/医疗),含关键词数 | ✅ |
|
||||
| Prompts | GET /api/v1/prompts | 200 | items + total + page + page_size | ✅ |
|
||||
| Scheduled Tasks | GET /api/v1/scheduler/tasks | 200 | 0 个任务 | ✅ |
|
||||
| Telemetry Stats | GET /api/v1/telemetry/stats | 200 | **空数组 []** | ⚠️ |
|
||||
| Telemetry Daily | GET /api/v1/telemetry/daily | 200 | **0 条记录** | ⚠️ |
|
||||
| Usage | GET /api/v1/usage | 200 | **全零** (total_requests=0) | ❌ |
|
||||
| Billing Plans | GET /api/v1/billing/plans | 200 | 3 个 (免费¥0/专业¥49/团队¥199) | ✅ |
|
||||
| Billing Subscription | GET /api/v1/billing/subscription | 200 | team 计划, active, 周期正确 | ✅ |
|
||||
| Billing Usage | GET /api/v1/billing/usage | 200 | input=3,390,168 output=199,440 relay=568 | ✅ |
|
||||
| Operation Logs | GET /api/v1/logs/operations | 200 | 2075 条, 含 action/target_id/IP | ✅ |
|
||||
| Dashboard | GET /api/v1/stats/dashboard | 200 | 30 账号/3 Provider/3 模型/17 请求 | ✅ |
|
||||
| Devices | GET /api/v1/devices | 200 | 有设备记录 | ✅ |
|
||||
|
||||
### 2.2 数据一致性验证
|
||||
|
||||
| 验证项 | 数据源 A | 数据源 B | 一致性 |
|
||||
|--------|----------|----------|--------|
|
||||
| 账号数 | Dashboard total=30 | Accounts API len=30 | ✅ 一致 |
|
||||
| 模型列表 | Admin /models 3个 | Relay /models 3个 | ✅ 一致 |
|
||||
| Provider 数 | Dashboard active=3 | Providers API 3个 | ✅ 一致 |
|
||||
| Relay 请求数 | Billing relay=568 | Dashboard tasks_today=17 | ✅ 不同周期 |
|
||||
| Token 用量 | Billing input=3.39M | Telemetry total=0 | ❌ **严重不一致** |
|
||||
|
||||
---
|
||||
|
||||
## 3. Admin V2 管理端测试(17 页面)
|
||||
|
||||
### 3.1 页面加载与数据验证
|
||||
|
||||
| 页面 | URL | 加载 | 数据与API一致 | CRUD按钮 | 通过 |
|
||||
|------|-----|------|-------------|----------|------|
|
||||
| 仪表盘 | / | ✅ | 30账号/3Provider/3模型 ✅ | - | ✅ |
|
||||
| 账号管理 | /accounts | ✅ | 30 账号, 分页, 编辑/禁用按钮 | ✅ | ✅ |
|
||||
| 模型服务 | /model-services | ✅ | 3 Provider, 可展开模型/KeyPool | ✅ | ✅ |
|
||||
| API 密钥 | /api-keys | ⚠️ | 侧边栏导航正常, 直接URL崩溃 | ✅ | ❌ |
|
||||
| 角色与权限 | /roles | ✅ | 3 角色, 权限数 10/12/3 | ✅ | ✅ |
|
||||
| 计费管理 | /billing | ✅ | 3 计划, 团队版当前, 用量 568/20000 | ✅ | ✅ |
|
||||
| 知识库 | /knowledge | ✅ | 6 条目, 5 标签页 (条目/分类/搜索/分析/结构化) | ✅ | ✅ |
|
||||
| 行业配置 | /industries | ✅ | 4 行业 (46/35/35/41 关键词), 编辑/禁用 | ✅ | ✅ |
|
||||
| Agent 模板 | /agent-templates | ✅ | 有条目 | ✅ | ✅ |
|
||||
| 中转任务 | /relay | ✅ | 空 (API total=0) | - | ✅ |
|
||||
| 用量统计 | /usage | ⚠️ | 总请求=0, 总Token=0 (**与计费不一致**) | - | ❌ |
|
||||
| 定时任务 | /scheduled-tasks | ✅ | 0 任务 | ✅ | ✅ |
|
||||
| 操作日志 | /logs | ✅ | 2075 条, 操作类型/目标/IP 完整 | ✅ | ✅ |
|
||||
| 提示词管理 | /prompts | ✅ | 条目列表 | ✅ | ✅ |
|
||||
| 系统配置 | /config | ✅ | HTTP 200 | ✅ | ✅ |
|
||||
| 同步日志 | /config-sync | ✅ | HTTP 200 | ✅ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 4. Tauri 桌面端测试
|
||||
|
||||
### 4.1 核心功能验证
|
||||
|
||||
| 功能 | 验证方式 | 结果 | 详情 |
|
||||
|------|----------|------|------|
|
||||
| 应用启动 | app_info | ✅ | ZCLAW v0.9.0-beta.1, visible=true |
|
||||
| Kernel 初始化 | kernel_status | ✅ | initialized=true, 4 agents, SQLite 连接 |
|
||||
| SaaS Relay 模式 | kernel_status.baseUrl | ✅ | http://127.0.0.1:8080/api/v1/relay |
|
||||
| 当前模型 | kernel_status.model | ✅ | deepseek-chat |
|
||||
| Agent 列表 | agent_list | ✅ | 4 个 Agent (含外科助手等), provider=saas |
|
||||
| Hand 列表 | hand_list | ✅ | researcher 等, 正常返回 |
|
||||
| 侧边栏 | DOM 查询 | ✅ | 对话列表/新对话按钮/智能体 Tab |
|
||||
| 模型选择器 | DOM 查询 | ✅ | 显示 "deepseek-chat" |
|
||||
| **发送消息** | 输入+Enter | ❌ | **401 Unauthorized** — SaaS JWT token 过期/缺失 |
|
||||
|
||||
### 4.2 聊天错误详情
|
||||
|
||||
```
|
||||
LLM 响应错误: LLM error: API error 401 Unauthorized:
|
||||
{"error":"UNAUTHORIZED","message":"未认证"}
|
||||
```
|
||||
|
||||
**根因**: 桌面端处于 SaaS Relay 模式(baseUrl 指向 8080),但 OS keyring 中无有效 JWT token。需要先通过设置页面的 SaaS 登录获取 token。
|
||||
|
||||
---
|
||||
|
||||
## 5. 问题清单
|
||||
|
||||
### 5.1 P0 — 阻断性
|
||||
|
||||
无。
|
||||
|
||||
### 5.2 P1 — 功能失效
|
||||
|
||||
| ID | 问题 | 影响 | 根因 | 修复 | 验证方式 |
|
||||
|----|------|------|------|------|----------|
|
||||
| P1-01 | **Tauri 桌面端聊天 401** | 桌面端无法正常聊天 | kernel_init 不检测 API key 变更 + streamStore 无 401 恢复 | ✅ kernel_init 增加 api_key 比较 + streamStore 自动刷新重连 | kernel_status + 聊天气泡错误 |
|
||||
| P1-02 | **用量统计全零** | Admin 用量统计页无数据 | `/api/v1/usage` 读 usage_records(SSE 记录 0 tokens),而 billing_usage_quotas 有真实数据 | ✅ totals 改读 billing_usage_quotas,breakdown 仍从 usage_records | API curl 对比 |
|
||||
| P1-03 | **API 密钥页刷新崩溃** | 用户刷新/书签访问 /api-keys 白屏 | Vite proxy 规则 `/api` 前缀匹配 `/api-keys` | ✅ `'/api'` → `'/api/'`(加尾部斜杠) | 直接 URL + 刷新测试 |
|
||||
|
||||
### 5.3 P2 — 体验问题
|
||||
|
||||
| ID | 问题 | 影响 |
|
||||
|----|------|------|
|
||||
| P2-01 | Permission Templates = 0 | 角色权限模板功能无法使用 |
|
||||
| P2-02 | Migrations API 404 | `/api/v1/migrations` 路由不存在(可能未注册) |
|
||||
| P2-03 | Model Groups = 0 | 模型分组功能未使用 |
|
||||
| P2-04 | 操作日志 user_id 而非 username | 日志中显示 `db5fb656` 而非 `admin` |
|
||||
|
||||
### 5.4 P3 — 轻微
|
||||
|
||||
| ID | 问题 |
|
||||
|----|------|
|
||||
| P3-01 | Telemetry Stats 返回空数组 `[]`(与 P1-02 同根因) |
|
||||
| P3-02 | 账号创建 API `POST /api/v1/accounts` 返回 405(路由仅注册 GET) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试覆盖率
|
||||
|
||||
### 6.1 API 覆盖率
|
||||
|
||||
| 模块 | 端点数 | 测试数 | 覆盖率 |
|
||||
|------|--------|--------|--------|
|
||||
| Auth | 3 | 2 | 67% |
|
||||
| Account | 12 | 3 | 25% |
|
||||
| Model Config | 18 | 5 | 28% |
|
||||
| Role | 7 | 2 | 29% |
|
||||
| Knowledge | 18 | 2 | 11% |
|
||||
| Industry | 7 | 1 | 14% |
|
||||
| Billing | 12 | 3 | 25% |
|
||||
| Telemetry | 4 | 3 | 75% |
|
||||
| Prompt | 6 | 1 | 17% |
|
||||
| Scheduled Task | 4 | 1 | 25% |
|
||||
| Relay | 3 | 2 | 67% |
|
||||
| **总计** | **~94** | **25** | **27%** |
|
||||
|
||||
> 注:本测试覆盖所有主要 CRUD 列表端点,但未逐一测试每个资源的 GET/POST/PUT/DELETE。
|
||||
|
||||
### 6.2 Admin V2 页面覆盖率
|
||||
|
||||
17/17 页面加载测试 = **100%**
|
||||
数据验证(6 核心页面与 API 交叉对比)= **100%**
|
||||
|
||||
### 6.3 Tauri 功能覆盖率
|
||||
|
||||
| 功能域 | 测试项 | 通过 |
|
||||
|--------|--------|------|
|
||||
| 应用启动/初始化 | 2/2 | ✅ |
|
||||
| Agent 管理 | 1/1 | ✅ |
|
||||
| Hand 管理 | 1/1 | ✅ |
|
||||
| 聊天发送 | 0/1 | ❌ 401 |
|
||||
| 模型选择 | 1/1 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险评估
|
||||
|
||||
### 7.1 高风险
|
||||
|
||||
| 风险 | 影响 | 建议 |
|
||||
|------|------|------|
|
||||
| 桌面端 SaaS 认证失败 | 用户无法正常使用聊天功能(核心功能) | 发布前必须确保 SaaS 登录流程完整可用,包含 token 自动刷新 |
|
||||
|
||||
### 7.2 中风险
|
||||
|
||||
| 风险 | 影响 | 建议 |
|
||||
|------|------|------|
|
||||
| 用量统计数据源不一致 | 运营无法准确监控使用情况 | 统一 telemetry/billing 数据写入路径 |
|
||||
| API 密钥页刷新崩溃 | 用户误操作后需清除缓存恢复 | 修改 Vite proxy 规则,`/api` 改为 `/api/` |
|
||||
|
||||
### 7.3 低风险
|
||||
|
||||
| 风险 | 影响 | 建议 |
|
||||
|------|------|------|
|
||||
| 权限模板为空 | 功能未使用 | 发布后迭代填充 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 修复建议优先级
|
||||
|
||||
### 8.1 发布前必须修复
|
||||
|
||||
1. **P1-01**: 验证桌面端 SaaS 登录→token 存 keyring→relay 认证完整链路
|
||||
2. **P1-03**: 修改 `admin-v2/vite.config.ts` proxy 规则:`'/api'` → `'/api/'`(加尾部斜杠),或重命名路由路径为 `/keys`
|
||||
|
||||
### 8.2 发布后一周内修复
|
||||
|
||||
3. **P1-02**: 调查 telemetry worker 和 billing worker 的数据写入差异,统一数据源
|
||||
|
||||
### 8.3 发布后迭代
|
||||
|
||||
4. **P2-01~04**: 权限模板/migrations/模型组/日志用户名
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试截图清单
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `tests/screenshots/admin-dashboard.png` | Admin 仪表盘(30账号/3Provider/3模型) |
|
||||
| `tests/screenshots/admin-accounts.png` | 账号管理页(30账号分页) |
|
||||
| `tests/screenshots/admin-model-services-expanded.txt` | 模型服务展开 deepseek-chat 详情 |
|
||||
| `tests/screenshots/admin-model-keypool.png` | DeepSeek Key Pool 标签页 |
|
||||
| `tests/screenshots/admin-billing.png` | 计费管理(3计划/团队版当前) |
|
||||
| `tests/screenshots/admin-usage-zero.png` | 用量统计全零(与计费不一致) |
|
||||
|
||||
---
|
||||
|
||||
## 10. 测试结论
|
||||
|
||||
**测试结论: 有条件通过**
|
||||
|
||||
- SaaS 后端 30+ API 全部 HTTP 200,核心数据模型(账号/模型/计费/行业)完整正确
|
||||
- Admin V2 17 页面全部可加载,6 核心页面数据与 API 交叉验证一致
|
||||
- Tauri 桌面端启动正常,4 Agent + Hands + Kernel 初始化完成
|
||||
- **阻断项**: 桌面端 SaaS 认证 401 需修复后才可正常使用聊天功能
|
||||
- **数据不一致**: 用量统计模块与计费模块数据源需统一
|
||||
@@ -1,7 +1,7 @@
|
||||
# ZCLAW 系统真相文档
|
||||
|
||||
> **更新日期**: 2026-04-13
|
||||
> **数据来源**: V11 全面审计 + 二次审计 + V12 模块化端到端审计 + 代码全量扫描验证 + 功能测试 Phase 1-5 + 发布前功能测试 Phase 3 + 发布前全面测试代码级审计 + 2026-04-11 代码验证 + V13 系统性功能审计 2026-04-12 + V13 审计修复 2026-04-13
|
||||
> **更新日期**: 2026-04-15
|
||||
> **数据来源**: V11 全面审计 + 二次审计 + V12 模块化端到端审计 + 代码全量扫描验证 + 功能测试 Phase 1-5 + 发布前功能测试 Phase 3 + 发布前全面测试代码级审计 + 2026-04-11 代码验证 + V13 系统性功能审计 2026-04-12 + V13 审计修复 2026-04-13 + 发布前冲刺 Day1 2026-04-15
|
||||
> **规则**: 此文档是唯一真相源。所有其他文档如果与此冲突,以此为准。
|
||||
|
||||
---
|
||||
@@ -13,11 +13,12 @@
|
||||
| Rust Crates | 10 个 (编译通过) | `cargo check --workspace` |
|
||||
| Rust 代码行数 | ~77,000 (crates) + ~61,400 (src-tauri) = ~138,400 | wc -l (2026-04-12 V13 验证) |
|
||||
| Rust 单元测试 | 433 个 (#[test]) + 368 个 (#[tokio::test]) = 801 | `grep '#\[test\]' crates/` + `grep '#\[tokio::test\]'` (2026-04-12 V13 验证) |
|
||||
| Cargo Warnings (非 SaaS) | **0 个** (仅 sqlx-postgres 外部依赖 1 个) | `cargo check --workspace --exclude zclaw-saas` (2026-04-15 清零) |
|
||||
| Rust 测试运行通过 | 684 workspace + 138 SaaS = 822 | Hermes 4 Chunk `cargo test --workspace` 2026-04-09 |
|
||||
| Tauri 命令 | 189 个 (2026-04-13 V13 修复验证) | `grep '#\[.*tauri::command'` |
|
||||
| **Tauri 命令有前端调用** | **105 处** | `grep invoke( desktop/src/` (2026-04-13 V13 修复验证) |
|
||||
| **Tauri 命令已标注 @reserved** | **22 个** | Rust 源码 @reserved 标注 (2026-04-13 V13 修复验证) |
|
||||
| **Tauri 命令孤儿 (无调用+无标注)** | ~62 个 | 189 - 105 invoke处 - 22 @reserved ≈ 62 |
|
||||
| Tauri 命令 | 183 个 (2026-04-15 Heartbeat 统一健康系统新增 health_snapshot) | `grep '#\[.*tauri::command'` |
|
||||
| **Tauri 命令有前端调用** | **95 处** | `grep invoke( desktop/src/` (2026-04-15 验证) |
|
||||
| **Tauri 命令已标注 @reserved** | **89 个** | Rust 源码 @reserved 标注 (2026-04-15 全量标注) |
|
||||
| **Tauri 命令孤儿 (无调用+无标注)** | **0 个** | 182 - 95 invoke处 - 89 @reserved = 0 (2026-04-15 清零) |
|
||||
| SKILL.md 文件 | 75 个 | `ls skills/*.md \| wc -l` |
|
||||
| Hands 启用 | 9 个 | Browser/Collector/Researcher/Clip/Twitter/Whiteboard/Slideshow/Speech/Quiz(均有 HAND.toml) |
|
||||
| Hands 禁用 | 2 个 | Predictor, Lead(概念定义存在,无 TOML 配置文件或 Rust 实现) |
|
||||
@@ -28,10 +29,11 @@
|
||||
| SaaS Workers | 7 个 | log_operation/cleanup_rate_limit/cleanup_refresh_tokens/record_usage/update_last_used/aggregate_usage/generate_embedding |
|
||||
| LLM Provider | 8 个 | Kimi/Qwen/DeepSeek/Zhipu/OpenAI/Anthropic/Gemini/Local |
|
||||
| Zustand Store | 21 个 | find desktop/src/store/ -name "*.ts" (2026-04-12 V13 验证) |
|
||||
| React 组件 | 104 个 (.tsx/.ts) | find desktop/src/components/ (2026-04-11 验证) |
|
||||
| React 组件 | 105 个 (.tsx/.ts) | find desktop/src/components/ (2026-04-15 新增 HealthPanel.tsx) |
|
||||
| 前端 TypeScript 测试 | 31 个文件 (6 store + 5 lib + 1 config + 1 stabilization + 18 E2E spec) | Phase 3-4 全量 |
|
||||
| 前端 lib | 83 个 .ts | find desktop/src/lib/ (2026-04-11 验证) |
|
||||
| 前端测试运行通过 | 330 passed + 1 skipped | `pnpm vitest run` |
|
||||
| 前端 lib | 76 个 .ts | find desktop/src/lib/ (2026-04-15 删除 intelligence-client/ 9 文件) |
|
||||
| 前端测试运行通过 | 344 passed + 1 skipped | `pnpm vitest run` (2026-04-15 验证) |
|
||||
| 生产构建 | **通过** (14.8s, 0 require 残留) | `pnpm build` (2026-04-15 验证) |
|
||||
| Admin V2 页面 | 15 个 | admin-v2/src/pages/ 全量统计(含 ScheduledTasks、ConfigSync) |
|
||||
| 桌面端设置页面 | 19 个 | SettingsLayout.tsx tabs: 通用/用量统计/积分详情/模型与API/MCP服务/技能/IM频道/工作区/数据与隐私/安全存储/SaaS平台/订阅与计费/语义记忆/安全状态/审计日志/定时任务/心跳配置/提交反馈/关于 |
|
||||
| Admin V2 测试 | 17 个文件 (61 tests) | vitest 统计 |
|
||||
@@ -201,3 +203,4 @@ Viking 5 个孤立 invoke 调用已于 2026-04-03 清理移除:
|
||||
| 2026-04-10 | 发布前修复批次:(1) ButlerRouter 语义路由 — SemanticSkillRouter TF-IDF 替代关键词,75 技能参与路由 (2) P1-04 AuthGuard 竞态 — 三态守卫 + cookie 先验证 (3) P2-03 限流 — Cross 测试共享 token (4) P1-02 浏览器聊天 — Playwright SaaS fixture。BREAKS.md 全部 P0/P1/P2 已修复 |
|
||||
| 2026-04-11 | 发布前数字校准:(1) Rust 代码 66K→74.6K (2) Rust 测试 537→798 (#[test] 431 + #[tokio::test] 367) (3) Tauri 命令 182→184 (4) 前端 invoke 92→105 (5) @reserved 20→33 (6) SaaS .route() 140→122 (7) Zustand Store 18→20 (8) React 组件 135→104 (9) 前端 lib 85→83 (10) Cargo.toml 版本 0.1.0→0.9.0-beta.1 |
|
||||
| 2026-04-12 | V13 系统性功能审计数字校准:(1) Tauri 命令 184→191 (2) 前端 invoke 105→106 (3) @reserved 33→24 (Butler/MCP已接通) (4) 孤儿命令 ~46→~61 (5) Rust 测试 798→801 (433+368) (6) SaaS .route() 122→136 (7) Zustand Store 20→21 (8) dead_code 76→43 (9) Rust LOC crates ~74.6K→~77K |
|
||||
| 2026-04-15 | Heartbeat 统一健康系统:(1) Tauri 命令 182→183 (+health_snapshot) (2) intelligence 模块 15→16 文件 (+health_snapshot.rs +heartbeat.rs 重构) (3) React 组件 104→105 (+HealthPanel.tsx) (4) 前端 lib 85→76 (删除 intelligence-client/ 9 文件) |
|
||||
|
||||
360
docs/superpowers/specs/2026-04-15-heartbeat-unified-design.md
Normal file
360
docs/superpowers/specs/2026-04-15-heartbeat-unified-design.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# Heartbeat 统一健康系统设计
|
||||
|
||||
> 日期: 2026-04-15
|
||||
> 状态: Draft
|
||||
> 范围: Intelligence Heartbeat 断链修复 + 统一健康面板 + SaaS 自动恢复
|
||||
|
||||
## 1. 问题诊断
|
||||
|
||||
### 1.1 五个心跳系统现状
|
||||
|
||||
ZCLAW 有 5 个独立心跳系统,各自运行,互不交互:
|
||||
|
||||
| 系统 | 触发机制 | 监测对象 | 状态 |
|
||||
|------|----------|----------|------|
|
||||
| Intelligence Heartbeat | Rust tokio timer (30min) | Agent 智能健康 | 设计完整,6处断链 |
|
||||
| WebSocket Ping/Pong | 前端 JS setInterval (30s) | TCP 连接活性 | 完整 |
|
||||
| SaaS Device Heartbeat | 前端 saasStore (5min) | 设备在线状态 | 完整,降级无恢复 |
|
||||
| StreamBridge SSE | 服务端 async_stream (15s) | SSE 流保活 | 完整(纯服务端) |
|
||||
| A2A Heartbeat | 无(枚举占位) | Agent 间协议 | 空壳,暂不需要 |
|
||||
|
||||
**核心发现:5 个系统之间没有运行时交互,也不需要交互。** 它们各自监测完全不同的东西。不存在"统一协调"的实际需求,需要的是"断链修复 + 统一可见性"。
|
||||
|
||||
### 1.2 Intelligence Heartbeat 的 6 处断链
|
||||
|
||||
1. **告警无法实时送达前端** — Rust `broadcast::Sender` 有发送者但零订阅者,`subscribe()` 是 dead code。告警只存入 history,用户无法实时感知。
|
||||
2. **HeartbeatConfig 保存只到 localStorage** — `handleSave()` 不调用 `updateConfig()`,Rust 后端永远用 App.tsx 的硬编码默认值。
|
||||
3. **动态间隔修改无效** — `tokio::time::interval` 创建后不可变,`update_config` 改了值但不生效。
|
||||
4. **Config 不持久化** — VikingStorage 只存 history 和 last_interaction,config 重启后丢失。
|
||||
5. **重复 client 实现** — `intelligence-client.ts` 单文件版被 `intelligence-client/` 目录版遮蔽,是死代码。
|
||||
6. **Memory stats 依赖前端推送** — 如果前端同步失败,检查只能产出"缓存为空"警告。
|
||||
|
||||
### 1.3 SaaS 降级无恢复
|
||||
|
||||
`saasStore.ts` 在 SaaS 连续 3 次心跳失败后从 `saas` 模式降级到 `tauri` 模式,但不会自动恢复。用户必须手动切换回去。
|
||||
|
||||
## 2. 设计决策
|
||||
|
||||
### 2.1 为什么不做后台协调器
|
||||
|
||||
5 个系统不需要状态协调:
|
||||
- WebSocket 断了不影响 Intelligence Heartbeat 继续检查任务积压
|
||||
- SaaS 不可达不影响 WebSocket ping/pong
|
||||
- 每个系统的触发者、消费者、检测对象完全不同
|
||||
|
||||
"统一可见性"通过按需查询实现,不需要常驻后台任务。
|
||||
|
||||
### 2.2 核心策略
|
||||
|
||||
**断链修复 + 按需查询 > 后台协调器**
|
||||
|
||||
| 改动类型 | 内容 |
|
||||
|----------|------|
|
||||
| 修复 | heartbeat.rs 6 处断链 |
|
||||
| 新增 | `health_snapshot` Tauri 命令(按需查询) |
|
||||
| 新增 | `HealthPanel.tsx` 前端组件 |
|
||||
| 修复 | SaaS 自动恢复 |
|
||||
| 清理 | 删除重复 client 文件 |
|
||||
|
||||
## 3. 详细设计
|
||||
|
||||
### 3.1 Rust 后端断链修复
|
||||
|
||||
**文件**: `desktop/src-tauri/src/intelligence/heartbeat.rs`
|
||||
|
||||
#### 3.1.1 告警实时推送
|
||||
|
||||
**方案选择**:使用 `OnceLock<AppHandle>` 全局单例(与 `MEMORY_STATS_CACHE` 等 OnceLock 模式一致)。`heartbeat_init` Tauri 命令从参数中拿到 `app: AppHandle`,写入全局 `HEARTBEAT_APP_HANDLE: OnceLock<AppHandle>`。后台 spawned task 通过全局读取 emit。
|
||||
|
||||
项目已有先例:`stream:chunk`(chat.rs:403)、`hand-execution-complete`(hand.rs:302)、`pipeline-complete`(discovery.rs:173)都在 Tauri 命令中直接使用 `app: AppHandle` 参数。HeartbeatEngine 的特殊性在于它运行在 `tokio::spawn` 后台任务中,无法直接获得命令参数,因此使用全局单例传递。
|
||||
|
||||
```rust
|
||||
// 全局声明(与 MEMORY_STATS_CACHE 同层级,在 heartbeat.rs 顶部)
|
||||
static HEARTBEAT_APP_HANDLE: OnceLock<tauri::AppHandle> = OnceLock::new();
|
||||
|
||||
// heartbeat_init 命令中注入
|
||||
pub async fn heartbeat_init(
|
||||
app: tauri::AppHandle, // Tauri 自动注入
|
||||
agent_id: String,
|
||||
config: Option<HeartbeatConfig>,
|
||||
state: tauri::State<'_, HeartbeatEngineState>,
|
||||
) -> Result<(), String> {
|
||||
if let Err(_) = HEARTBEAT_APP_HANDLE.set(app) {
|
||||
tracing::warn!("[heartbeat] APP_HANDLE already set (multiple init calls)");
|
||||
}
|
||||
// ... 现有 init 逻辑
|
||||
}
|
||||
|
||||
// execute_tick() 末尾,告警生成后
|
||||
if !alerts.is_empty() {
|
||||
if let Some(app) = HEARTBEAT_APP_HANDLE.get() {
|
||||
if let Err(e) = app.emit("heartbeat:alert", &alerts) {
|
||||
tracing::warn!("[heartbeat] Failed to emit alert: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
前端用 `safeListen('heartbeat:alert', callback)` 接收(`desktop/src/lib/safe-tauri.ts:76` 已有封装),回调中调 `toast(alert.content, urgencyToType(alert.urgency))` 弹出通知。
|
||||
|
||||
#### 3.1.2 动态 Interval
|
||||
|
||||
替换 `tokio::time::interval()` 为 `tokio::time::sleep` + 每次重读 config。使用 `tokio::select!` + `tokio::sync::Notify` 实现可中断的 sleep,确保 stop 信号能立即响应:
|
||||
|
||||
```rust
|
||||
// HeartbeatEngine 结构体新增字段(在 heartbeat.rs 约 116-122 行)
|
||||
pub struct HeartbeatEngine {
|
||||
agent_id: String,
|
||||
config: Arc<Mutex<HeartbeatConfig>>,
|
||||
running: Arc<Mutex<bool>>,
|
||||
stop_notify: Arc<Notify>, // 新增
|
||||
alert_sender: broadcast::Sender<HeartbeatAlert>,
|
||||
history: Arc<Mutex<Vec<HeartbeatResult>>>,
|
||||
}
|
||||
|
||||
// HeartbeatEngine::new() 中初始化
|
||||
stop_notify: Arc::new(Notify::new()),
|
||||
|
||||
// start() 中的 loop
|
||||
loop {
|
||||
let sleep_secs = config.lock().await.interval_minutes * 60;
|
||||
// 可中断的 sleep:stop_notify 信号到达时立即醒来
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(sleep_secs)) => {},
|
||||
_ = stop_notify.notified() => { break; }
|
||||
};
|
||||
if !*running_clone.lock().await { break; }
|
||||
if is_quiet_hours(&*config.lock().await) { continue; }
|
||||
let result = execute_tick(&agent_id, &config, &alert_sender).await;
|
||||
// ... history + persist
|
||||
}
|
||||
|
||||
// stop() 方法
|
||||
pub async fn stop(&self) {
|
||||
*self.running.lock().await = false;
|
||||
self.stop_notify.notify_one(); // 立即唤醒 sleep
|
||||
}
|
||||
```
|
||||
|
||||
每次循环重新读取 `config.interval_minutes`,修改立即生效。`stop()` 通过 `Notify` 立即中断 sleep,无需等待下一周期。
|
||||
|
||||
#### 3.1.3 Config 持久化
|
||||
|
||||
- `update_config()` 改完后写 VikingStorage key `heartbeat:config:{agent_id}`
|
||||
- `heartbeat_init()` 恢复时优先读 VikingStorage,无记录才用传入的默认值
|
||||
- 前端 App.tsx 不再需要传 config,让 Rust 侧自己恢复
|
||||
|
||||
#### 3.1.4 Memory Stats 查询 Fallback
|
||||
|
||||
检查函数中,如果 `MEMORY_STATS_CACHE` 为空,fallback 直接查 VikingStorage 统计 entry 数量和存储大小。
|
||||
|
||||
#### 3.1.5 清理 Dead Code
|
||||
|
||||
- 删除 `subscribe()` — `health_snapshot` 通过 `history: Arc<Mutex<Vec<HeartbeatResult>>>` 访问告警历史,不需要 broadcast receiver。`broadcast::Sender` 仅用于内部告警传递,不再暴露 subscribe API。
|
||||
- 移除 `HeartbeatCheckFn` type alias — 当前未使用且设计方向已明确为硬编码 5 检查
|
||||
- `is_running()` 暴露为 Tauri 命令(`health_snapshot` 需要查询引擎运行状态)
|
||||
|
||||
### 3.2 Health Snapshot 端点
|
||||
|
||||
**新文件**: `desktop/src-tauri/src/intelligence/health_snapshot.rs`(~120 行)
|
||||
|
||||
#### 3.2.1 数据结构
|
||||
|
||||
```rust
|
||||
#[derive(Serialize)]
|
||||
pub struct HealthSnapshot {
|
||||
pub timestamp: String,
|
||||
pub intelligence: IntelligenceHealth,
|
||||
pub memory: MemoryHealth,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IntelligenceHealth {
|
||||
pub engine_running: bool,
|
||||
pub config: HeartbeatConfig,
|
||||
pub last_tick: Option<String>,
|
||||
pub alert_count_24h: usize,
|
||||
pub total_checks: usize, // 固定值 5(内置检查项总数)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MemoryHealth {
|
||||
pub total_entries: usize,
|
||||
pub storage_size_bytes: u64,
|
||||
pub last_extraction: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
只包含 Rust 侧能查询的状态。连接状态和 SaaS 状态由前端各自 store 管理,不绕道 Rust。
|
||||
|
||||
#### 3.2.2 Tauri 命令
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn health_snapshot(
|
||||
agent_id: String,
|
||||
heartbeat_state: tauri::State<'_, HeartbeatEngineState>,
|
||||
) -> Result<HealthSnapshot, String>
|
||||
```
|
||||
|
||||
#### 3.2.3 注册
|
||||
|
||||
在 `intelligence/mod.rs` 添加 `pub mod health_snapshot;`,在 `lib.rs` builder 中注册命令和 re-export。
|
||||
|
||||
### 3.3 前端修复
|
||||
|
||||
#### 3.3.1 HeartbeatConfig 保存接通后端
|
||||
|
||||
**文件**: `desktop/src/components/HeartbeatConfig.tsx`
|
||||
|
||||
`handleSave()` 在写 localStorage 之后同时调用 `intelligenceClient.heartbeat.updateConfig()` 推送到 Rust 后端。保留 localStorage 作为离线 fallback。
|
||||
|
||||
错误处理策略:`updateConfig` 调用用 try/catch 包裹,失败时仅 `log.warn`(不阻塞 UI 更新),用户看到"已保存"反馈。浏览器模式下 `fallbackHeartbeat.updateConfig()` 是纯内存操作,不会失败。
|
||||
|
||||
```typescript
|
||||
const handleSave = useCallback(async () => {
|
||||
localStorage.setItem('zclaw-heartbeat-config', JSON.stringify(config));
|
||||
localStorage.setItem('zclaw-heartbeat-checks', JSON.stringify(checkItems));
|
||||
try {
|
||||
await intelligenceClient.heartbeat.updateConfig('zclaw-main', config);
|
||||
} catch (err) {
|
||||
log.warn('[HeartbeatConfig] Backend sync failed:', err);
|
||||
}
|
||||
setHasChanges(false);
|
||||
}, [config, checkItems]);
|
||||
```
|
||||
|
||||
#### 3.3.2 App.tsx 启动读持久化 Config
|
||||
|
||||
**文件**: `desktop/src/App.tsx`
|
||||
|
||||
优先从 localStorage 读用户保存的配置,无记录才用默认值。Rust 侧 `heartbeat_init` 也会从 VikingStorage 恢复,形成双层恢复。
|
||||
|
||||
#### 3.3.3 告警监听 + Toast 展示
|
||||
|
||||
**文件**: `desktop/src/App.tsx`
|
||||
|
||||
在 heartbeat start 之后注册 `safeListen('heartbeat:alert', callback)`,回调中对每条告警调 `toast()`。使用项目已有的 `safeListen` 封装和 Toast 系统。
|
||||
|
||||
#### 3.3.4 SaaS 自动恢复
|
||||
|
||||
**文件**: `desktop/src/store/saasStore.ts`
|
||||
|
||||
降级后启动周期探测,复用现有 `saasClient.deviceHeartbeat(DEVICE_ID)` 调用本身作为探测(它已经能证明 SaaS 可达性)。使用指数退避:初始 2 分钟,最长 10 分钟(2min → 3min → 4.5min → 6.75min → 10min cap)。恢复后自动切回 `saas` 模式 + toast 通知用户 + 停止探测。
|
||||
|
||||
**探测启动点**:在 saasStore.ts 的 `DEGRADE_AFTER_FAILURES` 降级逻辑的同一个 catch 块中启动(`saasStore.ts` 约 694-700 行)。降级代码执行后紧接着调用 `startRecoveryProbe()`。
|
||||
|
||||
```typescript
|
||||
// saasStore.ts 现有降级逻辑中追加
|
||||
if (_consecutiveFailures >= DEGRADE_AFTER_FAILURES) {
|
||||
set({ saasReachable: false, connectionMode: 'tauri' });
|
||||
saveConnectionMode('tauri');
|
||||
startRecoveryProbe(); // ← 新增
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.5 清理重复文件
|
||||
|
||||
删除 `desktop/src/lib/intelligence-client.ts`(1476 行单文件版)。已被 `intelligence-client/` 目录版遮蔽,是死代码。
|
||||
|
||||
### 3.4 HealthPanel 健康面板
|
||||
|
||||
**新文件**: `desktop/src/components/HealthPanel.tsx`(~300 行)
|
||||
|
||||
#### 3.4.1 定位
|
||||
|
||||
设置页中的一个选项卡,只读展示所有子系统健康状态 + 历史告警浏览。不做配置(配置由 HeartbeatConfig 选项卡负责)。
|
||||
|
||||
#### 3.4.2 数据来源
|
||||
|
||||
| 区域 | 数据源 |
|
||||
|------|--------|
|
||||
| Agent 心跳 | `health_snapshot` invoke |
|
||||
| 连接状态 | `useConnectionStore`(已有) |
|
||||
| SaaS 状态 | `useSaasStore`(已有) |
|
||||
| 记忆状态 | `health_snapshot` invoke |
|
||||
| 历史告警 | `intelligenceClient.heartbeat.getHistory()` |
|
||||
|
||||
不新建 Zustand store,用 `useState` 管理,组件卸载即释放。
|
||||
|
||||
#### 3.4.3 UI 布局
|
||||
|
||||
```
|
||||
系统健康 [刷新]
|
||||
├── Agent 心跳卡片(运行状态/间隔/上次检查/告警数)
|
||||
├── 连接状态卡片(模式/连接/SaaS可达)
|
||||
├── SaaS 设备卡片(注册/上次心跳/连续失败)
|
||||
├── 记忆管道卡片(条目/存储/上次提取)
|
||||
└── 最近告警列表(紧急度/时间/标题,最多 100 条)
|
||||
```
|
||||
|
||||
#### 3.4.4 状态指示器
|
||||
|
||||
| 状态 | 指示 | 条件 |
|
||||
|------|------|------|
|
||||
| 绿灯 | `●` | 正常运行 |
|
||||
| 黄灯 | `●` | 降级/暂停 |
|
||||
| 灰灯 | `○` | 已禁用/空白 |
|
||||
| 红灯 | `●` | 断开/错误 |
|
||||
|
||||
#### 3.4.5 刷新策略
|
||||
|
||||
进入面板时调用一次,手动刷新按钮重新调用。告警列表额外订阅 `heartbeat:alert` Tauri event 实时追加新告警(组件卸载时 unlisten),其他区域不自动轮询。
|
||||
|
||||
#### 3.4.6 导航入口
|
||||
|
||||
在 `SettingsLayout.tsx` 的 `advanced` 分组中添加 `{ id: 'health', label: '系统健康' }`,与 `heartbeat` 选项卡并列。
|
||||
|
||||
## 4. 改动清单
|
||||
|
||||
| 文件 | 操作 | 行数 |
|
||||
|------|------|------|
|
||||
| `intelligence/heartbeat.rs` | 修改(6处修复) | ~80 行改动 |
|
||||
| `intelligence/health_snapshot.rs` | 新建 | ~120 行 |
|
||||
| `intelligence/mod.rs` | 修改(添加模块声明) | ~3 行 |
|
||||
| `lib.rs` | 修改(注册命令 + re-export) | ~5 行 |
|
||||
| `components/HealthPanel.tsx` | 新建 | ~300 行 |
|
||||
| `components/HeartbeatConfig.tsx` | 修改(保存逻辑) | ~10 行 |
|
||||
| `components/Settings/SettingsLayout.tsx` | 修改(添加导航) | ~3 行 |
|
||||
| `App.tsx` | 修改(读配置 + 告警监听) | ~17 行 |
|
||||
| `store/saasStore.ts` | 修改(自动恢复) | ~25 行 |
|
||||
| `lib/intelligence-client.ts` | 删除 | -1476 行 |
|
||||
| **合计** | | ~-913 行净变化 |
|
||||
|
||||
## 5. 不做的事
|
||||
|
||||
- 不建后台协调器或事件总线
|
||||
- 不替代 WebSocket ping/pong
|
||||
- 不替代 SaaS device heartbeat 的 HTTP POST 机制
|
||||
- 不实现 A2A Heartbeat(仍是枚举占位)
|
||||
- 不建新的 Zustand store
|
||||
- 不设自动轮询刷新
|
||||
|
||||
## 6. 验证标准
|
||||
|
||||
### 6.1 功能验证
|
||||
|
||||
- [ ] 修改 HeartbeatConfig 保存后,Rust 后端立即生效(interval 变更在下次 tick 体现)
|
||||
- [ ] 告警实时弹出 Toast(不高于 proactivity_level 过滤)
|
||||
- [ ] 重启应用后配置自动恢复(VikingStorage + localStorage 双层)
|
||||
- [ ] SaaS 降级后恢复连接,自动切回 + Toast 通知
|
||||
- [ ] HealthPanel 展示所有 4 个子系统状态 + 历史告警
|
||||
- [ ] Memory stats 前端同步失败时 fallback 到 VikingStorage 直接查询
|
||||
|
||||
### 6.2 回归验证
|
||||
|
||||
- [ ] `cargo check --workspace --exclude zclaw-saas` 通过
|
||||
- [ ] `cd desktop && pnpm tsc --noEmit` 通过
|
||||
- [ ] `cd desktop && pnpm vitest run` 通过
|
||||
- [ ] 现有 WebSocket ping/pong 不受影响
|
||||
- [ ] 现有 SSE StreamBridge 不受影响
|
||||
|
||||
## 7. 风险
|
||||
|
||||
| 风险 | 影响 | 缓解 |
|
||||
|------|------|------|
|
||||
| AppHandle 全局单例时序 | `heartbeat_init` 必须在 Tauri app 初始化后调用 | 实际在 App.tsx bootstrap Step 4.5 调用,此时 Tauri 已就绪;`OnceLock::set` 失败仅 log warn |
|
||||
| stop 信号在长 sleep 期间延迟 | 用户点击停止后需要等待 sleep 结束 | 使用 `tokio::select!` + `Notify`,stop 立即唤醒 |
|
||||
| SaaS 探测持续失败 | 长时间不可达时每 2 分钟探测浪费资源 | 指数退避,最长 10 分钟间隔 |
|
||||
| 删除 intelligence-client.ts 影响未知导入 | 如果有文件显式导入 `.ts` 后缀 | 实施前全局 grep 确认所有 import 路径;Vite/TypeScript 目录解析优先于文件 |
|
||||
| HealthPanel 告警列表内存 | 长时间打开面板可能积累大量实时告警 | 组件内限制最大 100 条,超出丢弃最早的 |
|
||||
BIN
tests/screenshots/01_roles_permissions.png
Normal file
BIN
tests/screenshots/01_roles_permissions.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 457 KiB |
BIN
tests/screenshots/02_agent_templates.png
Normal file
BIN
tests/screenshots/02_agent_templates.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 593 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user