diff --git a/cc-haha/desktop/src/components/login/HeicodeLoginPage.tsx b/cc-haha/desktop/src/components/login/HeicodeLoginPage.tsx index 5f5c22a..ac2c2b7 100644 --- a/cc-haha/desktop/src/components/login/HeicodeLoginPage.tsx +++ b/cc-haha/desktop/src/components/login/HeicodeLoginPage.tsx @@ -133,17 +133,24 @@ export function HeicodeLoginPage() { ) : null} - {hasFetched && providers.length === 0 ? ( + {/* docs/product-package/08 §"登录" + §"客户端不应该出现的内容": + Login screen only shows the Heicode provider. The legacy + `official` (Claude Official) preset is filtered out so the + login screen carries Heicode brand alone, no third-party + route entry as docs §8 forbids. */} + {hasFetched && providers.filter((p) => p.id !== 'official').length === 0 ? (
{t('login.errors.noProviders')}
) : null} - {hasFetched && providers.length > 0 ? ( + {hasFetched && providers.filter((p) => p.id !== 'official').length > 0 ? (
- {providers.map((provider) => ( - - ))} + {providers + .filter((provider) => provider.id !== 'official') + .map((provider) => ( + + ))}
) : null} diff --git a/heicode/web/default/index.html b/heicode/web/default/index.html index 5ee88c7..cd95909 100644 --- a/heicode/web/default/index.html +++ b/heicode/web/default/index.html @@ -2,10 +2,10 @@ - - - - + + + + @@ -16,7 +16,7 @@ content="Heicode Manager — multi-tenant control plane for Agnet deployments, events and audit." /> - + diff --git a/heicode/web/default/src/components/layout/components/footer.tsx b/heicode/web/default/src/components/layout/components/footer.tsx index d521e69..fe3446d 100644 --- a/heicode/web/default/src/components/layout/components/footer.tsx +++ b/heicode/web/default/src/components/layout/components/footer.tsx @@ -60,7 +60,7 @@ export function Footer(props: FooterProps) { demoSiteEnabled, } = useSystemConfig() - const displayLogo = systemLogo || props.logo || '/logo.png' + const displayLogo = systemLogo || props.logo || '/logo.png?v=h-glass-2' const displayName = systemName || props.name || BRAND_NAME const isDemoSiteMode = Boolean(demoSiteEnabled) const currentYear = new Date().getFullYear() diff --git a/heicode/web/default/src/features/agnet-console/pages.tsx b/heicode/web/default/src/features/agnet-console/pages.tsx index db2ac6a..f7d5d4d 100644 --- a/heicode/web/default/src/features/agnet-console/pages.tsx +++ b/heicode/web/default/src/features/agnet-console/pages.tsx @@ -50,10 +50,12 @@ import { type AgnetDeployment, type AgnetRuntimeExecution, type AgnetSKAccessPolicy, + type GitSource, type GitSourcePayload, type GitSourceUsage, } from './api' import { CreateAgnetDeploymentSheet } from './create-agnet-deployment-sheet' +import { toast } from 'sonner' type StatusKey = 'running' | 'success' | 'failed' | 'pending' @@ -670,11 +672,150 @@ export function AgnetEventsPage() { // Audit page // ============================================================================= +// ============================================================================= +// Audit — docs §10 §"任务用量与审计": +// "默认作为任务详情里的抽屉或浮层,不作为主体验。" 这里因为路由独立保留, +// 但视觉做成"抽屉式"分组卡片,所有敏感字段统一脱敏成 secret_ref / hash。 +// 禁止展示: +// - 明文密钥(password, access key, token, private key) +// - 完整 payload / permission manifest +// - 用户填的底层 ID(除非分类用途) +// ============================================================================= + +const SECRET_FIELD_HINTS = [ + 'token', + 'password', + 'key', + 'secret', + 'credential', + 'apikey', + 'api_key', + 'access_key', +] as const + +/** Heuristic redaction for any unexpected secret-shaped field showing up + * in the audit payload. The backend SHOULD never emit these, but defense + * in depth — docs §6 禁止前端返回明文密钥. */ +function maskIfSecret(key: string, value: string): string { + const lk = key.toLowerCase() + if (SECRET_FIELD_HINTS.some((h) => lk.includes(h))) { + if (!value) return '—' + if (value.startsWith('secret_ref:') || value.startsWith('vault:')) return value + return value.length > 8 ? `${value.slice(0, 4)}…${value.slice(-2)}` : '***' + } + return value +} + +function classifyAuditAction(action: string): { + tone: 'risk' | 'change' | 'info' + label: string +} { + const a = action.toLowerCase() + if ( + a.includes('approve') || + a.includes('deploy') || + a.includes('production') || + a.includes('delete') + ) { + return { tone: 'risk', label: action } + } + if ( + a.includes('grant') || + a.includes('revoke') || + a.includes('rotate') || + a.includes('lease') + ) { + return { tone: 'change', label: action } + } + return { tone: 'info', label: action } +} + +function AuditEntryCard({ + entry, + t, +}: { + entry: Record + t: ReturnType['t'] +}) { + const action = String(entry.action || entry.event || '—') + const actor = String(entry.actor || entry.user || '—') + const scope = String(entry.binding_scope || entry.tenant || entry.tenant_id || '—') + const resourceId = String(entry.resource_id || '—') + const resourceType = String(entry.resource_type || '—') + const allowedActions = Array.isArray(entry.allowed_actions) + ? (entry.allowed_actions as string[]).join(', ') + : String(entry.allowed_actions || '—') + const constraints = entry.constraints + ? typeof entry.constraints === 'string' + ? entry.constraints + : JSON.stringify(entry.constraints) + : '—' + const secretRef = String(entry.secret_ref || '—') + const occurred = String(entry.occurred_at || entry.timestamp || '') + const { tone, label } = classifyAuditAction(action) + + const toneCls: Record = { + risk: 'border-rose-500/30 bg-rose-500/5', + change: 'border-amber-500/30 bg-amber-500/5', + info: 'border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40', + } + + return ( +
+
+
+

+ {tone === 'risk' + ? t('High-risk action') + : tone === 'change' + ? t('Scope / credential change') + : t('Audit event')} +

+

+ {label} +

+
+ + {formatRelativeTime(occurred)} + +
+ +
+ + + + + + + +
+
+ ) +} + +function RedactedField({ k, v, mono }: { k: string; v: string; mono?: boolean }) { + return ( +
+
+ {k} +
+
+ {v} +
+
+ ) +} + export function AgnetAuditPage() { const { t } = useTranslation() const [scope, setScope] = useState('') const [actor, setActor] = useState('') - const [action, setAction] = useState('') + const [actionFilter, setActionFilter] = useState('') const { data = [], isLoading } = useQuery({ queryKey: ['agnet', 'audit'], @@ -692,16 +833,16 @@ export function AgnetAuditPage() { const ac = String(e.action || e.event || '').toLowerCase() if (scope && !s.includes(scope.toLowerCase())) return false if (actor && !a.includes(actor.toLowerCase())) return false - if (action && !ac.includes(action.toLowerCase())) return false + if (actionFilter && !ac.includes(actionFilter.toLowerCase())) return false return true }) - }, [data, scope, actor, action]) + }, [data, scope, actor, actionFilter]) return ( @@ -718,73 +859,31 @@ export function AgnetAuditPage() { className='h-9 w-36 rounded-xl text-xs' /> setAction(e.target.value)} + value={actionFilter} + onChange={(e) => setActionFilter(e.target.value)} placeholder={t('action')} className='h-9 w-36 rounded-xl text-xs' /> } > +

+ {t( + 'Per docs §6: plaintext credentials never appear here. Long-lived secrets live in the secret vault; only secret_ref and redacted previews are shown.' + )} +

{isLoading ? ( - + ) : filtered.length === 0 ? ( ) : ( -
- - - - - - - - - - - {filtered.map((entry, idx) => { - const e = entry as Record - return ( - - - - - - - ) - })} - -
- - {t('action')} - - - {t('actor')} - - - {t('scope')} - - - {t('time')} -
- {String(e.action || e.event || '—')} - - {String(e.actor || e.user || '—')} - - {String( - e.binding_scope || e.tenant || e.tenant_id || '—' - )} - - {formatRelativeTime( - String(e.occurred_at || e.timestamp || '') - )} -
+
+ {filtered.map((entry, idx) => ( + } t={t} /> + ))}
)} @@ -853,6 +952,8 @@ export function AgnetSKSourcesPage() { }, }) + const [summaryOpen, setSummaryOpen] = useState(false) + const projectSources = gitSources.filter( (s) => s.usage === 'project' || s.usage === 'combined' ) @@ -968,6 +1069,7 @@ export function AgnetSKSourcesPage() { size='sm' className='shrink-0 rounded-xl' disabled={!prereqsDone} + onClick={() => setSummaryOpen(true)} > {t('Confirm and launch Agnet')} @@ -1191,10 +1293,186 @@ export function AgnetSKSourcesPage() {
)} + + {summaryOpen && ( + setSummaryOpen(false)} + projectSources={projectSources} + skSources={skSources} + /> + )}
) } +// ============================================================================= +// 推荐确认卡 — docs/product-package/10 §"推荐确认卡": +// 本次会做 / 本次允许使用 / 本次不会做 / 高危规则 / 预计消耗 / 启动 Agnet +// 「启动 Agnet」旁边写「参数由 Heicode 自动生成」。 +// 没有 JSON 编辑器、permission manifest、resource grant 表(§10 高级展开禁令)。 +// ============================================================================= + +function RecommendationSummaryDialog({ + onClose, + projectSources, + skSources, +}: { + onClose: () => void + projectSources: GitSource[] + skSources: GitSource[] +}) { + const { t } = useTranslation() + const [launching, setLaunching] = useState(false) + + const willDo = [ + t('Clarify requirements and draft product brief'), + t('Generate development tasks and check list'), + t('Code, review, test in a sandboxed environment'), + t('Stage deployment artifacts; production deploy needs approval'), + ] + const allowedUse = [ + projectSources.length > 0 + ? t('Code: {{n}} repository connected', { n: projectSources.length }) + : t('Code: starting from scratch'), + skSources.length > 0 + ? t('Docs / SK: {{n}} source connected', { n: skSources.length }) + : t('Docs / SK: none (skipped)'), + t('Cloud resources: test-tier only (auto-discovery)'), + ] + const willNotDo = [ + t('Production deploy without desktop client approval'), + t('Production database write or migration'), + t('Export long-lived credentials'), + t('Delete cloud resources outside the task scope'), + ] + const highRisk = [ + t('Production deploy, production secrets and destructive ops require client approval'), + t('Approval issues short-lived, scope-limited credentials only'), + ] + + const handleLaunch = () => { + setLaunching(true) + // Real /api/agnet/deployments POST is wired separately when the task + // object backend lands. For now the summary card matches the docs spec + // visually; clicking captures intent + hands off to the desktop client. + setTimeout(() => { + toast.success( + t('Agnet launch staged. Continue the task in the desktop client.') + ) + setLaunching(false) + onClose() + }, 400) + } + + return ( +
+
+
+
+

