Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "pnpm --filter @novai/app dev",
"build": "pnpm --filter @novai/app build",
"test": "vitest run",
"typecheck": "tsc --build",
"commitlint": "commitlint --edit",
"prepare": "husky"
Expand All @@ -16,6 +17,7 @@
"commitlint-plugin-function-rules": "^4.3.2",
"husky": "^9.1.7",
"typescript": "^6.0.2",
"vite": "^6.2.0"
"vite": "^6.2.0",
"vitest": "^4.1.7"
}
}
3 changes: 3 additions & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@
},
"dependencies": {
"@novai/core": "workspace:*",
"markdown-it": "^14.2.0",
"pinia": "^3.0.4",
"vue": "^3.5.13",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^5.2.1",
"sass": "^1.98.0",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^6.2.0",
"vite-plugin-vue-devtools": "^8.1.2",
"vue-tsc": "^3.2.6"
}
}
16 changes: 15 additions & 1 deletion packages/app/src/app/router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router'

import HomeView from '../views/HomeView.vue'
import ProjectView from '../views/ProjectView.vue'
import SettingsView from '../views/SettingsView.vue'
import SessionTestView from '../views/SessionTestView.vue'
import TestLabView from '../views/TestLabView.vue'

Expand All @@ -8,7 +11,18 @@ export const router = createRouter({
routes: [
{
path: '/',
redirect: '/test',
name: 'home',
component: HomeView,
},
{
path: '/project/:id',
name: 'project',
component: ProjectView,
},
{
path: '/project/:id/settings',
name: 'settings',
component: SettingsView,
},
{
path: '/test',
Expand Down
123 changes: 123 additions & 0 deletions packages/app/src/components/chat/MessageItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<script setup lang="ts">
import type { ChatMessageView } from '@novai/core/services/types'
import MarkdownRenderer from '../ui/MarkdownRenderer.vue'

defineProps<{
message: ChatMessageView
}>()

function formatTime(dateStr: string) {
const date = new Date(dateStr)
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
</script>

<template>
<!-- 用户消息 -->
<div
v-if="message.role === 'user'"
class="flex justify-end gap-3"
>
<div class="flex max-w-[80%] flex-row-reverse items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-600 text-white">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div class="rounded-lg rounded-tr-none bg-blue-600 px-4 py-2.5">
<p class="whitespace-pre-wrap text-sm text-white">{{ message.text }}</p>
<p class="mt-1 text-right text-xs text-blue-200">{{ formatTime(message.createdAt) }}</p>
</div>
</div>
</div>

<!-- AI 消息 -->
<div
v-else-if="message.role === 'assistant'"
class="flex justify-start gap-3"
>
<div class="flex max-w-[80%] items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-900 text-white">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div class="rounded-lg rounded-tl-none bg-gray-100 px-4 py-2.5">
<MarkdownRenderer :content="message.text" />
<p class="mt-1 text-xs text-gray-500">{{ formatTime(message.createdAt) }}</p>
</div>
</div>
</div>

<!-- 系统消息(工具调用、工具结果、错误等) -->
<div
v-else-if="message.role === 'system'"
class="flex justify-center"
>
<div
:class="[
'max-w-[90%] rounded-lg px-4 py-2.5',
message.kind === 'error' ? 'bg-red-50 text-red-800' : 'bg-gray-50 text-gray-600',
]"
>
<!-- 工具调用 -->
<div v-if="message.kind === 'tool-call'" class="flex items-center gap-2">
<svg class="h-4 w-4 shrink-0 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="text-sm font-medium">调用工具:{{ message.toolName }}</span>
</div>

<!-- 工具结果 -->
<div v-else-if="message.kind === 'tool-result'" class="flex items-center gap-2">
<svg
:class="[
'h-4 w-4 shrink-0',
message.ok ? 'text-green-500' : 'text-red-500',
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
v-if="message.ok"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<path
v-else
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-sm">{{ message.text }}</span>
</div>

<!-- 上下文压缩摘要 -->
<div v-else-if="message.kind === 'context-summary'" class="flex items-center gap-2">
<svg class="h-4 w-4 shrink-0 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
<span class="text-sm text-gray-500">{{ message.text }}</span>
</div>

<!-- 错误消息 -->
<div v-else-if="message.kind === 'error'" class="flex items-center gap-2">
<svg class="h-4 w-4 shrink-0 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm">{{ message.text }}</span>
</div>

<!-- 其他系统消息 -->
<div v-else>
<p class="text-sm">{{ message.text }}</p>
</div>
</div>
</div>
</template>
132 changes: 132 additions & 0 deletions packages/app/src/components/file-tree/TreeNode.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ProjectFileNodeView } from '@novai/core/services/types'

const props = defineProps<{
node: ProjectFileNodeView
level: number
activeFilePath?: string
}>()

const emit = defineEmits<{
selectFile: [path: string]
}>()

const isExpanded = ref(props.level === 0)

function toggleExpand() {
if (props.node.kind === 'directory') {
isExpanded.value = !isExpanded.value
}
}

function handleClick() {
if (props.node.kind === 'file') {
emit('selectFile', props.node.path)
} else {
toggleExpand()
}
}

function getFileIcon(name: string) {
if (name.endsWith('.md')) return 'markdown'
if (name.endsWith('.json')) return 'json'
return 'file'
}
</script>

<template>
<div>
<button
:class="[
'flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors',
node.kind === 'file' && node.path === activeFilePath
? 'bg-white/15 text-white'
: 'text-gray-400 hover:bg-white/10 hover:text-gray-200',
]"
:style="{ paddingLeft: `${level * 12 + 8}px` }"
@click="handleClick"
>
<!-- 展开/折叠箭头(仅目录) -->
<svg
v-if="node.kind === 'directory'"
:class="[
'h-3.5 w-3.5 shrink-0 text-gray-500 transition-transform',
isExpanded ? 'rotate-90' : '',
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span v-else class="w-3.5" />

<!-- 图标 -->
<svg
v-if="node.kind === 'directory'"
class="h-4 w-4 shrink-0 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
v-if="isExpanded"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z"
/>
<path
v-else
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
<svg
v-else-if="getFileIcon(node.name) === 'markdown'"
class="h-4 w-4 shrink-0 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<svg
v-else-if="getFileIcon(node.name) === 'json'"
class="h-4 w-4 shrink-0 text-yellow-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<svg
v-else
class="h-4 w-4 shrink-0 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>

<!-- 文件名 -->
<span class="truncate">{{ node.name }}</span>
</button>

<!-- 子节点(递归渲染) -->
<div v-if="node.kind === 'directory' && isExpanded && node.children?.length">
<TreeNode
v-for="child in node.children"
:key="child.path"
:node="child"
:level="level + 1"
:active-file-path="activeFilePath"
@select-file="emit('selectFile', $event)"
/>
</div>
</div>
</template>
Loading
Loading