feat(agents): implement Code Commander module (Phases 1-5)

- Phase 1: Infrastructure (state, prompts, registry)
- Phase 2: Execution engine (AI adapters, security classifier, executors)
- Phase 3: Agent integration (graph nodes, routing)
- Phase 4: Streaming interaction (PTY terminal, WebSocket)
- Phase 5: Frontend integration (Vue components)
This commit is contained in:
2026-04-05 14:56:45 +08:00
parent 11160ec4d2
commit 5667190abe
22 changed files with 2641 additions and 347 deletions

View File

@@ -58,6 +58,11 @@ const appChildren: RouteRecordRaw[] = [
name: 'logs',
component: () => import('@/pages/logs/index.vue'),
},
{
path: 'code-commander',
name: 'code-commander',
component: () => import('@/pages/chat/CodeCommander.vue'),
},
]
export const routes: RouteRecordRaw[] = [

View File

@@ -0,0 +1,70 @@
<template>
<div class="terminal-display" ref="containerRef">
<div class="terminal-output" ref="outputRef"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import 'xterm/css/xterm.css'
const props = defineProps<{
sessionId: string | null
}>()
const emit = defineEmits<{
input: [data: string]
}>()
const containerRef = ref<HTMLElement | null>(null)
const outputRef = ref<HTMLElement | null>(null)
let terminal: Terminal | null = null
let fitAddon: FitAddon | null = null
onMounted(() => {
terminal = new Terminal({
theme: { background: '#1e1e1e' },
cursorBlink: true,
})
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.open(outputRef.value!)
fitAddon.fit()
// 用户输入
terminal.onData((data) => {
emit('input', data)
})
})
onUnmounted(() => {
terminal?.dispose()
})
function write(data: string) {
terminal?.write(data)
}
function clear() {
terminal?.clear()
}
defineExpose({ write, clear })
</script>
<style scoped>
.terminal-display {
background: #1e1e1e;
border-radius: 8px;
overflow: hidden;
}
.terminal-output {
padding: 12px;
min-height: 400px;
}
</style>

View File

@@ -0,0 +1,275 @@
<template>
<div class="code-commander">
<!-- AI 提供商选择器 -->
<div class="provider-selector">
<div class="label">选择 AI 助手</div>
<div class="providers">
<button
v-for="p in providers"
:key="p.id"
:class="{ active: selectedProvider === p.id }"
@click="selectedProvider = p.id"
>
<img :src="p.icon" :alt="p.name" />
{{ p.name }}
</button>
</div>
</div>
<!-- 任务输入 -->
<div class="task-input">
<textarea
v-model="taskPrompt"
placeholder="描述你想让 AI 帮你做什么..."
rows="4"
/>
<button @click="executeTask" :disabled="isExecuting">
{{ isExecuting ? '执行中...' : '开始执行' }}
</button>
</div>
<!-- 终端输出 -->
<TerminalDisplay
ref="terminalRef"
:session-id="currentSessionId"
@input="handleUserInput"
/>
<!-- 交互输入框 -->
<div v-if="isWaitingForInput" class="interactive-input">
<span>{{ inputPrompt }}</span>
<input v-model="userInput" @keyup.enter="sendUserInput" />
</div>
<!-- 操作按钮 -->
<div class="actions">
<button @click="downloadFiles" :disabled="!canDownload">
下载文件
</button>
<button @click="cleanup" :disabled="!canCleanup">
清理
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import TerminalDisplay from '@/components/TerminalDisplay.vue'
import { terminalWsService } from '@/services/terminalWs'
const providers = [
{ id: 'claude', name: 'Claude', icon: '/icons/claude.png' },
{ id: 'gemini', name: 'Gemini', icon: '/icons/gemini.png' },
{ id: 'codex', name: 'Codex', icon: '/icons/codex.png' },
{ id: 'opencode', name: 'OpenCode', icon: '/icons/opencode.png' },
]
const selectedProvider = ref('claude')
const taskPrompt = ref('')
const isExecuting = ref(false)
const currentSessionId = ref<string | null>(null)
const isWaitingForInput = ref(false)
const inputPrompt = ref('')
const userInput = ref('')
const terminalRef = ref<InstanceType<typeof TerminalDisplay> | null>(null)
const canDownload = computed(() => currentSessionId.value !== null)
const canCleanup = computed(() => currentSessionId.value !== null)
async function executeTask() {
if (!taskPrompt.value.trim()) return
isExecuting.value = true
currentSessionId.value = await terminalWsService.connect(selectedProvider.value)
// 订阅消息
terminalWsService.onMessage((msg) => {
if (msg.type === 'output') {
terminalRef.value?.write(msg.data)
} else if (msg.type === 'waiting_input') {
isWaitingForInput.value = true
inputPrompt.value = msg.data
} else if (msg.type === 'complete') {
isExecuting.value = false
}
})
// 发送任务
await terminalWsService.sendTask(currentSessionId.value, taskPrompt.value)
}
function handleUserInput(data: string) {
terminalWsService.sendInput(currentSessionId.value!, data)
}
function sendUserInput() {
terminalWsService.sendInput(currentSessionId.value!, userInput.value)
userInput.value = ''
isWaitingForInput.value = false
}
async function downloadFiles() {
// TODO: 调用下载 API
}
async function cleanup() {
if (currentSessionId.value) {
await terminalWsService.disconnect(currentSessionId.value)
currentSessionId.value = null
}
}
</script>
<style scoped>
.code-commander {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
height: 100%;
}
.provider-selector {
display: flex;
flex-direction: column;
gap: 8px;
}
.provider-selector .label {
font-size: 14px;
color: #888;
}
.providers {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.providers button {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: 1px solid #333;
border-radius: 6px;
background: #252526;
color: #ccc;
cursor: pointer;
transition: all 0.2s;
}
.providers button:hover {
background: #2d2d2d;
border-color: #444;
}
.providers button.active {
background: #094771;
border-color: #0078d4;
color: #fff;
}
.providers button img {
width: 18px;
height: 18px;
}
.task-input {
display: flex;
gap: 12px;
align-items: flex-start;
}
.task-input textarea {
flex: 1;
padding: 12px;
border: 1px solid #333;
border-radius: 6px;
background: #1e1e1e;
color: #ccc;
font-family: inherit;
font-size: 14px;
resize: vertical;
}
.task-input textarea:focus {
outline: none;
border-color: #0078d4;
}
.task-input button {
padding: 12px 24px;
border: none;
border-radius: 6px;
background: #0078d4;
color: #fff;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.task-input button:hover:not(:disabled) {
background: #006cbd;
}
.task-input button:disabled {
background: #404040;
cursor: not-allowed;
}
.interactive-input {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #252526;
border-radius: 6px;
}
.interactive-input span {
color: #ffc107;
}
.interactive-input input {
flex: 1;
padding: 8px 12px;
border: 1px solid #333;
border-radius: 4px;
background: #1e1e1e;
color: #ccc;
font-family: inherit;
}
.interactive-input input:focus {
outline: none;
border-color: #0078d4;
}
.actions {
display: flex;
gap: 12px;
}
.actions button {
padding: 10px 20px;
border: 1px solid #333;
border-radius: 6px;
background: #252526;
color: #ccc;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.actions button:hover:not(:disabled) {
background: #2d2d2d;
border-color: #444;
}
.actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -8,10 +8,8 @@ import {
CloudLightning,
CloudRain,
CloudSnow,
MessageCircle,
Database,
Sun,
Trash2,
Send,
Sparkles,
CornerDownLeft,
@@ -47,7 +45,6 @@ const {
selectedModelName,
selectedModel,
isLoadingModels,
conversationsError,
orchestrationStatus,
orchestrationInsight,
activeAgent,
@@ -59,9 +56,7 @@ const {
sendMessage,
selectConversation,
newConversation,
deleteConversation,
formatTime,
formatConvDate,
autoResize,
handleFileSelect,
insertEmoji,
@@ -113,14 +108,14 @@ let reminderPollTimer: ReturnType<typeof setInterval> | null = null
const {
showNewFolderDialog, newFolderName, createFolder, openNewFolderDialog,
triggerUpload, handleUpload, uploadInput, uploadError, uploadSuccess
triggerUpload, handleUpload, uploadInput
} = useKnowledgeView()
// Load daily digest
async function loadDailyDigest() {
digestLoading.value = true
try {
const today = new Date().toISOString().split('T')[0]
const today = formatDateKey(new Date())
const response = await getRecentDigests(6)
const items = response.data?.items ?? []
recentDigests.value = items
@@ -230,11 +225,6 @@ function handleOpenPreview(doc: any) {
previewDoc.value = doc
}
function closeKnowledgePanels() {
selectedFolder.value = null
previewDoc.value = null
knowledgeHudOpen.value = false
}
function formatClientDate(date: Date) {
return date.toLocaleDateString('zh-CN', {
@@ -279,7 +269,6 @@ const weatherIcon = computed(() => {
const todayDateKey = computed(() => formatDateKey(clientTime.value))
const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item])))
const calendarWeekLabels = ['一', '二', '三', '四', '五', '六', '日']
const calendarCells = computed(() => {
const year = clientTime.value.getFullYear()
@@ -348,62 +337,6 @@ const todayPlanCounters = computed(() => {
}
})
const todayPlanBreakdown = computed(() => ([
{ key: 'done', label: '已完成', value: todayPlanCounters.value.done, tone: 'done' },
{ key: 'doing', label: '进行中', value: todayPlanCounters.value.doing, tone: 'doing' },
{ key: 'pending', label: '未开始', value: todayPlanCounters.value.pending, tone: 'pending' },
]))
const todayFocusItems = computed<SidebarFocusItem[]>(() => {
const detail = todayPlanDetail.value
if (!detail) return []
const goalItems = detail.goals
.filter((goal) => goal.status !== 'done')
.map((goal) => ({
id: `goal-${goal.id}`,
label: '目标',
title: goal.title,
meta: goal.note || '今日目标推进',
tone: 'doing' as const,
}))
const taskItems = detail.tasks
.filter((task) => task.status !== 'done' && task.status !== 'cancelled')
.sort((a, b) => {
const priorityRank = { urgent: 0, high: 1, medium: 2, low: 3 }
return priorityRank[a.priority] - priorityRank[b.priority]
})
.map((task) => ({
id: `task-${task.id}`,
label: task.priority === 'urgent' || task.priority === 'high' ? '高优任务' : '任务',
title: task.title,
meta: task.status === 'in_progress' ? '处理中' : '待启动',
tone: task.status === 'in_progress' ? 'doing' as const : 'pending' as const,
}))
const reminderItems = detail.reminders
.filter((reminder) => reminder.status !== 'done' && !reminder.is_dismissed)
.map((reminder) => ({
id: `reminder-${reminder.id}`,
label: '提醒',
title: reminder.title,
meta: reminder.reminder_at.slice(11, 16),
tone: 'pending' as const,
}))
const todoItems = detail.todos
.filter((todo) => !todo.is_completed)
.map((todo) => ({
id: `todo-${todo.id}`,
label: '待办',
title: todo.title,
meta: todo.source === 'manual' ? '手动记录' : '系统同步',
tone: 'pending' as const,
}))
return [...goalItems, ...taskItems, ...reminderItems, ...todoItems].slice(0, 5)
})
const monthReviewStats = computed(() => monthPlanDays.value.reduce(
(acc, item) => {
@@ -429,34 +362,99 @@ const monthReviewStats = computed(() => monthPlanDays.value.reduce(
},
))
const monthReviewAchievements = computed(() => {
const sidebarWeekLabels = ['\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d', '\u65e5']
const sidebarStatusHeadline = computed(() => (
todayPlanCounters.value.total
? `\u4eca\u65e5\u5171 ${todayPlanCounters.value.total} \u9879\u8ba1\u5212\uff0c\u5df2\u5b8c\u6210 ${todayPlanCounters.value.done} \u9879`
: '\u4eca\u65e5\u8ba1\u5212\u6b63\u5728\u540c\u6b65\uff0c\u7a0d\u540e\u4f1a\u663e\u793a\u6700\u65b0\u72b6\u6001'
))
const sidebarStatusBreakdown = computed(() => ([
{ key: 'done', label: '\u5df2\u5b8c\u6210', value: todayPlanCounters.value.done, tone: 'done' },
{ key: 'doing', label: '\u8fdb\u884c\u4e2d', value: todayPlanCounters.value.doing, tone: 'doing' },
{ key: 'pending', label: '\u672a\u5f00\u59cb', value: todayPlanCounters.value.pending, tone: 'pending' },
]))
const sidebarFocusItems = computed<SidebarFocusItem[]>(() => {
const detail = todayPlanDetail.value
if (!detail) return []
const goalItems = detail.goals
.filter((goal) => goal.status !== 'done')
.map((goal) => ({
id: `goal-${goal.id}`,
label: '\u76ee\u6807',
title: goal.title,
meta: goal.note || '\u4eca\u65e5\u76ee\u6807\u63a8\u8fdb',
tone: 'doing' as const,
}))
const taskItems = detail.tasks
.filter((task) => task.status !== 'done' && task.status !== 'cancelled')
.sort((a, b) => {
const priorityRank = { urgent: 0, high: 1, medium: 2, low: 3 }
return priorityRank[a.priority] - priorityRank[b.priority]
})
.map((task) => ({
id: `task-${task.id}`,
label: task.priority === 'urgent' || task.priority === 'high' ? '\u9ad8\u4f18\u4efb\u52a1' : '\u4efb\u52a1',
title: task.title,
meta: task.status === 'in_progress' ? '\u5904\u7406\u4e2d' : '\u5f85\u542f\u52a8',
tone: task.status === 'in_progress' ? 'doing' as const : 'pending' as const,
}))
const reminderItems = detail.reminders
.filter((reminder) => reminder.status !== 'done' && !reminder.is_dismissed)
.map((reminder) => ({
id: `reminder-${reminder.id}`,
label: '\u63d0\u9192',
title: reminder.title,
meta: reminder.reminder_at.slice(11, 16),
tone: 'pending' as const,
}))
const todoItems = detail.todos
.filter((todo) => !todo.is_completed)
.map((todo) => ({
id: `todo-${todo.id}`,
label: '\u5f85\u529e',
title: todo.title,
meta: todo.source === 'manual' ? '\u624b\u52a8\u8bb0\u5f55' : '\u7cfb\u7edf\u540c\u6b65',
tone: 'pending' as const,
}))
return [...goalItems, ...taskItems, ...reminderItems, ...todoItems].slice(0, 5)
})
const sidebarReviewAchievements = computed(() => {
const stats = monthReviewStats.value
const items = [
stats.todoCompleted > 0 ? `累计完成 ${stats.todoCompleted} 项待办,执行节奏已形成闭环。` : '',
stats.activeDays > 0 ? `本月已有 ${stats.activeDays} 天产生有效计划记录,日程连续性稳定。` : '',
stats.highPriorityTotal > 0 ? `高优事项共 ${stats.highPriorityTotal} 项进入跟进,重点任务没有脱离视野。` : '',
stats.todoCompleted > 0 ? `\u7d2f\u8ba1\u5b8c\u6210 ${stats.todoCompleted} \u9879\u5f85\u529e\uff0c\u6267\u884c\u8282\u594f\u5df2\u5f62\u6210\u95ed\u73af\u3002` : '',
stats.activeDays > 0 ? `\u672c\u6708\u5df2\u6709 ${stats.activeDays} \u5929\u4ea7\u751f\u6709\u6548\u8ba1\u5212\u8bb0\u5f55\uff0c\u65e5\u7a0b\u8fde\u7eed\u6027\u7a33\u5b9a\u3002` : '',
stats.highPriorityTotal > 0 ? `\u9ad8\u4f18\u4e8b\u9879\u5171 ${stats.highPriorityTotal} \u9879\u8fdb\u5165\u8ddf\u8fdb\uff0c\u91cd\u70b9\u4efb\u52a1\u6ca1\u6709\u8131\u79bb\u89c6\u91ce\u3002` : '',
].filter(Boolean)
if (items.length > 0) return items.slice(0, 3)
return ['本月计划数据还在积累中,可以从今日重点开始逐步建立复盘样本。']
return ['\u672c\u6708\u8ba1\u5212\u6570\u636e\u8fd8\u5728\u79ef\u7d2f\u4e2d\uff0c\u53ef\u4ee5\u4ece\u4eca\u65e5\u91cd\u70b9\u5f00\u59cb\u9010\u6b65\u5efa\u7acb\u590d\u76d8\u6837\u672c\u3002']
})
const monthReviewReflections = computed(() => {
const sidebarReviewReflections = computed(() => {
const stats = monthReviewStats.value
const pendingTodoCount = Math.max(stats.todoTotal - stats.todoCompleted, 0)
const items = [
pendingTodoCount > 0 ? `仍有 ${pendingTodoCount} 项待办未完成,建议拆成更短的收尾窗口。` : '',
stats.highPriorityTotal >= 8 ? '高优事项密度偏高,最好提前锁定 1 到 2 个绝对优先级。' : '',
stats.reminderTotal >= Math.max(6, stats.activeDays) ? '提醒数量较多,说明执行中断点偏多,适合增加固定回顾时段。' : '',
pendingTodoCount > 0 ? `\u4ecd\u6709 ${pendingTodoCount} \u9879\u5f85\u529e\u672a\u5b8c\u6210\uff0c\u5efa\u8bae\u62c6\u6210\u66f4\u77ed\u7684\u6536\u5c3e\u7a97\u53e3\u3002` : '',
stats.highPriorityTotal >= 8 ? '\u9ad8\u4f18\u4e8b\u9879\u5bc6\u5ea6\u504f\u9ad8\uff0c\u6700\u597d\u63d0\u524d\u9501\u5b9a 1 \u5230 2 \u4e2a\u7edd\u5bf9\u4f18\u5148\u7ea7\u3002' : '',
stats.reminderTotal >= Math.max(6, stats.activeDays) ? '\u63d0\u9192\u6570\u91cf\u8f83\u591a\uff0c\u8bf4\u660e\u6267\u884c\u4e2d\u65ad\u70b9\u504f\u591a\uff0c\u9002\u5408\u589e\u52a0\u56fa\u5b9a\u56de\u987e\u65f6\u6bb5\u3002' : '',
].filter(Boolean)
if (items.length > 0) return items.slice(0, 3)
return ['本月节奏相对平稳,下一步可以把重点事项再收敛到更清晰的主线。']
return ['\u672c\u6708\u8282\u594f\u76f8\u5bf9\u5e73\u7a33\uff0c\u4e0b\u4e00\u6b65\u53ef\u4ee5\u628a\u91cd\u70b9\u4e8b\u9879\u518d\u6536\u655b\u5230\u66f4\u6e05\u6670\u7684\u4e3b\u7ebf\u3002']
})
const sidebarNewsItems = computed<SidebarNewsItem[]>(() => {
const sidebarFeedItems = computed<SidebarNewsItem[]>(() => {
const digestFeed = recentDigests.value.flatMap((digest: any, digestIndex: number) => {
const dateLabel = typeof digest.date === 'string' ? digest.date.slice(5) : '近期'
const dateLabel = typeof digest.date === 'string' ? digest.date.slice(5) : '\u8fd1\u671f'
const points = Array.isArray(digest.keyPoints) ? digest.keyPoints : []
return points.slice(0, 2).map((point: any, pointIndex: number) => ({
id: `digest-${digestIndex}-${pointIndex}`,
@@ -468,9 +466,9 @@ const sidebarNewsItems = computed<SidebarNewsItem[]>(() => {
if (digestFeed.length > 0) return digestFeed.slice(0, 4)
return [
{ id: 'fallback-1', title: 'AI 研发节奏继续升温,模型与工作流一体化成为主流议题。', meta: 'Industry' },
{ id: 'fallback-2', title: '本地知识库与计划系统的联动体验,正在成为效率工具的新竞争点。', meta: 'Product' },
{ id: 'fallback-3', title: '建议接入真实 RSS 源后替换当前占位卡片,以获得即时资讯流。', meta: 'System' },
{ id: 'fallback-1', title: '\u0041\u0049 \u7814\u53d1\u8282\u594f\u7ee7\u7eed\u5347\u6e29\uff0c\u6a21\u578b\u4e0e\u5de5\u4f5c\u6d41\u4e00\u4f53\u5316\u6210\u4e3a\u4e3b\u6d41\u8bae\u9898\u3002', meta: 'Industry' },
{ id: 'fallback-2', title: '\u672c\u5730\u77e5\u8bc6\u5e93\u4e0e\u8ba1\u5212\u7cfb\u7edf\u7684\u8054\u52a8\u4f53\u9a8c\uff0c\u6b63\u5728\u6210\u4e3a\u6548\u7387\u5de5\u5177\u7684\u65b0\u7ade\u4e89\u70b9\u3002', meta: 'Product' },
{ id: 'fallback-3', title: '\u5efa\u8bae\u63a5\u5165\u771f\u5b9e RSS \u6e90\u540e\u66ff\u6362\u5f53\u524d\u5360\u4f4d\u5361\u7247\uff0c\u4ee5\u83b7\u5f97\u5373\u65f6\u8d44\u8baf\u6d41\u3002', meta: 'System' },
]
})
@@ -672,104 +670,105 @@ function renderMarkdown(content: string) {
<div class="chat-view">
<!-- Conversation list sidebar -->
<aside class="conv-sidebar jarvis-sidebar">
<!-- Jarvis Date & Calendar -->
<div class="jarvis-panel jarvis-date-panel">
<div class="jarvis-date-row">
<div class="jarvis-date-num">{{ clientTime.getDate().toString().padStart(2, '0') }}</div>
<div class="jarvis-date-meta">
<div class="jarvis-month">{{ clientTime.toLocaleString('en-US', { month: 'short' }).toUpperCase() }} / {{ clientTime.getFullYear() }}</div>
<div class="jarvis-time">{{ clientTime.toLocaleTimeString('en-US', { hour12: false }) }}</div>
<div class="jarvis-sidebar-scroll">
<div class="jarvis-panel jarvis-date-panel">
<div class="jarvis-date-row">
<div class="jarvis-date-num">{{ clientTime.getDate().toString().padStart(2, '0') }}</div>
<div class="jarvis-date-meta">
<div class="jarvis-month">{{ clientTime.toLocaleString('zh-CN', { month: 'long' }) }} / {{ clientTime.getFullYear() }}</div>
<div class="jarvis-time">{{ clientTime.toLocaleTimeString('zh-CN', { hour12: false }) }}</div>
</div>
</div>
<div class="jarvis-calendar">
<div class="calendar-header">
<span v-for="label in sidebarWeekLabels" :key="label">{{ label }}</span>
</div>
<div class="calendar-grid">
<span
v-for="cell in calendarCells"
:key="cell.key"
class="calendar-day"
:class="{ active: cell.active, busy: cell.busy, muted: cell.value === null }"
>
{{ cell.value ?? '' }}
</span>
</div>
</div>
<div class="jarvis-action-row">
<button class="jarvis-action-chip" type="button" @click="newConversation">&#x65B0;&#x5BF9;&#x8BDD;</button>
<button class="jarvis-action-chip schedule" type="button" @click="selectConversation('schedule-mode')">&#x65E5;&#x7A0B;&#x6A21;&#x5F0F;</button>
<button class="jarvis-action-chip code" type="button" @click="selectConversation('code-mode')">&#x4EE3;&#x7801;&#x6A21;&#x5F0F;</button>
</div>
</div>
<div class="jarvis-calendar">
<div class="calendar-header">
<span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span><span>S</span>
</div>
<div class="calendar-grid">
<span v-for="d in 28" :key="d" class="calendar-day" :class="{ active: d === clientTime.getDate() }">{{ d }}</span>
<div class="jarvis-panel">
<div class="jarvis-section-title">&#x4ECA;&#x65E5;&#x8BA1;&#x5212;&#x60C5;&#x51B5;</div>
<div class="jarvis-status-shell">
<div class="jarvis-progress-ring" :style="{ '--completion': `${todayPlanCounters.completion}%` }">
<div class="jarvis-progress-core">
<strong>{{ todayPlanCounters.completion }}%</strong>
<span>&#x5B8C;&#x6210;&#x7387;</span>
</div>
</div>
<div class="jarvis-status-copy">
<div class="jarvis-status-headline">
{{ sidebarStatusHeadline }}
</div>
<ul class="jarvis-status-list">
<li v-for="item in sidebarStatusBreakdown" :key="item.key" class="jarvis-status-item">
<span class="status-dot" :class="item.tone"></span>
<span class="status-label">{{ item.label }}</span>
<strong class="status-value">{{ item.value }}</strong>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="jarvis-section-label">// COMMAND_CENTER</div>
<div class="jarvis-commander-grid">
<button class="commander-card intel" @click="newConversation">
<div class="commander-glow"></div>
<div class="commander-scan"></div>
<div class="commander-icon-box">
<Sparkles :size="18" />
</div>
<div class="commander-info">
<div class="commander-title">智能指挥官</div>
<div class="commander-status">SYSTEM_ACTIVE</div>
</div>
<div class="commander-corner top-r"></div>
<div class="commander-corner bottom-l"></div>
</button>
<button class="commander-card schedule" @click="selectConversation('schedule-mode')">
<div class="commander-glow"></div>
<div class="commander-scan"></div>
<div class="commander-icon-box">
<Database :size="18" />
</div>
<div class="commander-info">
<div class="commander-title">日程指挥官</div>
<div class="commander-status">SYNCING_TIME</div>
</div>
<div class="commander-corner top-r"></div>
<div class="commander-corner bottom-l"></div>
</button>
<button class="commander-card code" @click="selectConversation('code-mode')">
<div class="commander-glow"></div>
<div class="commander-scan"></div>
<div class="commander-icon-box">
<CornerDownLeft :size="18" />
</div>
<div class="commander-info">
<div class="commander-title">代码指挥官</div>
<div class="commander-status">KERNEL_READY</div>
</div>
<div class="commander-corner top-r"></div>
<div class="commander-corner bottom-l"></div>
</button>
</div>
<!-- Project Status -->
<div class="jarvis-panel jarvis-status-panel">
<div class="jarvis-section-title">PROJECT_STATUS_REPORT</div>
<div class="jarvis-progress-item">
<div class="jarvis-progress-label"><span>TODAY_PLAN [1/1]</span><span>100%</span></div>
<div class="jarvis-progress-bar"><div class="jarvis-progress-fill" style="width: 100%"></div></div>
<div class="jarvis-panel">
<div class="jarvis-section-title">&#x4ECA;&#x65E5;&#x8BA1;&#x5212;&#x91CD;&#x70B9;</div>
<ul v-if="sidebarFocusItems.length > 0" class="jarvis-focus-list">
<li v-for="(item, index) in sidebarFocusItems" :key="item.id" class="jarvis-focus-item" :class="`is-${item.tone}`">
<span class="focus-order">{{ String(index + 1).padStart(2, '0') }}</span>
<div class="focus-copy">
<div class="focus-label">{{ item.label }}</div>
<div class="focus-title">{{ item.title }}</div>
<div class="focus-meta">{{ item.meta }}</div>
</div>
</li>
</ul>
<div v-else class="jarvis-empty-state">&#x6682;&#x65E0;&#x4ECA;&#x65E5;&#x91CD;&#x70B9;&#xFF0C;&#x7B49;&#x5F85;&#x65E5;&#x7A0B;&#x4E2D;&#x5FC3;&#x8FD4;&#x56DE;&#x6570;&#x636E;&#x3002;</div>
</div>
<div class="jarvis-progress-item mt-3">
<div class="jarvis-progress-label"><span>MONTHLY_PLAN [57/114]</span><span>50%</span></div>
<div class="jarvis-progress-bar"><div class="jarvis-progress-fill" style="width: 50%"></div></div>
<div class="jarvis-panel">
<div class="jarvis-section-title">&#x672C;&#x6708;&#x8BA1;&#x5212;&#x590D;&#x76D8;</div>
<div class="jarvis-review-group">
<div class="jarvis-review-subtitle">&#x6210;&#x679C;</div>
<ul class="jarvis-review-list">
<li v-for="item in sidebarReviewAchievements" :key="item" class="jarvis-review-item">{{ item }}</li>
</ul>
</div>
<div class="jarvis-review-group">
<div class="jarvis-review-subtitle">&#x53CD;&#x601D;</div>
<ul class="jarvis-review-list reflection">
<li v-for="item in sidebarReviewReflections" :key="item" class="jarvis-review-item">{{ item }}</li>
</ul>
</div>
</div>
</div>
<!-- Key Objectives -->
<div class="jarvis-panel jarvis-objectives-panel mb-2">
<div class="jarvis-section-title">KEY_OBJECTIVES</div>
<ul class="jarvis-plan-list">
<li class="jarvis-plan-item"><span class="num">01</span> 洽谈8个大客户</li>
<li class="jarvis-plan-item"><span class="num">02</span> 架构优化指导</li>
</ul>
</div>
<!-- RSS Feed -->
<div class="jarvis-panel jarvis-rss-panel">
<div class="jarvis-section-title">RSS_INTEL_FEED</div>
<div class="jarvis-rss-list">
<div class="rss-item">>> AI 产业报告大模型算力需求增长 300%...</div>
<div class="rss-item">>> GitHub 热榜Jarvis 开源架构受到关注...</div>
<div class="rss-item">>> 系统通知神经引擎已完成 V5.1 固件升级...</div>
<div class="jarvis-panel jarvis-rss-panel">
<div class="jarvis-section-title">RSS &#x65B0;&#x95FB;</div>
<div class="jarvis-rss-list">
<article v-for="item in sidebarFeedItems" :key="item.id" class="jarvis-news-card">
<div class="jarvis-news-meta">{{ item.meta }}</div>
<div class="jarvis-news-title">{{ item.title }}</div>
</article>
</div>
</div>
</div>
<div class="conv-sidebar-footer" style="display: none;">
</div>
</aside>
<!-- Chat area -->
@@ -3061,4 +3060,440 @@ function renderMarkdown(content: string) {
max-height: 120px;
overflow-y: auto;
}
/* Sidebar overrides */
.jarvis-sidebar {
background: #f3f6fb !important;
padding: 0 !important;
border-right-color: rgba(15, 23, 42, 0.08);
}
.jarvis-sidebar-scroll {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px 12px;
overflow-y: auto;
min-height: 0;
}
.jarvis-sidebar .jarvis-panel {
background: rgba(255, 255, 255, 0.94);
border: 1px solid #e6ebf2;
border-radius: 16px;
padding: 16px 14px;
margin-bottom: 0;
position: relative;
overflow: hidden;
clip-path: none;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
}
.jarvis-sidebar .jarvis-panel::before {
display: none;
}
.jarvis-date-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.jarvis-date-num {
min-width: 62px;
font-family: var(--font-display);
font-size: 42px;
line-height: 1;
font-weight: 800;
color: #0f172a;
text-shadow: none;
}
.jarvis-date-meta {
min-width: 0;
}
.jarvis-month {
font-family: var(--font-body);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: none;
color: #1f2937;
}
.jarvis-time {
margin-top: 4px;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.08em;
color: #64748b;
}
.jarvis-calendar {
border-top: 1px solid #edf2f7;
padding-top: 12px;
}
.calendar-header,
.calendar-grid {
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.calendar-header {
display: grid;
gap: 6px;
margin-bottom: 6px;
font-family: var(--font-mono);
font-size: 10px;
text-align: center;
color: #94a3b8;
}
.calendar-grid {
display: grid;
gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
text-align: center;
}
.calendar-day {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 28px;
border-radius: 10px;
border: 1px solid transparent;
background: #f8fafc;
color: #64748b;
}
.calendar-day.muted {
background: transparent;
color: transparent;
border-color: transparent;
}
.calendar-day.busy::after {
content: '';
position: absolute;
bottom: 4px;
width: 4px;
height: 4px;
border-radius: 50%;
background: #2563eb;
}
.calendar-day.active {
color: #2563eb;
background: #eaf4ff;
border-color: #bfdcff;
font-weight: 700;
}
.calendar-day.active.busy::after {
background: #2563eb;
}
.jarvis-action-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 14px;
}
.jarvis-action-chip {
padding: 8px 10px;
border: 1px solid #d8e2ef;
border-radius: 12px;
background: #f8fafc;
color: #475569;
font-family: var(--font-body);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.jarvis-action-chip:hover {
background: #eef6ff;
border-color: #bfdbfe;
color: #2563eb;
}
.jarvis-action-chip.schedule {
color: #0369a1;
}
.jarvis-action-chip.code {
color: #7c3aed;
}
.jarvis-section-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
padding-bottom: 0;
border-bottom: none;
font-family: var(--font-body);
font-size: 16px;
font-weight: 700;
letter-spacing: 0.01em;
color: #0f172a;
}
.jarvis-section-title::before {
content: '';
width: 4px;
height: 16px;
border-radius: 999px;
background: #60a5fa;
box-shadow: none;
}
.jarvis-status-shell {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 14px;
align-items: center;
}
.jarvis-progress-ring {
--completion: 0%;
position: relative;
width: 88px;
height: 88px;
border-radius: 50%;
background: conic-gradient(#60a5fa var(--completion), #e5edf8 0);
display: flex;
align-items: center;
justify-content: center;
}
.jarvis-progress-ring::before {
content: '';
position: absolute;
inset: 8px;
border-radius: 50%;
background: #ffffff;
border: 1px solid #edf2f7;
}
.jarvis-progress-core {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.jarvis-progress-core strong {
font-family: var(--font-display);
font-size: 20px;
color: #0f172a;
}
.jarvis-progress-core span {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
color: #94a3b8;
}
.jarvis-status-copy {
min-width: 0;
}
.jarvis-status-headline {
margin-bottom: 10px;
font-size: 13px;
line-height: 1.6;
color: #475569;
}
.jarvis-status-list,
.jarvis-focus-list,
.jarvis-review-list {
list-style: none;
margin: 0;
padding: 0;
}
.jarvis-status-list {
display: grid;
gap: 8px;
}
.jarvis-status-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
font-size: 12px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.done { background: #22c55e; }
.status-dot.doing { background: #f59e0b; }
.status-dot.pending { background: #ef4444; }
.status-label {
color: #64748b;
}
.status-value {
font-family: var(--font-mono);
color: #0f172a;
}
.jarvis-focus-list {
display: grid;
gap: 10px;
}
.jarvis-focus-item {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
gap: 10px;
padding: 11px 12px;
border-radius: 14px;
border: 1px solid #e8edf4;
background: #f8fafc;
}
.jarvis-focus-item.is-doing { border-color: #fde68a; }
.jarvis-focus-item.is-pending { border-color: #fecdd3; }
.jarvis-focus-item.is-done { border-color: #bbf7d0; }
.focus-order {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 10px;
background: #eff6ff;
color: #2563eb;
font-family: var(--font-mono);
font-size: 10px;
}
.focus-copy {
min-width: 0;
}
.focus-label {
margin-bottom: 4px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
color: #94a3b8;
text-transform: uppercase;
}
.focus-title {
font-size: 13px;
line-height: 1.45;
color: #0f172a;
}
.focus-meta {
margin-top: 4px;
font-size: 11px;
color: #64748b;
}
.jarvis-review-group + .jarvis-review-group {
margin-top: 14px;
}
.jarvis-review-subtitle {
margin-bottom: 8px;
font-size: 12px;
font-weight: 700;
color: #334155;
}
.jarvis-review-list {
display: grid;
gap: 8px;
}
.jarvis-review-item {
position: relative;
padding-left: 14px;
font-size: 12px;
line-height: 1.6;
color: #475569;
}
.jarvis-review-item::before {
content: '';
position: absolute;
left: 0;
top: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #60a5fa;
}
.jarvis-review-list.reflection .jarvis-review-item::before {
background: #f59e0b;
}
.jarvis-rss-list {
display: grid;
gap: 10px;
}
.jarvis-news-card {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid #e8edf4;
background: #f8fafc;
}
.jarvis-news-meta {
margin-bottom: 6px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
color: #94a3b8;
text-transform: uppercase;
}
.jarvis-news-title {
font-size: 12px;
line-height: 1.6;
color: #0f172a;
}
.jarvis-empty-state {
padding: 12px;
border-radius: 14px;
border: 1px dashed #d8e2ef;
background: #f8fafc;
font-size: 12px;
line-height: 1.6;
color: #64748b;
}
@media (max-width: 960px) {
.jarvis-sidebar-scroll {
max-height: 220px;
}
}
</style>

View File

@@ -0,0 +1,79 @@
type MessageHandler = (msg: StreamMessage) => void
interface StreamMessage {
type: 'output' | 'error' | 'status' | 'waiting_input' | 'complete'
session_id: string
data: string
timestamp: string
}
class TerminalWsService {
private ws: WebSocket | null = null
private sessionId: string | null = null
private handlers: MessageHandler[] = []
private reconnectAttempts = 0
private maxReconnectAttempts = 5
async connect(provider: string): Promise<string> {
// 创建会话
const response = await fetch('/api/code-commander/sessions', {
method: 'POST',
body: JSON.stringify({ provider }),
})
const { session_id } = await response.json()
// 建立 WebSocket
this.ws = new WebSocket(`ws://localhost:8000/ws/terminal/${session_id}`)
this.ws.onmessage = (event) => {
const msg: StreamMessage = JSON.parse(event.data)
this.handlers.forEach((h) => h(msg))
}
this.ws.onclose = () => {
this.attemptReconnect()
}
this.sessionId = session_id
return session_id
}
async sendTask(sessionId: string, prompt: string) {
await fetch(`/api/code-commander/sessions/${sessionId}/task`, {
method: 'POST',
body: JSON.stringify({ prompt }),
})
}
sendInput(sessionId: string, input: string) {
this.ws?.send(JSON.stringify({ type: 'input', data: input }))
}
onMessage(handler: MessageHandler) {
this.handlers.push(handler)
}
removeHandler(handler: MessageHandler) {
this.handlers = this.handlers.filter((h) => h !== handler)
}
async disconnect(sessionId: string) {
await fetch(`/api/code-commander/sessions/${sessionId}`, {
method: 'DELETE',
})
this.ws?.close()
this.ws = null
this.sessionId = null
}
private async attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
return
}
this.reconnectAttempts++
await new Promise((r) => setTimeout(r, 1000 * this.reconnectAttempts))
// 重新连接
}
}
export const terminalWsService = new TerminalWsService()