+ {t('Recommendation summary')} +

+

+ {t('Confirm scope, risk and budget before launching Agnet')} +

+

+ {t( + 'Parameters are generated by Heicode. You only confirm the boundaries.' + )} +

+
+ +
+ +
+ + + + +
+ +
+

+ {t('Estimated consumption')} +

+

+ {t( + 'Model budget will be capped to your default. Detailed usage shows up in Models & balance after the run.' + )} +

+
+ +
+

+ {t('Parameters auto-generated by Heicode')} +

+
+ + +
+
+
+
+ ) +} + +function RecBlock({ + title, + tone, + items, +}: { + title: string + tone: 'primary' | 'warn' | 'danger' + items: string[] +}) { + const toneCls: Record = { + primary: { + dot: 'bg-primary', + border: 'rgba(123,107,227,0.20)', + }, + warn: { + dot: 'bg-amber-400', + border: 'rgba(245,158,11,0.30)', + }, + danger: { + dot: 'bg-rose-400', + border: 'rgba(225,29,72,0.28)', + }, + } + return ( +
+

{title}

+
    + {items.map((line, i) => ( +
  • + + {line} +
  • + ))} +
+
+ ) +} // ============================================================================= // Templates / Agents (kept for backward compatibility — invoked by side routes) diff --git a/heicode/web/default/src/features/dashboard/components/home-hero.tsx b/heicode/web/default/src/features/dashboard/components/home-hero.tsx new file mode 100644 index 0000000..ed14dee --- /dev/null +++ b/heicode/web/default/src/features/dashboard/components/home-hero.tsx @@ -0,0 +1,523 @@ +/** + * Heicode 首页主视图 — 实现 docs/product-package/10-frontend-detail-spec.md + * 「初始首页」+ 11-product-prototype-wireframes.md §4 Manager 辅助控制台原型。 + * + * 四块对应 §10: + * 主输入 — "你想把什么想法变成可以上线的软件?" + * 继续任务 — 最近任务、运行状态、待审批提示 + * 今日焦点 — 当前最重要任务、失败任务、待确认事项 + * 辅助入口 — 客户端下载、账户安全、准备清单、最近审计 + * + * 设计原则(§10 §"设计原则" 1-8): + * 1. 第一屏只强调"你想做什么" + * 2. 主流程围绕当前任务,不围绕后台模块 + * 8. 状态必须覆盖空态、加载、错误、成功 + * + * Manager 不承担主开发对话(§13 §5.1)— 主输入只把想法暂存到 localStorage + * 草稿,提示用户打开客户端继续推进。后端 /api/task 之类的 idea-task 接口 + * 还没接,等接通后这个 stash 流程换成 POST /api/task。 + */ +import { useCallback, useMemo, useRef, useState } from 'react' +import { Link } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { + ArrowRight, + ArrowUpRight, + CheckCircle2, + CircleDashed, + Cpu, + Download, + GitBranch, + PlayCircle, + Rocket, + ShieldCheck, + Sparkles, + UserCog, + XCircle, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { cn } from '@/lib/utils' +import { + listAgnetDeploymentsQuiet, + type AgnetDeployment, +} from '@/features/agnet-console/api' + +const DRAFT_STORAGE_KEY = 'heicode_idea_draft' + +type StatusKey = 'running' | 'success' | 'failed' | 'pending' + +const STATUS_MAP: Record = { + running: 'running', + active: 'running', + in_progress: 'running', + succeeded: 'success', + success: 'success', + completed: 'success', + failed: 'failed', + error: 'failed', + rejected: 'failed', + pending: 'pending', + queued: 'pending', + awaiting: 'pending', +} + +function classifyStatus(s: string): StatusKey { + return STATUS_MAP[(s || '').toLowerCase()] ?? 'pending' +} + +function StatusBadge({ phase }: { phase: string }) { + const k = classifyStatus(phase) + const palette: Record = { + running: { + cls: 'bg-[color-mix(in_oklch,var(--primary)_22%,transparent)] text-primary ring-primary/40', + Icon: PlayCircle, + }, + success: { + cls: 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30', + Icon: CheckCircle2, + }, + failed: { + cls: 'bg-rose-500/15 text-rose-400 ring-rose-500/30', + Icon: XCircle, + }, + pending: { + cls: 'bg-amber-500/15 text-amber-400 ring-amber-500/30', + Icon: CircleDashed, + }, + } + const p = palette[k] + return ( + + + {phase || 'pending'} + + ) +} + +function formatRelativeTime(value?: string): string { + if (!value) return '—' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + const diff = Date.now() - date.getTime() + const sec = Math.round(diff / 1000) + if (sec < 60) return `${sec}s` + const min = Math.round(sec / 60) + if (min < 60) return `${min}m` + const hr = Math.round(min / 60) + if (hr < 24) return `${hr}h` + return `${Math.round(hr / 24)}d` +} + +function IdeaInput({ t }: { t: ReturnType['t'] }) { + const [value, setValue] = useState(() => { + if (typeof window === 'undefined') return '' + return window.localStorage.getItem(DRAFT_STORAGE_KEY) ?? '' + }) + const taRef = useRef(null) + + const handleSubmit = useCallback(() => { + const trimmed = value.trim() + if (!trimmed) { + taRef.current?.focus() + return + } + try { + window.localStorage.setItem(DRAFT_STORAGE_KEY, trimmed) + } catch { + /* localStorage unavailable — ignore */ + } + toast.success(t('Idea captured. Open the desktop client to continue.')) + }, [value, t]) + + return ( +
+
+
+ + + +
+

+ {t('What idea would you like to turn into shippable software?')} +

+

+ {t( + 'Describe the product, feature, or fix. Heicode will draft the task, recommend resources, and dispatch the Agnet team.' + )} +

+
+
+ +