chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NInput, NButton, NSpace, NInputNumber, NCollapse, NCollapseItem } from 'naive-ui'
|
||||
import { getStoredUsername } from '@/api/client'
|
||||
import FolderPicker from '@/components/hermes/chat/FolderPicker.vue'
|
||||
|
||||
type InputLikeInstance = {
|
||||
focus: () => void
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
submit: [name: string, inviteCode: string, userName: string, description: string, compression: { triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number }, workspace: string]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const roomName = ref('')
|
||||
const inviteCode = ref('')
|
||||
const workspace = ref('')
|
||||
const userName = ref(localStorage.getItem('gc_user_name') || getStoredUsername() || '')
|
||||
const description = ref(localStorage.getItem('gc_user_description') || '')
|
||||
const roomInput = ref<InputLikeInstance | null>(null)
|
||||
|
||||
const compression = ref({
|
||||
triggerTokens: 100000,
|
||||
maxHistoryTokens: 32000,
|
||||
tailMessageCount: 10,
|
||||
})
|
||||
|
||||
function generateCode(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
let code = ''
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars[Math.floor(Math.random() * chars.length)]
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
const name = roomName.value.trim()
|
||||
const code = inviteCode.value.trim() || generateCode()
|
||||
const user = userName.value.trim()
|
||||
if (!name || !user) return
|
||||
emit('submit', name, code, user, description.value.trim(), { ...compression.value }, workspace.value || '')
|
||||
}
|
||||
|
||||
function focusRoomInput() {
|
||||
nextTick(() => roomInput.value?.focus())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="create-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('groupChat.yourName') }}</label>
|
||||
<NInput
|
||||
v-model:value="userName"
|
||||
:placeholder="t('groupChat.yourNamePlaceholder')"
|
||||
@keyup.enter="focusRoomInput"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('groupChat.yourDescription') }}</label>
|
||||
<NInput
|
||||
v-model:value="description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:placeholder="t('groupChat.yourDescriptionPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('groupChat.roomName') }}</label>
|
||||
<NInput
|
||||
ref="roomInput"
|
||||
v-model:value="roomName"
|
||||
:placeholder="t('groupChat.roomNamePlaceholder')"
|
||||
@keyup.enter="handleCreate"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('groupChat.inviteCode') }}</label>
|
||||
<div class="code-row">
|
||||
<NInput
|
||||
v-model:value="inviteCode"
|
||||
:placeholder="t('groupChat.autoGenerate')"
|
||||
/>
|
||||
<NButton size="small" @click="inviteCode = generateCode()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||
</svg>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('chat.workspace') }}</label>
|
||||
<FolderPicker v-model="workspace" />
|
||||
</div>
|
||||
|
||||
<NCollapse class="compression-collapse">
|
||||
<NCollapseItem :title="t('groupChat.compressionSettings')" name="compression">
|
||||
<div class="compression-fields">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('groupChat.triggerTokens') }}</label>
|
||||
<NInputNumber v-model:value="compression.triggerTokens" :min="1000" :step="1000" style="width: 100%" />
|
||||
<p class="form-hint">{{ t('groupChat.triggerTokensDesc') }}</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('groupChat.maxHistoryTokens') }}</label>
|
||||
<NInputNumber v-model:value="compression.maxHistoryTokens" :min="1000" :step="1000" style="width: 100%" />
|
||||
<p class="form-hint">{{ t('groupChat.maxHistoryTokensDesc') }}</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('groupChat.tailMessageCount') }}</label>
|
||||
<NInputNumber v-model:value="compression.tailMessageCount" :min="1" :step="5" style="width: 100%" />
|
||||
<p class="form-hint">{{ t('groupChat.tailMessageCountDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
|
||||
<div class="modal-actions">
|
||||
<NSpace justify="end">
|
||||
<NButton @click="emit('cancel')">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :disabled="!roomName.trim() || !userName.trim()" @click="handleCreate">{{ t('common.create') }}</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.create-form {
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-secondary;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.code-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.compression-collapse {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.compression-fields {
|
||||
padding-top: 8px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
||||
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||
import GroupMessageItem from './GroupMessageItem.vue'
|
||||
import VirtualMessageList from '../chat/VirtualMessageList.vue'
|
||||
|
||||
const store = useGroupChatStore()
|
||||
const { t } = useI18n()
|
||||
const { toolTraceVisible } = useToolTraceVisibility()
|
||||
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null)
|
||||
const showScrollBottomButton = ref(false)
|
||||
const displayMessages = computed(() => store.sortedMessages.filter(msg =>
|
||||
msg.role !== 'tool' ||
|
||||
toolTraceVisible.value ||
|
||||
msg.toolStatus === 'running' ||
|
||||
msg.toolName === 'workspace_diff' ||
|
||||
msg.tool_name === 'workspace_diff',
|
||||
))
|
||||
const listPadding = computed(() => store.activePendingApproval ? '16px 20px 260px' : '16px 20px')
|
||||
let pendingInitialBottomRoomId: string | null = store.currentRoomId
|
||||
|
||||
type BottomScrollOptions = number | {
|
||||
frames?: number
|
||||
keepAliveMs?: number
|
||||
}
|
||||
|
||||
function scrollToBottom(options?: BottomScrollOptions): void {
|
||||
const list = listRef.value as (InstanceType<typeof VirtualMessageList> & {
|
||||
scrollToBottom: (options?: BottomScrollOptions) => void
|
||||
}) | null
|
||||
list?.scrollToBottom(options)
|
||||
showScrollBottomButton.value = false
|
||||
}
|
||||
|
||||
function updateScrollBottomButton(): void {
|
||||
showScrollBottomButton.value = displayMessages.value.length > 0 && !(listRef.value?.isNearBottom(1000) ?? true)
|
||||
}
|
||||
|
||||
function handleListScroll(): void {
|
||||
updateScrollBottomButton()
|
||||
}
|
||||
|
||||
function handleScrollBottomClick(): void {
|
||||
scrollToBottom({ frames: 4, keepAliveMs: 600 })
|
||||
}
|
||||
|
||||
async function handleTopReach(): Promise<void> {
|
||||
if (!store.hasMoreBefore || store.isLoadingOlderMessages || store.hasReachedMessageDisplayLimit) return
|
||||
const snapshot = listRef.value?.captureScrollPosition() ?? null
|
||||
const loaded = await store.loadOlderMessages()
|
||||
if (!loaded) return
|
||||
await nextTick()
|
||||
listRef.value?.restoreScrollPosition(snapshot)
|
||||
updateScrollBottomButton()
|
||||
}
|
||||
|
||||
watch(() => store.currentRoomId, (roomId) => {
|
||||
pendingInitialBottomRoomId = roomId
|
||||
})
|
||||
|
||||
watch(() => displayMessages.value.map(msg => [
|
||||
msg.id,
|
||||
msg.content?.length ?? 0,
|
||||
msg.reasoning?.length ?? 0,
|
||||
msg.reasoning_content?.length ?? 0,
|
||||
msg.toolStatus ?? '',
|
||||
].join(':')).join('|'), async () => {
|
||||
const shouldForceInitialBottom = !!store.currentRoomId &&
|
||||
pendingInitialBottomRoomId === store.currentRoomId &&
|
||||
displayMessages.value.length > 0
|
||||
const shouldScroll = shouldForceInitialBottom || (listRef.value?.isNearBottom(200) ?? true)
|
||||
await nextTick()
|
||||
if (shouldScroll) {
|
||||
scrollToBottom(shouldForceInitialBottom ? { frames: 5, keepAliveMs: 700 } : { frames: 1, keepAliveMs: 120 })
|
||||
if (shouldForceInitialBottom) pendingInitialBottomRoomId = null
|
||||
}
|
||||
updateScrollBottomButton()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.currentRoomId || displayMessages.value.length === 0) return
|
||||
pendingInitialBottomRoomId = null
|
||||
await nextTick()
|
||||
scrollToBottom({ frames: 5, keepAliveMs: 700 })
|
||||
updateScrollBottomButton()
|
||||
})
|
||||
|
||||
defineExpose({ scrollToBottom })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group-message-list-shell">
|
||||
<VirtualMessageList
|
||||
class="group-message-list"
|
||||
ref="listRef"
|
||||
:messages="displayMessages"
|
||||
:virtualized="false"
|
||||
:estimated-item-height="170"
|
||||
:row-gap="12"
|
||||
:padding="listPadding"
|
||||
@scroll="handleListScroll"
|
||||
@top-reach="handleTopReach"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="empty-state">
|
||||
<img :src="'/coding-agents/hermes.png'" alt="Hermes" class="empty-logo" />
|
||||
<p>{{ t("chat.emptyState") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #before>
|
||||
<div
|
||||
v-if="store.hasReachedMessageDisplayLimit"
|
||||
class="history-limit-notice"
|
||||
>
|
||||
{{ t('groupChat.messageDisplayLimit') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="store.hasMoreBefore || store.isLoadingOlderMessages"
|
||||
class="history-loader"
|
||||
>
|
||||
<span v-if="store.isLoadingOlderMessages" class="history-loader-spinner"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template #item="{ message: msg }">
|
||||
<GroupMessageItem
|
||||
:message="msg"
|
||||
:agents="store.agents"
|
||||
:members="store.members"
|
||||
:current-user-id="store.userId"
|
||||
/>
|
||||
</template>
|
||||
</VirtualMessageList>
|
||||
<button
|
||||
v-if="showScrollBottomButton"
|
||||
type="button"
|
||||
class="scroll-bottom-button"
|
||||
:aria-label="t('chat.scrollToBottom')"
|
||||
:title="t('chat.scrollToBottom')"
|
||||
@click="handleScrollBottomClick"
|
||||
>
|
||||
<svg
|
||||
class="scroll-bottom-icon"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
<path d="M6 19h12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.group-message-list-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.group-message-list {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.scroll-bottom-button {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 7;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(var(--accent-primary-rgb), 0.24);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
color: var(--accent-primary);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
|
||||
.dark & {
|
||||
background: rgba(38, 38, 38, 0.94);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-bottom-button:hover {
|
||||
background: rgba(var(--accent-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.scroll-bottom-icon {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: $text-muted;
|
||||
|
||||
.empty-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.history-loader {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.history-loader-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.16);
|
||||
border-top-color: $accent-primary;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
|
||||
.dark & {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
border-top-color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.history-limit-notice {
|
||||
width: fit-content;
|
||||
max-width: min(100%, 420px);
|
||||
margin: 0 auto 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(var(--accent-info-rgb), 0.22);
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--accent-info-rgb), 0.08);
|
||||
color: $text-secondary;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
export type MentionOption = {
|
||||
key: string
|
||||
type: 'all' | 'agent'
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
type MentionAgent = {
|
||||
name: string
|
||||
profile?: string
|
||||
}
|
||||
|
||||
function isReservedMentionName(name: string): boolean {
|
||||
return name.trim().toLowerCase() === 'all'
|
||||
}
|
||||
|
||||
export function buildMentionOptions(agents: MentionAgent[], query: string): MentionOption[] {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
const options: MentionOption[] = []
|
||||
|
||||
if (!normalizedQuery || 'all'.includes(normalizedQuery)) {
|
||||
options.push({
|
||||
key: 'special:all',
|
||||
type: 'all',
|
||||
name: 'all',
|
||||
label: '@all',
|
||||
description: 'All agents',
|
||||
})
|
||||
}
|
||||
|
||||
for (const agent of agents) {
|
||||
const agentName = agent.name || ''
|
||||
if (isReservedMentionName(agentName)) continue
|
||||
if (!agentName.toLowerCase().includes(normalizedQuery)) continue
|
||||
options.push({
|
||||
key: `agent:${agentName}`,
|
||||
type: 'agent',
|
||||
name: agentName,
|
||||
label: `@${agentName}`,
|
||||
description: agent.profile || '',
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
Reference in New Issue
Block a user