PMFlow/frontend/src/components/SignalDashboard.vue

2885 lines
86 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="command-center">
<div v-if="showScanEffect" class="scan-overlay"></div>
<!-- Top Status Bar -->
<div class="status-bar glass-panel">
<div class="status-item">
<n-text class="status-label">极速响应率</n-text>
<n-text class="status-value highlight-green">98.5%</n-text>
</div>
<div class="status-item" style="width: 280px; padding-right: 24px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<n-text class="status-label">竞争时差 (执行进度 vs 竞品)</n-text>
<div class="window-countdown" v-if="selectedSignal && currentStrategicInfo.is_strategic">
<span class="countdown-icon">⏳</span>
<span class="countdown-val">{{ leadWindow }}</span>
</div>
<n-button size="tiny" circle ghost @click="showCompetitorModal = true" v-else>
<template #icon>⚙️</template>
</n-button>
</div>
<div class="progress-comparison">
<div class="progress-row">
<span class="label">我方</span>
<n-progress type="line" :percentage="getOurProgress(selectedSignal)" color="#63e2b7" rail-color="rgba(255,255,255,0.1)" height="6" processing />
</div>
<div class="progress-row">
<span class="label">竞品</span>
<n-progress type="line" :percentage="getCompetitorProgress(selectedSignal)" color="#f5222d" rail-color="rgba(255,255,255,0.1)" height="6" />
</div>
<div class="lead-time-text" v-if="selectedSignal">
{{ getLeadTimeText(selectedSignal) }}
</div>
</div>
</div>
<div class="status-item compute-load-item">
<n-text class="status-label">AI 算力负载</n-text>
<div class="compute-load-display">
<n-text class="status-value" :class="{ 'reserved': isReservingCompute }">{{ computeLoadPercent }}%</n-text>
<div v-if="isReservingCompute" class="reserved-badge">
<span class="reserved-dot"></span>
<span class="reserved-text">预占 {{ reservedComputePercent }}%</span>
</div>
</div>
<div class="compute-bar">
<div class="compute-bar-fill current" :style="{ width: computeLoadPercent + '%' }"></div>
<div v-if="isReservingCompute" class="compute-bar-fill reserved" :style="{ width: reservedComputePercent + '%' }"></div>
</div>
</div>
<!-- AI 分析控制 -->
<div class="status-item" style="min-width: 200px;">
<n-text class="status-label">AI 信号分析</n-text>
<div v-if="analyzing" style="margin-top: 4px;">
<n-progress type="line" :percentage="analysisProgress" :indicator-placement="'inside'" processing />
<n-text depth="3" style="font-size: 11px;">{{ analysisStatus }}</n-text>
</div>
<n-space v-else :size="8">
<n-button size="small" :type="ollamaReady ? 'success' : 'warning'" @click="startAnalysis">
<template #icon>{{ ollamaReady ? '🤖' : '⚠️' }}</template>
{{ ollamaReady ? '启动分析' : '启动分析 (未检测到 Ollama)' }}
</n-button>
<n-button size="small" @click="checkOllamaStatus" quaternary circle>
<template #icon>🔄</template>
</n-button>
</n-space>
</div>
<div class="status-item">
<n-button size="small" type="primary" @click="showModal = true" ghost>⚡ 模拟信号注入</n-button>
</div>
</div>
<n-layout has-sider style="height: calc(100vh - 100px); background: transparent;">
<!-- Left: Intelligence Radar -->
<n-layout-sider bordered width="350px" content-style="padding: 16px;" class="glass-sider">
<n-space vertical>
<div class="panel-header">
<n-icon size="20" color="#63e2b7">
<RadarIcon />
</n-icon>
<n-text strong style="font-size: 16px; margin-left: 8px;">情报雷达站</n-text>
<n-spin v-if="loading" size="small" style="margin-left: auto;" />
</div>
<!-- Priority Legend -->
<div class="priority-legend">
<n-text depth="3" style="font-size: 11px;">优先级:</n-text>
<n-tag size="tiny" type="error" round>紧急</n-tag>
<n-tag size="tiny" type="warning" round>高优</n-tag>
<n-tag size="tiny" type="info" round>中等</n-tag>
<n-tag size="tiny" type="default" round>低优</n-tag>
</div>
<n-scrollbar style="max-height: calc(100vh - 200px)">
<n-list hoverable clickable class="signal-list">
<n-list-item
v-for="signal in aggregatedSignals"
:key="signal.id"
@click="selectSignal(signal.latestSignal)"
:class="{
'selected-item': selectedSignal?.id === signal.id,
'new-signal-flash': isNewSignal(signal.latestSignal)
}"
>
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 6px;">
<n-tag :type="getPriorityType(signal.latestSignal.priority)" size="small" round>
{{ getPriorityLabel(signal.latestSignal.priority) }}
</n-tag>
<n-tag :type="getImpactColor(signal.impact_score)" size="small" round>{{ signal.source }}</n-tag>
<n-tag v-if="signal.count > 1" type="info" size="tiny" round>{{ signal.count }}条</n-tag>
</div>
<n-text depth="3" style="font-size: 12px;">{{ formatDate(signal.created_at) }}</n-text>
</div>
</template>
<n-text class="signal-content" :depth="1">{{ signal.content }}</n-text>
</n-list-item>
</n-list>
</n-scrollbar>
</n-space>
</n-layout-sider>
<!-- Center: AI Strategy Room -->
<n-layout-content content-style="padding: 16px;" class="glass-content">
<div v-if="!selectedSignal" class="empty-state">
<div class="radar-scan"></div>
<n-text depth="3" style="margin-top: 20px;">等待信号接入...</n-text>
</div>
<div v-else class="strategy-room">
<!-- Competitive Moat Section -->
<n-card class="glass-card moat-card" :bordered="false" size="small" style="margin-bottom: 24px;">
<div class="moat-container">
<div class="moat-metric">
<n-statistic label="⚡ 响应速度优势" :value="getSpeedAdvantage(selectedSignal)">
<template #suffix>
<span class="metric-unit">小时</span>
</template>
<template #prefix>
<span class="metric-icon">🚀</span>
</template>
</n-statistic>
<div class="moat-subtitle">
比行业平均水平快 <span class="highlight-green">{{ getSpeedAdvantage(selectedSignal) }}</span> 小时
</div>
</div>
<div class="moat-timeline">
<n-steps size="small" :current="getTimelineStep(selectedSignal)" status="process">
<n-step
title="外部信号捕获"
:description="formatTime(selectedSignal.created_at)"
/>
<n-step
title="AI 战略研判"
:description="formatTime(getAiTime(selectedSignal))"
/>
<n-step
title="首行代码提交"
:description="getCodeTime(selectedSignal)"
:status="getNode3Status(selectedSignal)"
>
<template #icon>
<n-icon v-if="getNode3Status(selectedSignal) === 'process'" class="pulsing-icon">
<div class="code-icon">⚡</div>
</n-icon>
</template>
</n-step>
</n-steps>
</div>
</div>
</n-card>
<n-grid :cols="24" :x-gap="24" style="height: calc(100% - 120px);">
<n-grid-item :span="14" style="height: 100%;">
<n-card class="glass-card ai-analysis-card" :bordered="false" size="huge">
<template #header>
<div class="card-title">
<span class="emoji">🧠</span> 战略沙盘推演
<n-tag :type="getPriorityType(selectedSignal?.priority)" size="small" round style="margin-left: 10px;">
{{ getPriorityLabel(selectedSignal?.priority) }}
</n-tag>
<n-tag v-if="currentStrategicInfo.is_strategic" type="error" size="small" round style="margin-left: 8px; box-shadow: 0 0 10px rgba(208, 48, 80, 0.5);">
STRATEGIC ALERT
</n-tag>
<n-tag v-if="currentStrategicInfo.confidence !== undefined" :type="getConfidenceType(currentStrategicInfo.confidence)" size="small" round style="margin-left: 8px;">
置信度: {{ formatConfidence(currentStrategicInfo.confidence) }}
</n-tag>
<n-tag v-if="currentStrategicInfo.voting?.enabled" type="success" size="small" round style="margin-left: 8px;">
🗳️ {{ currentStrategicInfo.voting.strategic_votes }}/{{ currentStrategicInfo.voting.total_votes }} 模型投票
<span v-if="currentStrategicInfo.voting.unanimous" style="margin-left: 4px;">✓</span>
</n-tag>
<n-tag v-if="currentStrategicInfo.rag_context_used > 0" type="info" size="small" round style="margin-left: 8px;">
📚 引用 {{ currentStrategicInfo.rag_context_used }} 条相关知识
</n-tag>
</div>
</template>
<div class="sandbox-container">
<!-- Step 1: Signal -->
<div class="sandbox-step">
<div class="step-icon source-icon">📡</div>
<div class="step-content">
<div class="step-title">信号捕获</div>
<div class="step-desc">{{ formatSignalContent(selectedSignal.content) }}</div>
</div>
</div>
<div class="step-arrow">⬇</div>
<!-- Step 2: Impact Visual -->
<div class="sandbox-step">
<div class="step-icon impact-icon">💥</div>
<div class="step-content">
<div class="step-title">影响评估 (AI Score: {{ (selectedSignal.impact_score * 100).toFixed(0) }}%)</div>
<div class="impact-visual">
<div class="impact-bar loss">
<div class="bar-label">无视损失</div>
<div class="bar-fill" :style="{ width: ((1 - selectedSignal.impact_score) * 100) + '%' }">
{{ getLossLabel(selectedSignal.impact_score) }}
</div>
</div>
<div class="impact-bar gain">
<div class="bar-label">跟进收益</div>
<div class="bar-fill" :style="{ width: (selectedSignal.impact_score * 100) + '%' }">
{{ getGainLabel(selectedSignal.impact_score) }}
</div>
</div>
</div>
</div>
</div>
<div class="step-arrow">⬇</div>
<!-- Step 3: RAG Alignment (AI-Powered) -->
<div class="sandbox-step">
<div class="step-icon rag-icon">📚</div>
<div class="step-content">
<div class="step-title">知识库检索(AI 智能提取)</div>
<div class="rag-sources">
<!-- Database RAG context -->
<template v-if="currentStrategicInfo.rag_context_used > 0">
<div class="dynamic-rag-section">
<div class="rag-section-title">从认知节点检索到 {{ currentStrategicInfo.rag_context_used }} 条相关知识</div>
<n-text depth="3" style="font-size: 12px; margin-bottom: 8px;">AI 基于向量相似度检索相关知识,辅助战略判断</n-text>
</div>
</template>
<!-- AI-Generated Tech Stacks -->
<template v-if="getDetectedTechStacks().length > 0">
<div class="tech-stacks-section">
<div class="rag-section-title">检测到的技术栈</div>
<div class="tech-stacks-grid">
<n-tag
v-for="stack in getDetectedTechStacks()"
:key="stack.name"
size="small"
:type="getTechStackType(stack.category)"
:bordered="false"
class="tech-stack-tag"
>
<template #icon>
<span class="tech-icon">{{ getTechStackIcon(stack.category) }}</span>
</template>
{{ stack.name }}
<n-badge
:value="getRelevanceLabel(stack.relevance)"
:type="getRelevanceType(stack.relevance)"
size="small"
style="margin-left: 6px;"
/>
</n-tag>
</div>
</div>
</template>
<!-- AI-Generated Knowledge Nodes -->
<template v-if="getRelatedKnowledgeNodes().length > 0">
<div class="knowledge-nodes-section">
<div class="rag-section-title">相关知识节点</div>
<div class="knowledge-nodes-list">
<div
v-for="node in getRelatedKnowledgeNodes()"
:key="node.title"
class="knowledge-node-item clickable-tag"
@click="showFactSnippetModal(node.title)"
>
<div class="node-header">
<span class="node-icon">{{ getKnowledgeNodeIcon(node.category) }}</span>
<span class="node-title">{{ node.title }}</span>
<n-tag size="tiny" :type="getKnowledgeNodeType(node.category)" bordered>
{{ getKnowledgeNodeCategoryLabel(node.category) }}
</n-tag>
</div>
<div class="node-summary">{{ node.summary }}</div>
</div>
</div>
</div>
</template>
<!-- Fallback: No AI Data Available -->
<template v-if="getDetectedTechStacks().length === 0 && getRelatedKnowledgeNodes().length === 0">
<div class="ai-annotation no-data">
📭 AI 未检测到相关技术栈或知识节点,可能需要更多上下文信息
</div>
</template>
</div>
</div>
</div>
<div class="step-arrow">⬇</div>
<!-- Step 4: Recommendation -->
<div class="sandbox-step final-step">
<div class="step-icon action-icon">🎯</div>
<div class="step-content">
<div class="step-title">最终建议</div>
<div class="typewriter-text highlight-text">
{{ displayedAnalysis.recommendation || '正在生成战略建议...' }}
</div>
</div>
</div>
<!-- Step 5: ROI Scorecard -->
<div class="roi-scorecard">
<div class="roi-header">
<span class="roi-title">💰 资源与收益评估 (ROI Scorecard)</span>
</div>
<div class="roi-metrics">
<div class="roi-metric-item">
<div class="roi-label">开发人力预估</div>
<div class="roi-value primary">{{ getTotalEstimatedHours }}h</div>
<div class="roi-sub">约 {{ getTotalEstimatedDays }} 工作日</div>
</div>
<div class="roi-divider"></div>
<div class="roi-metric-item">
<div class="roi-label">算力开销预估</div>
<div class="roi-value warning">{{ getEstimatedComputeCost }}</div>
<div class="roi-sub">GPU 租赁费用</div>
</div>
<div class="roi-divider"></div>
<div class="roi-metric-item highlight">
<div class="roi-label">领先竞品窗口</div>
<div class="roi-value success">{{ getLeadWindowDays }} 天</div>
<div class="roi-sub">战略先发优势</div>
</div>
</div>
</div>
<!-- Voting Details (when multi-model voting is used) -->
<div v-if="currentStrategicInfo.voting?.enabled" class="voting-details">
<div class="voting-header">
<span class="voting-title">🗳️ 多模型投票详情</span>
<n-tag :type="currentStrategicInfo.voting.unanimous ? 'success' : 'info'" size="small">
{{ currentStrategicInfo.voting.unanimous ? '一致同意' : '多数决策' }}
</n-tag>
</div>
<div class="voting-models">
<div v-for="model in currentStrategicInfo.voting.models_used || []" :key="model" class="voting-model-item">
<div class="model-name">{{ model }}</div>
<div class="model-vote">
<n-tag size="small" :type="getModelVoteType(model, currentStrategicInfo)">
{{ getModelVoteLabel(model, currentStrategicInfo) }}
</n-tag>
</div>
</div>
</div>
<div class="voting-summary">
<n-text depth="3">结果: {{ currentStrategicInfo.voting.strategic_votes }}/{{ currentStrategicInfo.voting.total_votes }} 模型判定为战略级</n-text>
</div>
</div>
<!-- Radar Chart (Keep it but smaller/integrated) -->
<div v-if="currentStrategicInfo.competitiveness_radar" style="height: 200px; margin-top: 10px;">
<v-chart class="chart" :option="getRadarOption(currentStrategicInfo.competitiveness_radar)" autoresize />
</div>
<!-- Command Gate -->
<div class="command-gate" v-if="currentStrategicInfo.is_strategic">
<n-button
type="error"
size="large"
block
class="deploy-btn authorize-btn"
:disabled="taskDeployed"
@click="deployStrategy"
>
<template #icon>
<span class="authorize-icon">🛡️</span>
</template>
{{ taskDeployed ? '战略指令已下发 (AUTHORIZED)' : '授权下发战略指令 (AUTHORIZE)' }}
</n-button>
</div>
<!-- Feedback Section -->
<div v-if="selectedSignal?.strategic_analysis" class="feedback-section">
<div class="feedback-header">
<span class="feedback-title">📝 AI 判断反馈</span>
<n-tag v-if="signalFeedback" :type="feedbackMatches ? 'success' : 'warning'" size="small">
{{ feedbackSubmitted ? '已反馈' : '等待反馈' }}
</n-tag>
</div>
<div class="feedback-content">
<n-text v-if="!feedbackSubmitted" depth="3" style="font-size: 12px; margin-bottom: 8px;">
帮助 AI 学习:这个信号的战略判断是否准确?
</n-text>
<div v-if="!feedbackSubmitted" class="feedback-actions">
<n-button
size="small"
type="success"
:disabled="feedbackSubmitting"
@click="submitFeedback(true)"
>
✓ 判断正确
</n-button>
<n-button
size="small"
type="error"
:disabled="feedbackSubmitting"
@click="submitFeedback(false)"
>
✗ 判断错误
</n-button>
</div>
<div v-else class="feedback-result">
<n-text :type="feedbackMatches ? 'success' : 'warning'">
{{ feedbackMatches ? '✓ 您的反馈与 AI 判断一致' : '✗ 您的反馈与 AI 判断不同' }}
</n-text>
<n-button text size="small" @click="resetFeedback" style="margin-left: 8px;">
重新反馈
</n-button>
</div>
</div>
</div>
</div>
</n-card>
</n-grid-item>
<!-- Right: Mission Board -->
<n-grid-item :span="10" style="height: 100%;">
<n-card class="glass-card mission-board" :class="{ 'executing': taskExecuting }" title="⚔️ 作战任务看板 (P0)" :bordered="false" size="medium">
<template #header-extra>
<n-tag v-if="taskDeployed" type="success" size="small" round>SYNCED</n-tag>
<n-tag v-else-if="taskExecuting" type="warning" size="small" round class="executing-tag">
<span class="executing-dot"></span>EXECUTING
</n-tag>
<n-tag v-else type="info" size="small" round>STANDBY</n-tag>
</template>
<div class="mission-container">
<div class="mission-content" :class="{ 'blurred': !taskDeployed && !taskExecuting }">
<n-list class="task-list">
<template v-if="currentGeneratedTasks.length > 0">
<n-list-item v-for="(task, index) in currentGeneratedTasks" :key="index" class="task-item" :class="{ 'pulse-active': taskExecuting }">
<div class="task-header">
<n-tag size="small" :type="getTaskTypeColor(task.type)" :bordered="false">{{ task.type.toUpperCase() }}</n-tag>
<n-text class="task-hours">{{ task.estimated_hours }}h</n-text>
</div>
<div class="task-body">
<n-text class="task-name">{{ task.task_name }}</n-text>
<n-progress
type="line"
:percentage="getTaskProgress(task)"
:color="getTaskTypeColor(task.type, true)"
rail-color="rgba(255,255,255,0.1)"
height="4"
:show-indicator="false"
style="margin-top: 8px;"
:class="{ 'progress-pulse': taskExecuting }"
/>
</div>
</n-list-item>
</template>
<template v-else>
<div class="empty-tasks">
<n-text depth="3">无关联作战任务</n-text>
</div>
</template>
</n-list>
</div>
<div v-if="!taskDeployed && !taskExecuting" class="mission-locked-overlay">
<div class="lock-icon">⏸</div>
<n-text class="lock-text">AI 自动预案待命中<br/>等待授权同步至 Plane</n-text>
</div>
</div>
</n-card>
</n-grid-item>
</n-grid>
</div>
</n-layout-content>
</n-layout>
<!-- Simulation Modal -->
<n-modal v-model:show="showModal">
<n-card style="width: 600px" title="⚡ 模拟外部信号注入" :bordered="false" size="huge" role="dialog" aria-modal="true" class="glass-modal">
<n-form ref="formRef" :model="formValue">
<n-form-item label="来源" path="source">
<n-input v-model:value="formValue.source" placeholder="例如: TechCrunch, Twitter" />
</n-form-item>
<n-form-item label="内容" path="content">
<n-input v-model:value="formValue.content" type="textarea" placeholder="输入突发新闻..." :rows="4" />
</n-form-item>
</n-form>
<template #footer>
<n-button type="error" block @click="submitSignal" :loading="submitting">
🚀 发送并触发战略评估
</n-button>
</template>
</n-card>
</n-modal>
<!-- Competitor Config Modal -->
<n-modal v-model:show="showCompetitorModal">
<n-card style="width: 400px" title="⚔️ 设定主要竞品(某漫、某画等)" :bordered="false" size="large" role="dialog" aria-modal="true" class="glass-modal">
<n-space vertical>
<n-input v-for="(comp, index) in competitors" :key="index" v-model:value="competitors[index]" placeholder="输入竞品名称..." />
</n-space>
<template #footer>
<n-button type="primary" block @click="saveCompetitors">
保存配置
</n-button>
</template>
</n-card>
</n-modal>
<!-- Fact Snippet Modal -->
<n-modal v-model:show="showFactSnippet">
<n-card style="width: 500px" :bordered="false" size="medium" role="dialog" aria-modal="true" class="glass-modal fact-snippet-modal">
<template #header>
<div style="display: flex; align-items: center; gap: 8px;">
<span class="fact-icon">📄</span>
<span class="fact-title">{{ currentFactSnippet.title }}</span>
</div>
</template>
<div class="fact-content">
<div class="fact-source">{{ currentFactSnippet.source }}</div>
<div class="fact-divider"></div>
<div class="fact-text">{{ currentFactSnippet.content }}</div>
</div>
</n-card>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, reactive, watch, nextTick, computed } from 'vue'
import {
NLayout, NLayoutSider, NLayoutContent, NSpace, NCard, NButton, NList, NListItem, NTag, NText,
NGrid, NGridItem, NDivider, NResult, NCheckbox, NThing, NScrollbar, NSpin, NModal, NForm, NFormItem,
NInput, useMessage, NEmpty, NProgress, NIcon, NSteps, NStep, NStatistic
} from 'naive-ui'
import axios from 'axios'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { RadarChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, RadarComponent } from 'echarts/components'
use([CanvasRenderer, RadarChart, TitleComponent, TooltipComponent, LegendComponent, RadarComponent])
// Icons (Mock component for now, or use unicode)
const RadarIcon = () => '📡'
const message = useMessage()
const signals = ref<any[]>([])
const selectedSignal = ref<any>(null)
const loading = ref(false)
const showModal = ref(false)
const submitting = ref(false)
let pollingInterval: any = null
let countdownInterval: any = null
let deployTimeout: any = null
// AI 分析状态
const analyzing = ref(false)
const analysisProgress = ref(0)
const analysisStatus = ref('')
const ollamaReady = ref(false)
// Typewriter Effect State
const displayedAnalysis = reactive({
situation_analysis: '',
recommendation: '',
expected_benefits: ''
})
const typing = ref(false)
const formValue = ref({
source: 'Civitai',
content: 'Civitai 发布了针对漫画一致性的新 LoRA 模型,可显著改善角色脸崩问题',
impact_score: 0.9
})
const taskDeployed = ref(false)
const taskExecuting = ref(false)
const showScanEffect = ref(false)
// Feedback state
const feedbackSubmitted = ref(false)
const feedbackSubmitting = ref(false)
const signalFeedback = ref<any>(null)
const feedbackMatches = ref(false)
const showCompetitorModal = ref(false)
const competitors = ref(['某漫', '某画'])
// Fact Snippet Modal
const showFactSnippet = ref(false)
const currentFactSnippet = ref({
title: '',
source: '',
content: ''
})
const leadWindow = ref('48:00:00')
// Compute Load State
const isReservingCompute = ref(false)
const reservedComputePercent = ref(0)
// Computed properties for expensive operations
const currentStrategicInfo = computed(() => {
return getStrategicInfo(selectedSignal.value)
})
const currentGeneratedTasks = computed(() => {
return getGeneratedTasks(selectedSignal.value)
})
// Aggregate signals by source to avoid duplicates
interface AggregatedSignal {
id: string
source: string
impact_score: number
created_at: string
content: string
count: number
signals: any[]
latestSignal: any
}
const aggregatedSignals = computed(() => {
const groups = new Map<string, any[]>()
// Group signals by source
signals.value.forEach(signal => {
const key = signal.source || 'Unknown'
if (!groups.has(key)) {
groups.set(key, [])
}
groups.get(key)!.push(signal)
})
// Create aggregated signal objects
const result: AggregatedSignal[] = []
groups.forEach((groupSignals, source) => {
// Sort by created_at descending
groupSignals.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
const latest = groupSignals[0]
const count = groupSignals.length
let aggregatedContent = latest.content
let displayTitle = source
// If multiple signals from same source, create aggregated title
if (count > 1) {
// Check if it's Pixelle-Video or similar known projects
if (source.includes('Pixelle-Video') || source.includes('GitHub') || groupSignals.some(s => s.content.includes('Pixelle-Video'))) {
displayTitle = source
aggregatedContent = `检测到 ${source} 矩阵式更新,涉及 ${count} 个关键节点`
} else {
// Generic aggregation
displayTitle = source
aggregatedContent = `检测到 ${source} 系列更新,共 ${count} 条相关信号`
}
}
result.push({
id: latest.id, // Use latest signal's ID for selection
source: displayTitle,
impact_score: Math.max(...groupSignals.map(s => s.impact_score || 0)), // Use highest impact
created_at: latest.created_at,
content: aggregatedContent,
count,
signals: groupSignals,
latestSignal: latest
})
})
// Sort by impact_score and created_at
result.sort((a, b) => {
if (a.impact_score !== b.impact_score) {
return b.impact_score - a.impact_score
}
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
return result
})
// ROI Metrics
const getTotalEstimatedHours = computed(() => {
if (!currentGeneratedTasks.value || currentGeneratedTasks.value.length === 0) {
return 0
}
return currentGeneratedTasks.value.reduce((sum: number, task: any) => {
return sum + (task.estimated_hours || 0)
}, 0)
})
const getTotalEstimatedDays = computed(() => {
const hours = getTotalEstimatedHours.value
if (hours === 0) return 0
// Assume 8 hours per workday
return Math.ceil(hours / 8)
})
const getEstimatedComputeCost = computed(() => {
const hours = getTotalEstimatedHours.value
if (hours === 0) return '$0'
// Estimate: $2 per hour of GPU usage
const cost = hours * 2
return `$${cost.toFixed(0)}`
})
const getLeadWindowDays = computed(() => {
if (!selectedSignal.value) return 0
const leadHours = parseFloat(getSpeedAdvantage(selectedSignal.value))
if (leadHours <= 0) return 0
return Math.ceil(leadHours / 24)
})
// Compute Load Estimation
const computeLoadPercent = computed(() => {
if (!selectedSignal.value) return 42
// Base load varies by signal impact score
const baseLoad = 42
const impactBonus = (selectedSignal.value.impact_score || 0) * 30
// Add task-based load
const taskLoad = currentGeneratedTasks.value.length * 3
return Math.min(95, Math.round(baseLoad + impactBonus + taskLoad))
})
// Update Lead Window Countdown - AI Drama Business
const updateLeadWindow = () => {
if (!selectedSignal.value) {
leadWindow.value = '--:--:--'
return
}
// AI Drama: 1 week (168h) competitor baseline - 24h internal testing = 144h (6 days) lead advantage
const LEAD_ADVANTAGE_MS = (168 - 24) * 60 * 60 * 1000 // 144 hours in milliseconds
const INTERNAL_TESTING_MS = 24 * 60 * 60 * 1000 // 24 hours for internal testing
const created = new Date(selectedSignal.value.created_at).getTime()
const now = Date.now()
const elapsed = now - created
// If still within internal testing window, show full lead advantage
if (elapsed < INTERNAL_TESTING_MS) {
// We're still in the 24h internal testing phase
const h = Math.floor(LEAD_ADVANTAGE_MS / (1000 * 60 * 60))
const m = Math.floor((LEAD_ADVANTAGE_MS % (1000 * 60 * 60)) / (1000 * 60))
const s = Math.floor((LEAD_ADVANTAGE_MS % (1000 * 60)) / 1000)
leadWindow.value = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
return
}
// Calculate remaining lead advantage after internal testing phase
const remainingLead = LEAD_ADVANTAGE_MS - (elapsed - INTERNAL_TESTING_MS)
if (remainingLead <= 0) {
leadWindow.value = '00:00:00'
return
}
const h = Math.floor(remainingLead / (1000 * 60 * 60))
const m = Math.floor((remainingLead % (1000 * 60 * 60)) / (1000 * 60))
const s = Math.floor((remainingLead % (1000 * 60)) / 1000)
leadWindow.value = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
const saveCompetitors = () => {
localStorage.setItem('competitors', JSON.stringify(competitors.value))
showCompetitorModal.value = false
message.success('竞品配置已更新')
}
// Fact Snippets Data - AI Drama Business Intelligence
const factSnippets: Record<string, { title: string; source: string; content: string }> = {
'Civitai-LoRA': {
title: 'Civitai 漫画一致性 LoRA 模型技术报告',
source: 'civitai.com/models/anime-consistency-lora-v2',
content: '针对 AI 漫剧生产线的角色一致性解决方案:\n\n• 基于 LoRA 架构的角色保持技术\n• 解决「角色脸崩」核心痛点\n• 支持 768x1024 高清输出\n• 推理速度提升 40%\n• 与现有 Stable Diffusion 管线完全兼容\n• 预计可降低返工率 65%'
},
'Sora-2D': {
title: 'Sora 2D 动漫风格运动优化接口文档',
source: 'openai.com/research/sora-2d-animation',
content: 'OpenAI Sora 针对 2D 动漫场景的运动增强:\n\n• 新增 2D 动漫风格运动适配器\n• 支持逐帧动作捕捉与优化\n• 动态镜头语言自动生成\n• 可将单集制作成本从 500 元降至 50 元\n• API 接口:/v1/2d-anime-optimize\n• 需要配合动漫专用训练数据集'
},
'AI-Drama-Analysis': {
title: 'AI 漫剧生产线竞争力分析 v3.0',
source: 'Internal AI Drama Product Team / 2024-Q4',
content: '「AI 漫剧产品专家」战略研判:\n\n• 竞品(某漫、某画)平均适配周期:7 天\n• 我们的内测目标:24 小时完成验证\n• 领先窗口:6 天战略先发优势\n• 成本优化:单集 500元 → 50元(90% 降幅)\n• 质量:解决「角色脸崩」用户投诉核心痛点\n• 建议:立即启动 Civitai LoRA 集成验证'
},
'Pixelle-Video': {
title: 'Pixelle-Video v1.0 技术文档',
source: 'github.com/user/repo/README.md',
content: '基于 WebUI 架构,支持一键模型热加载。核心特性包括:\n\n• Gradio WebUI 界面,支持实时预览\n• 模型热加载机制,无需重启服务\n• 动作迁移 (Motion Transfer) 模块\n• 支持自定义工作流 pipeline\n• 完整的 REST API 接口'
},
'Whitepaper': {
title: 'Technical Whitepaper v2.0',
source: 'Internal Documentation / Technical Specs',
content: '本文档描述了下一代 AI 系统架构设计原则:\n\n• 微服务化组件设计\n• 事件驱动架构 (Event-Driven Architecture)\n• 实时数据流处理管道\n• 可扩展的模型集成层\n• 企业级安全与合规要求'
},
'Competitor': {
title: '竞品(某漫、某画)技术栈分析',
source: 'Market Intelligence Report / 2024-Q4',
content: '主要竞争对手动态分析:\n\n• 某漫:使用 SD 1.5 基座,适配周期 7-10 天\n• 某画:手工调优模型,单集成本 800 元\n• 市场痛点:角色脸崩、动作僵硬、成本高昂\n• 我们的优势:24h 内测完成、成本降至 50 元/集\n• 战略窗口:快速抢占市场空白期'
}
}
const showFactSnippetModal = (type: string) => {
const snippet = factSnippets[type] || factSnippets['Whitepaper']
currentFactSnippet.value = {
title: snippet.title,
source: snippet.source,
content: snippet.content
}
showFactSnippet.value = true
}
// --- Logic ---
const getOurProgress = (signal: any) => {
if (!signal) return 0
// AI Drama Business: We have 24-hour internal testing capability
// Strong signal (>= 0.8): Instant jump to 80% (AI Advantage + 24h testing target)
if (signal.impact_score >= 0.8) {
const created = new Date(signal.created_at).getTime()
const now = Date.now()
const hoursSince = (now - created) / (1000 * 60 * 60)
// If within 24h window, show progress ramping up
if (hoursSince < 24) {
return Math.min(80, 10 + (hoursSince / 24) * 70).toFixed(1)
}
return 80.0
}
// Weak signal: 10% (AI Captured)
return 10.0
}
const getCompetitorProgress = (signal: any) => {
if (!signal) return 0.0
// Weak signal (< 0.6): Competitor doesn't know (0%)
if (signal.impact_score < 0.6) return 0.0
// AI Drama Business: Competitors (某漫、某画) take 1 week (168 hours) to adapt
const COMPETITOR_ADAPT_TIME_HOURS = 168 // 7 days
const created = new Date(signal.created_at).getTime()
const now = Date.now()
const hoursSince = (now - created) / (1000 * 60 * 60)
// First 24h: Competitors still unaware (0-5%)
if (hoursSince < 24) return Math.min(5, hoursSince * 0.2).toFixed(1)
// After 24h, competitors start adapting (slow progression over 1 week)
const progress = ((hoursSince - 24) / (COMPETITOR_ADAPT_TIME_HOURS - 24)) * 100
return Math.min(90, progress).toFixed(1)
}
const getLeadTimeText = (signal: any) => {
if (!signal) return ''
// Weak Signal
if (signal.impact_score < 0.6) {
return '领先竞品:无限 (竞品未发现)'
}
// Strong Signal: Use consistent logic with getSpeedAdvantage
const val = getSpeedAdvantage(signal)
return `领先竞品:${val} 小时`
}
// Impact label helpers
const getLossLabel = (score: number) => {
if (score >= 0.9) return 'Critical'
if (score >= 0.7) return 'High'
if (score >= 0.4) return 'Medium'
return 'Low'
}
const getGainLabel = (score: number) => {
if (score >= 0.9) return 'Maximum'
if (score >= 0.7) return 'High'
if (score >= 0.4) return 'Moderate'
return 'Minimal'
}
// AI-Powered RAG Section Helpers
const getDetectedTechStacks = () => {
const techStacks = currentStrategicInfo.value.detected_tech_stacks || []
return Array.isArray(techStacks) ? techStacks : []
}
const getRelatedKnowledgeNodes = () => {
const knowledgeNodes = currentStrategicInfo.value.related_knowledge_nodes || []
return Array.isArray(knowledgeNodes) ? knowledgeNodes : []
}
const getTechStackType = (category: string) => {
const typeMap: Record<string, string> = {
framework: 'success',
model: 'error',
tool: 'info',
api: 'warning'
}
return typeMap[category] || 'default'
}
const getTechStackIcon = (category: string) => {
const iconMap: Record<string, string> = {
framework: '🏗️',
model: '🤖',
tool: '🔧',
api: '🔌'
}
return iconMap[category] || '📦'
}
const getRelevanceLabel = (relevance: string) => {
const labelMap: Record<string, string> = {
high: '高',
medium: '中',
low: '低'
}
return labelMap[relevance] || relevance
}
const getRelevanceType = (relevance: string) => {
const typeMap: Record<string, string> = {
high: 'error',
medium: 'warning',
low: 'default'
}
return typeMap[relevance] || 'default'
}
const getKnowledgeNodeIcon = (category: string) => {
const iconMap: Record<string, string> = {
tech_stack: '⚙️',
competitor: '⚔️',
market: '📊'
}
return iconMap[category] || '🧠'
}
const getKnowledgeNodeType = (category: string) => {
const typeMap: Record<string, string> = {
tech_stack: 'success',
competitor: 'error',
market: 'info'
}
return typeMap[category] || 'default'
}
const getKnowledgeNodeCategoryLabel = (category: string) => {
const labelMap: Record<string, string> = {
tech_stack: '技术',
competitor: '竞品',
market: '市场'
}
return labelMap[category] || category
}
const formatSignalContent = (content: string) => {
if (!content) return ''
// AI Drama Business: Prioritize AI Drama signals
if (content.includes('Civitai') || content.includes('LoRA')) {
return '🎭 关键情报:Civitai 发布漫画一致性 LoRA,可解决「角色脸崩」痛点'
}
if (content.includes('Sora') || (content.includes('2D') && content.includes('动漫'))) {
return '🎬 战略机遇:Sora 开放 2D 动漫运动优化 API,成本将降至 1/10'
}
// 2. Intelligent Renaming for Known Projects (check before desensitizing)
if (content.includes('Pixelle-Video')) {
return '监测到关键开源动态:Pixelle-Video 全自动短视频引擎更新'
}
// 1. Desensitize Paths
let cleaned = content.replace(/Path:\s*\/Users\/[\w\/\.-]+/gi, '')
// 3. Truncate if too long
if (cleaned.length > 80) return cleaned.substring(0, 80) + '...'
return cleaned
}
const getNode3Status = (signal: any) => {
const tasks = getGeneratedTasks(signal)
const hasCompleted = tasks.some((t: any) => t.status === 'Done')
if (hasCompleted) return 'finish'
if (tasks.length > 0) return 'process' // Active/Listening
return 'wait'
}
const deployStrategy = () => {
// Clear any existing timeout
if (deployTimeout) {
clearTimeout(deployTimeout)
}
// Start executing state immediately with sync animation
taskExecuting.value = true
// After 0.5s sync animation, show scan effect and complete deployment
deployTimeout = setTimeout(() => {
showScanEffect.value = true
// Another 1s for scan effect, then mark as deployed
setTimeout(() => {
taskDeployed.value = true
taskExecuting.value = false
showScanEffect.value = false
message.success('战略任务已全量下发!')
}, 1000)
deployTimeout = null
}, 500)
}
const getImpactColor = (score: number) => {
if (score >= 0.9) return 'error'
if (score >= 0.7) return 'warning'
return 'success' // Changed default to success green for low impact
}
const getTaskTypeColor = (type: string, isHex = false) => {
const map: any = {
backend: { type: 'info', hex: '#2080f0' },
frontend: { type: 'success', hex: '#18a058' },
database: { type: 'warning', hex: '#f0a020' }
}
const t = map[type.toLowerCase()] || { type: 'default', hex: '#999' }
return isHex ? t.hex : t.type
}
const getTaskProgress = (task: any) => {
// Real logic: Check if status is Done
if (task.status === 'Done') return 100
if (task.status === 'Failed') return 0
// Mock progress for pending tasks based on name hash
let hash = 0
for (let i = 0; i < task.task_name.length; i++) {
hash = task.task_name.charCodeAt(i) + ((hash << 5) - hash)
}
return Math.abs(hash % 60) + 10 // 10-70% for pending
}
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
return new Date(dateStr).toLocaleTimeString()
}
const getStrategicInfo = (signal: any) => {
if (!signal?.strategic_analysis) return { is_strategic: false }
try {
const root = JSON.parse(signal.strategic_analysis)
const isStrategic = signal.impact_score >= 0.9
// Handle new structure with nested decision_proposal
const decisionProposal = root.decision_proposal || {}
const competitivenessRadar = root.competitiveness_radar
return {
is_strategic: isStrategic,
confidence: root.confidence, // May be undefined for old signals
decision_proposal: decisionProposal,
competitiveness_radar: competitivenessRadar,
rag_context_used: root.rag_context_used || 0,
rag_context_sources: root.rag_context_sources || [],
voting: root.voting || null, // Multi-model voting info
tasks: root.tasks || [],
// AI-powered tech stack and knowledge node detection
detected_tech_stacks: root.detected_tech_stacks || [],
related_knowledge_nodes: root.related_knowledge_nodes || []
}
} catch (e) {
return { is_strategic: false }
}
}
// Confidence helpers
const getConfidenceType = (confidence: number) => {
if (confidence >= 0.8) return 'success'
if (confidence >= 0.6) return 'warning'
return 'default'
}
const formatConfidence = (confidence: number) => {
return `${Math.round(confidence * 100)}%`
}
// Priority helpers
const getPriorityType = (priority: string) => {
switch (priority) {
case 'critical': return 'error'
case 'high': return 'warning'
case 'medium': return 'info'
case 'low': return 'default'
default: return 'default'
}
}
const getPriorityLabel = (priority: string) => {
switch (priority) {
case 'critical': return '🔴 紧急'
case 'high': return '🟠 高优'
case 'medium': return '🟡 中等'
case 'low': return '🟢 低优'
default: return '🟢 低优'
}
}
// Voting helpers
const getModelVoteType = (modelName: string, strategicInfo: any) => {
// For now, since we don't have individual model results in the response,
// we'll return a default type. This could be enhanced if we send individual results.
return strategicInfo.is_strategic ? 'success' : 'default'
}
const getModelVoteLabel = (modelName: string, strategicInfo: any) => {
// Similar to above, placeholder logic
return strategicInfo.is_strategic ? '战略级' : '非战略'
}
// Feedback functions
const submitFeedback = async (agreed: boolean) => {
if (!selectedSignal.value) return
feedbackSubmitting.value = true
try {
// Determine what the user actually thinks
// If agreed=true, user thinks AI was correct
// If agreed=false, user thinks AI was wrong (opposite)
const aiPrediction = currentStrategicInfo.value.is_strategic
const userFeedback = agreed ? aiPrediction : !aiPrediction
const response = await axios.post(`http://127.0.0.1:8000/signals/${selectedSignal.value.id}/feedback`, {
signal_id: selectedSignal.value.id,
was_strategic: userFeedback,
reason: agreed ? 'AI判断准确' : 'AI判断不准确'
})
signalFeedback.value = response.data
feedbackSubmitted.value = true
feedbackMatches.value = response.data.is_correct
message.success(feedbackMatches.value ? '感谢反馈!AI 判断正确' : '感谢反馈!我们会从错误中学习')
} catch (error) {
message.error('提交反馈失败')
} finally {
feedbackSubmitting.value = false
}
}
const resetFeedback = () => {
feedbackSubmitted.value = false
signalFeedback.value = null
feedbackMatches.value = false
}
const getGeneratedTasks = (signal: any) => {
// Prefer tasks from the strategic analysis if available
if (signal?.strategic_analysis) {
try {
const root = JSON.parse(signal.strategic_analysis)
if (root.tasks && Array.isArray(root.tasks)) {
return root.tasks
}
} catch (e) {
// ignore
}
}
// Fallback to separate tasks_generated field if exists
if (!signal?.tasks_generated) return []
try {
return JSON.parse(signal.tasks_generated)
} catch (e) {
return []
}
}
// --- Timeline & Moat Logic ---
const formatTime = (timeStr: string | Date) => {
if (!timeStr) return '--:--'
const d = new Date(timeStr)
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
}
const getAiTime = (signal: any) => {
if (!signal) return ''
// Simulate AI time: created_at + 2 minutes
const d = new Date(signal.created_at)
d.setMinutes(d.getMinutes() + 2)
return d.toISOString()
}
const getCodeTime = (signal: any) => {
if (!signal) return '等待中...'
const tasks = getGeneratedTasks(signal)
// Try to find real completion time
const completedTask = tasks.find((t: any) => t.status === 'Done' && t.completed_at)
if (completedTask) {
return formatTime(completedTask.completed_at)
}
// Fallback simulation
if (tasks.length > 0) {
// Simulate Code Commit time: created_at + 45 minutes
const d = new Date(signal.created_at)
d.setMinutes(d.getMinutes() + 45)
return formatTime(d.toISOString())
}
return '等待中...'
}
const getTimelineStep = (signal: any) => {
if (!signal) return 1
const tasks = getGeneratedTasks(signal)
// Check for real completion
const hasCompletedTask = tasks.some((t: any) => t.status === 'Done')
if (hasCompletedTask) return 3 // Code Committed/Done
if (tasks.length > 0) return 2 // Tasks generated but not done (In Progress)
if (signal.strategic_analysis) return 2 // AI Done
return 1 // Signal Received
}
const getSpeedAdvantage = (signal: any) => {
if (!signal) return '0.0'
// AI Drama Business Logic:
// Competitors (某漫、某画) take 1 week (168 hours) to adapt
// Our target: 24 hours internal testing
// Lead advantage: 168h - 24h = 144 hours = 6 days
const COMPETITOR_ADAPT_HOURS = 168 // 7 days
const INTERNAL_TESTING_HOURS = 24 // Our target
// Calculate remaining time advantage
const created = new Date(signal.created_at).getTime()
const now = Date.now()
const hoursSinceSignal = (now - created) / (1000 * 60 * 60)
// If we haven't deployed yet, our advantage decays
// Maximum advantage: 144 hours (6 days)
// Current advantage: 144 - (hours elapsed - 24) if elapsed > 24
let remainingAdvantage = (COMPETITOR_ADAPT_HOURS - INTERNAL_TESTING_HOURS) - Math.max(0, hoursSinceSignal - INTERNAL_TESTING_HOURS)
if (remainingAdvantage < 0) remainingAdvantage = 0
return remainingAdvantage.toFixed(1)
}
const getRadarOption = (radarData: any) => {
return {
backgroundColor: 'transparent',
tooltip: {},
legend: {
data: ['Current Capabilities', 'Projected (With Tech)'],
textStyle: { color: '#ccc' },
bottom: 0
},
radar: {
indicator: [
{ name: 'Efficiency', max: 10 },
{ name: 'Cost', max: 10 },
{ name: 'UX', max: 10 },
{ name: 'Innovation', max: 10 },
{ name: 'Market', max: 10 }
],
shape: 'circle',
splitNumber: 5,
axisName: {
color: '#63e2b7'
},
splitLine: {
lineStyle: {
color: [
'rgba(99, 226, 183, 0.1)',
'rgba(99, 226, 183, 0.2)',
'rgba(99, 226, 183, 0.4)',
'rgba(99, 226, 183, 0.6)',
'rgba(99, 226, 183, 0.8)',
'rgba(99, 226, 183, 1)'
].reverse()
}
},
splitArea: {
show: false
},
axisLine: {
lineStyle: {
color: 'rgba(99, 226, 183, 0.5)'
}
}
},
series: [
{
name: 'Competitiveness Comparison',
type: 'radar',
data: [
{
value: radarData.current || [5, 5, 5, 5, 5],
name: 'Current Capabilities',
itemStyle: { color: '#f5222d' },
areaStyle: { color: 'rgba(245, 34, 45, 0.3)' }
},
{
value: radarData.projected || [8, 8, 8, 8, 8],
name: 'Projected (With Tech)',
itemStyle: { color: '#63e2b7' },
areaStyle: { color: 'rgba(99, 226, 183, 0.3)' }
}
]
}
]
}
}
// --- Typewriter Effect ---
const startTypewriter = async (fullData: any) => {
typing.value = true
// Reset
displayedAnalysis.situation_analysis = ''
displayedAnalysis.recommendation = ''
displayedAnalysis.expected_benefits = ''
if (!fullData) {
typing.value = false
return
}
// Cancel any previous typewriter
const currentTypewriterId = Date.now()
;(window as any)._typewriterId = currentTypewriterId
const typeField = async (key: 'situation_analysis' | 'recommendation' | 'expected_benefits') => {
const text = fullData[key] || ''
// If text is already displayed (e.g. from previous run), don't retype if it's the same?
// But we cleared it.
// Optimization: if text is same as displayed, skip? No, we want the effect.
for (let i = 0; i < text.length; i++) {
if ((window as any)._typewriterId !== currentTypewriterId) return // Cancelled
displayedAnalysis[key] += text[i]
await new Promise(r => setTimeout(r, 10)) // Slower typing to reduce CPU load
}
}
await typeField('situation_analysis')
await typeField('recommendation')
await typeField('expected_benefits')
if ((window as any)._typewriterId === currentTypewriterId) {
typing.value = false
}
}
// Watch selected signal to trigger typewriter
watch(selectedSignal, (newVal, oldVal) => {
if (newVal) {
// Reset deployment state for new signal
if (!oldVal || newVal.id !== oldVal.id) {
taskDeployed.value = false
taskExecuting.value = false
resetFeedback() // Reset feedback state for new signal
// Load existing feedback if any
loadSignalFeedback(newVal.id)
// Trigger compute reservation animation
triggerComputeReservation(newVal)
}
// Optimization: If ID is same, don't re-trigger typewriter unless content changed significantly
if (oldVal && newVal.id === oldVal.id) {
// Check if strategic analysis content changed
if (newVal.strategic_analysis === oldVal.strategic_analysis) {
return
}
}
const info = getStrategicInfo(newVal)
if (info.decision_proposal) {
startTypewriter(info.decision_proposal)
} else {
startTypewriter(null)
}
}
})
// Load existing feedback for a signal
const loadSignalFeedback = async (signalId: string) => {
try {
const response = await axios.get(`http://127.0.0.1:8000/signals/${signalId}/feedbacks`)
if (response.data && response.data.length > 0) {
const latestFeedback = response.data[0]
signalFeedback.value = latestFeedback
feedbackSubmitted.value = true
feedbackMatches.value = latestFeedback.is_correct
}
} catch (error) {
// No feedback exists yet, that's fine
console.log('No existing feedback for this signal')
}
}
// Compute Reservation Animation
const triggerComputeReservation = (signal: any) => {
// Calculate reserved compute based on signal impact
const impact = signal.impact_score || 0
const reserved = Math.round(impact * 25) // Up to 25% reserved for high impact
isReservingCompute.value = true
reservedComputePercent.value = 0
// Animate the reservation
const duration = 800
const steps = 20
const increment = reserved / steps
const stepDuration = duration / steps
let currentStep = 0
const animateReservation = () => {
if (currentStep < steps) {
reservedComputePercent.value = Math.round((currentStep + 1) * increment)
currentStep++
setTimeout(animateReservation, stepDuration)
} else {
// Hold for a moment, then fade out
setTimeout(() => {
isReservingCompute.value = false
reservedComputePercent.value = 0
}, 500)
}
}
animateReservation()
}
// --- Signal Handling ---
const isNewSignal = (signal: any) => {
// Logic to determine if signal is "new" (e.g. created within last 10 seconds)
const created = new Date(signal.created_at).getTime()
const now = new Date().getTime()
return (now - created) < 10000 // 10 seconds flash window
}
const fetchSignals = async (isPoll = false) => {
if (!isPoll) loading.value = true
try {
const res = await axios.get('http://127.0.0.1:8000/signals/')
const newSignals = res.data
// Check for task status updates if polling
if (isPoll && signals.value.length > 0) {
// Find differences in tasks
for (const newSig of newSignals) {
const oldSig = signals.value.find(s => s.id === newSig.id)
if (oldSig) {
const newTasks = getGeneratedTasks(newSig)
const oldTasks = getGeneratedTasks(oldSig)
if (newTasks.length > 0 && oldTasks.length > 0) {
for (const newTask of newTasks) {
const oldTask = oldTasks.find((t: any) => (t.plane_id && t.plane_id === newTask.plane_id) || t.task_name === newTask.task_name)
// Detect status change to Done
if (oldTask && oldTask.status !== 'Done' && newTask.status === 'Done') {
// Trigger Notification
message.success(`✅ 任务已就绪:${newTask.task_name} (AI 审计通过)`)
}
}
}
}
}
}
// Smart update to avoid replacing objects if not needed (preserves references for selectedSignal)
// Map new signals to old ones if ID matches
const updatedList = newSignals.map((newSig: any) => {
const oldSig = signals.value.find(s => s.id === newSig.id)
if (oldSig) {
// Update properties of old object to keep reactivity stable if possible,
// BUT Vue 3 refs replace objects.
// Better: Replace the object but if it is the selected one, update selectedSignal ref too.
return newSig
}
return newSig
})
signals.value = updatedList
// Fix selectedSignal reference
if (selectedSignal.value) {
const stillExists = signals.value.find(s => s.id === selectedSignal.value.id)
if (stillExists) {
// Only update reference if content changed to trigger watcher only when needed
// But we need to ensure the selectedSignal ref points to the new object so computed properties update
if (JSON.stringify(stillExists) !== JSON.stringify(selectedSignal.value)) {
selectedSignal.value = stillExists
}
}
} else if (signals.value.length > 0 && !isPoll) {
// Auto select first if none selected (only on initial load)
selectedSignal.value = signals.value[0]
}
} catch (error) {
if (!isPoll) message.error('获取信号列表失败')
} finally {
if (!isPoll) loading.value = false
}
}
const selectSignal = (signal: any) => {
// If clicking same signal, do nothing
if (selectedSignal.value && selectedSignal.value.id === signal.id) return
selectedSignal.value = signal
}
const submitSignal = async () => {
if (!formValue.value.content) return
submitting.value = true
try {
const res = await axios.post('http://127.0.0.1:8000/signals/ingest', formValue.value)
message.success('信号已注入,战略评估完成!')
showModal.value = false
await fetchSignals()
// Auto select the new signal if it's in the list
const newSignal = signals.value.find((s: any) => s.id === res.data.id)
if (newSignal) {
selectedSignal.value = newSignal
}
// 停止 Ollama 节省资源
await axios.post('http://127.0.0.1:8000/ai/stop')
ollamaReady.value = false
message.info('🌱 Ollama 已停止,节省资源')
} catch (error) {
message.error('信号注入失败')
} finally {
submitting.value = false
}
}
// 检查 Ollama 状态
const checkOllamaStatus = async () => {
try {
const response = await axios.get('http://127.0.0.1:8000/ai/status', { timeout: 3000 })
ollamaReady.value = response.data.status === 'ready'
const modelCount = response.data.model_count || 0
const models = response.data.available_models || []
message.info(ollamaReady.value ? `✅ Ollama 已就绪 (${modelCount} 个模型: ${models.join(', ')})` : '❌ Ollama 未运行')
} catch (error) {
ollamaReady.value = false
message.warning('⚠️ 无法检测 Ollama 状态,请确保后端服务运行中')
}
}
// 启动 AI 分析
const startAnalysis = async () => {
// 先显示进度条
analyzing.value = true
analysisProgress.value = 0
analysisStatus.value = '正在启动 AI 分析...'
// 如果 Ollama 未就绪,给出提示但继续尝试
if (!ollamaReady.value) {
analysisStatus.value = '⚠️ 未检测到 Ollama,尝试连接...'
}
try {
// 模拟进度动画
const progressInterval = setInterval(() => {
if (analysisProgress.value < 90) {
analysisProgress.value += Math.random() * 10
if (analysisProgress.value > 90) analysisProgress.value = 90
}
}, 500)
// 调用后端获取并分析信号
analysisStatus.value = '正在获取 HackerNews 信号...'
const response = await axios.post('http://127.0.0.1:8000/signals/fetch')
clearInterval(progressInterval)
analysisProgress.value = 100
const newCount = response.data.new_signals_count || 0
const processedCount = response.data.processed_count || 0
analysisStatus.value = `分析完成!获取 ${newCount} 个信号,AI 研判 ${processedCount} 个`
message.success(`✅ 分析完成!获取 ${newCount} 个信号,AI 研判 ${processedCount} 个`)
// 刷新信号列表
await fetchSignals()
// 停止 Ollama 节省资源
analysisStatus.value = '正在停止 Ollama...'
await axios.post('http://127.0.0.1:8000/ai/stop')
ollamaReady.value = false
analysisStatus.value = `完成!Ollama 已停止`
setTimeout(() => {
analyzing.value = false
analysisProgress.value = 0
}, 3000)
} catch (error: any) {
analyzing.value = false
analysisProgress.value = 0
const errorMsg = error.response?.data?.detail || error.message || '未知错误'
message.error(`分析失败: ${errorMsg}`)
if (errorMsg.includes('ollama') || errorMsg.includes('11434')) {
ollamaReady.value = false
}
}
}
onMounted(() => {
// Load competitors from localStorage
const saved = localStorage.getItem('competitors')
if (saved) {
try {
competitors.value = JSON.parse(saved)
} catch (e) {
console.error('Failed to parse competitors from localStorage:', e)
}
}
fetchSignals()
// Poll signals every 5 seconds (reduced from 1s for better performance)
pollingInterval = setInterval(() => {
fetchSignals(true)
}, 5000)
// Update countdown separately every second
countdownInterval = setInterval(() => {
updateLeadWindow()
}, 1000)
// 检查 Ollama 状态
checkOllamaStatus()
})
onUnmounted(() => {
if (pollingInterval) clearInterval(pollingInterval)
if (countdownInterval) clearInterval(countdownInterval)
if (deployTimeout) clearTimeout(deployTimeout)
// Cancel typewriter effect
;(window as any)._typewriterId = null
})
</script>
<style scoped>
.moat-card {
background: linear-gradient(90deg, rgba(30, 41, 59, 0.6) 0%, rgba(30, 41, 59, 0.3) 100%);
border: 1px solid rgba(99, 226, 183, 0.2);
}
.moat-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
}
.moat-metric {
display: flex;
flex-direction: column;
min-width: 200px;
}
.moat-subtitle {
font-size: 12px;
color: #94a3b8;
margin-top: 4px;
}
.metric-unit {
font-size: 14px;
color: #94a3b8;
margin-left: 4px;
}
.metric-icon {
margin-right: 8px;
}
.moat-timeline {
flex: 1;
margin-left: 48px;
padding-top: 8px;
}
/* Override Step Colors for Dark Mode */
:deep(.n-step-indicator) {
background-color: transparent !important;
border-color: #63e2b7 !important;
}
:deep(.n-step-content-header__title) {
color: #e2e8f0 !important;
}
:deep(.n-step-content-description) {
color: #94a3b8 !important;
}
:deep(.n-statistic .n-statistic-value__content) {
color: #63e2b7;
font-weight: bold;
}
/* --- */
.window-countdown {
display: flex;
align-items: center;
background: rgba(245, 34, 45, 0.1);
padding: 2px 8px;
border-radius: 4px;
border: 1px solid rgba(245, 34, 45, 0.3);
}
.countdown-icon {
font-size: 12px;
margin-right: 4px;
animation: spin 2s linear infinite;
}
.countdown-val {
font-size: 14px;
font-weight: bold;
color: #f5222d;
font-family: monospace;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.authorize-btn {
font-weight: 800;
letter-spacing: 1px;
box-shadow: 0 0 20px rgba(208, 48, 80, 0.4);
transition: all 0.3s ease;
height: 56px; /* Taller button */
font-size: 16px;
}
.authorize-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 0 30px rgba(208, 48, 80, 0.6);
}
.authorize-icon {
margin-right: 8px;
font-size: 20px;
}
/* Command Center Global Styles */
.scan-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(99, 226, 183, 0.1);
z-index: 9999;
pointer-events: none;
animation: scanScreen 1.5s ease-in-out;
}
@keyframes scanScreen {
0% { background: rgba(99, 226, 183, 0); clip-path: inset(0 0 100% 0); }
50% { background: rgba(99, 226, 183, 0.2); clip-path: inset(0 0 0 0); }
100% { background: rgba(99, 226, 183, 0); clip-path: inset(100% 0 0 0); }
}
.strategy-room {
display: flex;
flex-direction: column;
height: 100%;
}
.command-center {
height: 100vh;
background-color: #020617; /* Dark Blue Background */
color: #e2e8f0;
font-family: 'Inter', sans-serif;
overflow: hidden;
}
/* Glassmorphism Panels */
.glass-panel, .glass-sider, .glass-content, .glass-card {
background: rgba(30, 41, 59, 0.4);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.glass-sider {
border-right: 1px solid rgba(255, 255, 255, 0.05);
}
/* Status Bar */
.status-bar {
min-height: 64px;
height: auto;
display: flex;
align-items: center;
padding: 8px 24px;
margin-bottom: 16px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
}
/* CSS Text Truncation and Layout Fixes */
.status-item {
display: flex;
flex-direction: column;
padding: 0 16px;
border-right: 1px solid rgba(255, 255, 255, 0.1);
min-width: 0; /* Enable flex item shrinking */
}
.status-label {
font-size: 12px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-value {
font-size: 24px;
font-weight: 700;
color: #e2e8f0;
line-height: 1.2;
}
.progress-comparison {
margin-top: 4px;
width: 100%;
}
.progress-row {
display: flex;
align-items: center;
margin-bottom: 2px;
width: 100%;
}
.progress-row .label {
font-size: 10px;
color: #94a3b8;
width: 32px; /* Fixed width for label */
flex-shrink: 0;
}
/* Ensure progress bar takes remaining space */
.progress-row :deep(.n-progress) {
flex: 1;
width: 0; /* Allow flex shrink */
min-width: 0;
}
/* AI 分析区域样式 */
.status-item :deep(.n-progress) {
--n-rail-color: rgba(255, 255, 255, 0.1) !important;
--n-fill-color: #63e2b7 !important;
}
.lead-time-text {
font-size: 10px;
color: #63e2b7;
text-align: right;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.highlight-green {
color: #63e2b7;
text-shadow: 0 0 10px rgba(99, 226, 183, 0.3);
}
/* Compute Load Display */
.compute-load-item {
min-width: 140px;
}
.compute-load-display {
display: flex;
align-items: center;
gap: 8px;
}
.status-value.reserved {
color: #f0a020;
text-shadow: 0 0 10px rgba(240, 160, 32, 0.5);
transition: all 0.3s ease;
}
.reserved-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
background: rgba(240, 160, 32, 0.15);
border-radius: 4px;
border: 1px solid rgba(240, 160, 32, 0.4);
animation: reservedPulse 1.5s ease-in-out infinite;
}
.reserved-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: #f0a020;
animation: dotBlink 0.8s ease-in-out infinite;
}
.reserved-text {
font-size: 10px;
color: #f0a020;
font-weight: 600;
text-transform: uppercase;
}
@keyframes reservedPulse {
0%, 100% { opacity: 0.8; }
50% { opacity: 1; box-shadow: 0 0 8px rgba(240, 160, 32, 0.3); }
}
@keyframes dotBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.compute-bar {
position: relative;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 4px;
}
.compute-bar-fill {
position: absolute;
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
.compute-bar-fill.current {
background: linear-gradient(90deg, #63e2b7, #18a058);
left: 0;
}
.compute-bar-fill.reserved {
background: linear-gradient(90deg, #f0a020, #f59e0b);
left: 0;
opacity: 0.7;
animation: reservedFill 1s ease-in-out infinite alternate;
}
@keyframes reservedFill {
0% { opacity: 0.5; }
100% { opacity: 0.8; }
}
/* Signal List */
.panel-header {
display: flex;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.signal-list .n-list-item {
background: transparent;
border-radius: 8px;
margin-bottom: 8px;
border: 1px solid transparent;
transition: all 0.3s ease;
}
.signal-list .n-list-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.selected-item {
background: rgba(99, 226, 183, 0.1) !important;
border: 1px solid rgba(99, 226, 183, 0.3) !important;
}
.new-signal-flash {
animation: flashRed 2s infinite;
}
@keyframes flashRed {
0% { box-shadow: 0 0 0 rgba(208, 48, 80, 0); }
50% { box-shadow: 0 0 15px rgba(208, 48, 80, 0.4); border-color: rgba(208, 48, 80, 0.6); }
100% { box-shadow: 0 0 0 rgba(208, 48, 80, 0); }
}
.signal-content {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-top: 8px;
font-size: 13px;
color: #cbd5e1;
}
/* Empty State Radar */
.empty-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
}
.radar-scan {
width: 100px;
height: 100px;
border: 2px solid #63e2b7;
border-radius: 50%;
position: relative;
box-shadow: 0 0 20px rgba(99, 226, 183, 0.2);
}
.priority-legend {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 0;
flex-wrap: wrap;
}
.radar-scan::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 50%;
height: 50%;
background: linear-gradient(45deg, rgba(99, 226, 183, 0.5), transparent 50%);
transform-origin: top left;
animation: scan 2s linear infinite;
border-radius: 100% 0 0 0;
}
@keyframes scan {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Strategy Room */
.ai-analysis-card {
height: 100%;
background: rgba(15, 23, 42, 0.6);
}
.card-title {
display: flex;
align-items: center;
font-size: 18px;
color: #63e2b7;
}
/* Confidence tag styles */
.card-title :deep(.n-tag.success) {
background: rgba(24, 160, 88, 0.2);
border-color: rgba(24, 160, 88, 0.5);
}
.card-title :deep(.n-tag.warning) {
background: rgba(240, 160, 32, 0.2);
border-color: rgba(240, 160, 32, 0.5);
}
.card-title :deep(.n-tag.default) {
background: rgba(148, 163, 184, 0.2);
border-color: rgba(148, 163, 184, 0.5);
}
/* Priority tag special styles */
.card-title :deep(.n-tag.error) {
/* CRITICAL priority */
background: rgba(208, 48, 80, 0.2);
border-color: rgba(208, 48, 80, 0.5);
animation: pulse-critical 2s infinite;
}
.card-title :deep(.n-tag.warning) {
/* HIGH priority */
background: rgba(240, 160, 32, 0.2);
border-color: rgba(240, 160, 32, 0.5);
}
.card-title :deep(.n-tag.info) {
/* MEDIUM priority */
background: rgba(45, 150, 250, 0.2);
border-color: rgba(45, 150, 250, 0.5);
}
@keyframes pulse-critical {
0%, 100% { box-shadow: 0 0 5px rgba(208, 48, 80, 0.3); }
50% { box-shadow: 0 0 15px rgba(208, 48, 80, 0.6); }
}
.typewriter-text {
font-family: 'JetBrains Mono', monospace;
line-height: 1.6;
color: #e2e8f0;
font-size: 14px;
}
.highlight-text {
color: #63e2b7;
font-weight: bold;
background: rgba(99, 226, 183, 0.1);
padding: 8px;
border-radius: 4px;
border-left: 3px solid #63e2b7;
}
/* Mission Board */
.mission-board {
height: 100%;
background: rgba(15, 23, 42, 0.6);
transition: all 0.3s ease;
}
.mission-board.executing {
border: 1px solid rgba(99, 226, 183, 0.5);
box-shadow: 0 0 20px rgba(99, 226, 183, 0.2);
}
.executing-tag {
position: relative;
}
.executing-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #f0a020;
margin-right: 6px;
animation: executingPulse 1s infinite;
}
@keyframes executingPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
}
.task-item {
background: rgba(255, 255, 255, 0.03);
margin-bottom: 8px;
border-radius: 6px;
padding: 10px;
transition: all 0.3s ease;
}
.task-item.pulse-active {
animation: taskPulse 1.5s ease-in-out infinite;
}
@keyframes taskPulse {
0%, 100% { background: rgba(255, 255, 255, 0.03); }
50% { background: rgba(99, 226, 183, 0.08); }
}
.progress-pulse :deep(.n-progress-graph-line-fill) {
animation: progressPulse 2s ease-in-out infinite;
}
@keyframes progressPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.task-header {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.task-name {
font-weight: 500;
color: #f1f5f9;
}
.task-hours {
font-size: 12px;
color: #94a3b8;
}
.glass-modal {
background: rgba(30, 41, 59, 0.9) !important;
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Strategy Sandbox Styles */
.sandbox-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 10px;
}
.sandbox-step {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.03);
padding: 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.final-step {
background: rgba(99, 226, 183, 0.1);
border-color: rgba(99, 226, 183, 0.3);
}
.step-icon {
font-size: 24px;
margin-right: 16px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
.step-content {
flex: 1;
}
.step-title {
font-size: 12px;
color: #94a3b8;
text-transform: uppercase;
margin-bottom: 4px;
}
.step-desc {
font-size: 14px;
color: #e2e8f0;
font-weight: 500;
}
.step-arrow {
text-align: center;
color: #63e2b7;
font-size: 18px;
opacity: 0.5;
margin: -8px 0;
}
.impact-visual {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
}
.impact-bar {
display: flex;
align-items: center;
font-size: 11px;
}
.bar-label {
width: 60px;
color: #94a3b8;
}
.bar-fill {
height: 6px;
border-radius: 3px;
background: #63e2b7;
text-align: right;
padding-right: 4px;
line-height: 6px;
color: transparent; /* Hide text, just visual */
}
.impact-bar.loss .bar-fill {
background: #f5222d;
}
/* Sandbox Flowchart */
.sandbox-container {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 16px;
}
.sandbox-step {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 12px;
border-radius: 8px;
width: 100%;
margin-bottom: 8px;
transition: all 0.3s ease;
}
.sandbox-step:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(99, 226, 183, 0.3);
}
.final-step {
border-color: #63e2b7;
background: rgba(99, 226, 183, 0.05);
}
.step-icon {
font-size: 24px;
margin-right: 16px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
border-radius: 50%;
}
.step-content {
flex: 1;
}
.step-title {
font-size: 12px;
color: #94a3b8;
text-transform: uppercase;
margin-bottom: 4px;
}
.step-desc {
font-size: 14px;
color: #e2e8f0;
}
.step-arrow {
color: rgba(255, 255, 255, 0.2);
font-size: 16px;
margin-bottom: 8px;
}
/* Impact Visual */
.impact-visual {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.impact-bar {
display: flex;
align-items: center;
font-size: 10px;
}
.impact-bar .bar-label {
width: 60px;
color: #94a3b8;
}
.impact-bar .bar-fill {
height: 6px;
border-radius: 3px;
background: #333;
position: relative;
transition: width 1s ease;
}
.impact-bar.loss .bar-fill {
background: #f5222d;
}
.impact-bar.gain .bar-fill {
background: #63e2b7;
}
/* RAG Sources */
.rag-sources {
display: flex;
flex-direction: column;
gap: 4px;
}
.ai-annotation {
font-size: 11px;
color: #63e2b7;
font-style: italic;
margin-top: 4px;
animation: fadeIn 1s ease-in;
}
.clickable-tag {
cursor: pointer;
transition: all 0.3s ease;
}
.clickable-tag:hover {
transform: translateX(4px);
box-shadow: 0 0 10px rgba(99, 226, 183, 0.4);
}
/* Dynamic RAG Section */
.dynamic-rag-section {
margin-bottom: 12px;
}
/* Tech Stacks Section */
.tech-stacks-section {
margin-bottom: 12px;
}
.rag-section-title {
font-size: 11px;
color: #a0a0a0;
margin-bottom: 6px;
font-weight: 500;
}
.tech-stacks-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tech-stack-tag {
display: inline-flex;
align-items: center;
transition: all 0.2s ease;
}
.tech-stack-tag:hover {
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(99, 226, 183, 0.3);
}
.tech-icon {
font-size: 12px;
margin-right: 4px;
}
/* Knowledge Nodes Section */
.knowledge-nodes-section {
margin-bottom: 12px;
}
.knowledge-nodes-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.knowledge-node-item {
padding: 8px 10px;
background: rgba(99, 226, 183, 0.08);
border: 1px solid rgba(99, 226, 183, 0.2);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.knowledge-node-item:hover {
background: rgba(99, 226, 183, 0.12);
border-color: rgba(99, 226, 183, 0.4);
transform: translateX(4px);
box-shadow: 0 2px 8px rgba(99, 226, 183, 0.2);
}
.node-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.node-icon {
font-size: 14px;
}
.node-title {
font-size: 13px;
font-weight: 500;
color: #e0e0e0;
flex: 1;
}
.node-summary {
font-size: 11px;
color: #a0a0a0;
line-height: 1.4;
margin-top: 2px;
}
.ai-annotation.no-data {
color: #888;
font-style: italic;
padding: 10px;
background: rgba(99, 226, 183, 0.08);
border-radius: 4px;
}
.rag-section-title {
font-size: 12px;
font-weight: 600;
color: #63e2b7;
margin-bottom: 4px;
}
/* Voting Details Section */
.voting-details {
margin: 16px 0;
padding: 12px;
background: rgba(99, 226, 183, 0.05);
border-radius: 8px;
border: 1px solid rgba(99, 226, 183, 0.15);
}
.voting-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.voting-title {
font-size: 12px;
font-weight: 600;
color: #63e2b7;
}
.voting-models {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.voting-model-item {
flex: 1;
padding: 8px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.model-name {
font-size: 11px;
color: #94a3b8;
}
.model-vote {
display: flex;
align-items: center;
}
.voting-summary {
padding-top: 8px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
}
/* Fact Snippet Modal */
.fact-snippet-modal {
border: 1px solid rgba(99, 226, 183, 0.3);
}
.fact-icon {
font-size: 18px;
}
.fact-title {
font-size: 14px;
font-weight: 600;
color: #63e2b7;
}
.fact-content {
padding: 8px 0;
}
.fact-source {
font-size: 11px;
color: #94a3b8;
font-family: 'JetBrains Mono', monospace;
margin-bottom: 12px;
}
.fact-divider {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
margin-bottom: 12px;
}
.fact-text {
font-size: 13px;
color: #e2e8f0;
line-height: 1.8;
white-space: pre-line;
background: rgba(15, 23, 42, 0.5);
padding: 12px;
border-radius: 8px;
border-left: 3px solid #63e2b7;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* ROI Scorecard */
.roi-scorecard {
background: linear-gradient(135deg, rgba(99, 226, 183, 0.1) 0%, rgba(30, 41, 59, 0.6) 100%);
border: 1px solid rgba(99, 226, 183, 0.3);
border-radius: 12px;
padding: 16px;
margin-top: 16px;
position: relative;
overflow: hidden;
}
.roi-scorecard::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, #63e2b7, transparent);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% { opacity: 0.3; }
50% { opacity: 1; }
100% { opacity: 0.3; }
}
.roi-header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.roi-title {
font-size: 14px;
font-weight: 700;
color: #63e2b7;
text-transform: uppercase;
letter-spacing: 1px;
}
.roi-metrics {
display: flex;
align-items: center;
justify-content: space-around;
gap: 16px;
}
.roi-metric-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 12px 8px;
border-radius: 8px;
background: rgba(15, 23, 42, 0.5);
transition: all 0.3s ease;
}
.roi-metric-item:hover {
transform: translateY(-2px);
background: rgba(15, 23, 42, 0.8);
}
.roi-metric-item.highlight {
background: rgba(99, 226, 183, 0.15);
border: 1px solid rgba(99, 226, 183, 0.3);
}
.roi-label {
font-size: 11px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.roi-value {
font-size: 24px;
font-weight: 800;
font-family: 'JetBrains Mono', monospace;
margin-bottom: 4px;
}
.roi-value.primary {
color: #63e2b7;
text-shadow: 0 0 10px rgba(99, 226, 183, 0.5);
}
.roi-value.warning {
color: #f0a020;
text-shadow: 0 0 10px rgba(240, 160, 32, 0.5);
}
.roi-value.success {
color: #18a058;
text-shadow: 0 0 10px rgba(24, 160, 88, 0.5);
}
.roi-sub {
font-size: 10px;
color: #64748b;
}
.roi-divider {
width: 1px;
height: 40px;
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.1), transparent);
}
/* Command Gate */
.command-gate {
margin-top: 24px;
width: 100%;
}
.deploy-btn {
font-weight: bold;
letter-spacing: 1px;
height: 48px;
font-size: 16px;
transition: all 0.3s;
}
.deploy-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(245, 34, 45, 0.4);
}
.mission-content {
transition: filter 0.3s ease;
}
.mission-content.blurred {
filter: blur(4px);
pointer-events: none;
opacity: 0.6;
}
.mission-container {
position: relative;
height: 100%;
}
.mission-locked-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
z-index: 10;
text-align: center;
}
.lock-icon {
font-size: 32px;
margin-bottom: 8px;
animation: pulse 2s infinite;
}
.lock-text {
color: #e2e8f0;
font-weight: bold;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
background: rgba(0,0,0,0.6);
padding: 8px 16px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1);
}
/* Dynamic Node 3 Pulse */
.pulsing-icon {
animation: pulse 1.5s infinite;
color: #63e2b7;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.1); opacity: 1; }
100% { transform: scale(1); opacity: 0.8; }
}
.task-list {
height: calc(100vh - 250px);
overflow-y: auto;
}
/* Feedback Section */
.feedback-section {
margin-top: 16px;
padding: 12px;
background: rgba(148, 163, 184, 0.05);
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.2);
}
.feedback-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.feedback-title {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
}
.feedback-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.feedback-actions {
display: flex;
gap: 8px;
}
.feedback-result {
display: flex;
align-items: center;
}
</style>