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:
@@ -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[] = [
|
||||
|
||||
70
frontend/src/components/TerminalDisplay.vue
Normal file
70
frontend/src/components/TerminalDisplay.vue
Normal 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>
|
||||
275
frontend/src/pages/chat/CodeCommander.vue
Normal file
275
frontend/src/pages/chat/CodeCommander.vue
Normal 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>
|
||||
@@ -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">新对话</button>
|
||||
<button class="jarvis-action-chip schedule" type="button" @click="selectConversation('schedule-mode')">日程模式</button>
|
||||
<button class="jarvis-action-chip code" type="button" @click="selectConversation('code-mode')">代码模式</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">今日计划情况</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>完成率</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">今日计划重点</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">暂无今日重点,等待日程中心返回数据。</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">本月计划复盘</div>
|
||||
<div class="jarvis-review-group">
|
||||
<div class="jarvis-review-subtitle">成果</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">反思</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 新闻</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>
|
||||
|
||||
79
frontend/src/services/terminalWs.ts
Normal file
79
frontend/src/services/terminalWs.ts
Normal 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()
|
||||
Reference in New Issue
Block a user