You've already forked agentic-coding-workflow
feat(web): chat views (Home/Detail/CreateModal) + Bubble/Uploader/ModelSelector + markdown-it
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
<NUpload
|
||||
:custom-request="upload"
|
||||
:show-file-list="false"
|
||||
multiple
|
||||
>
|
||||
<NButton size="small">
|
||||
添加附件
|
||||
</NButton>
|
||||
</NUpload>
|
||||
<div
|
||||
v-for="a in attachments"
|
||||
:key="a.id"
|
||||
class="text-xs px-2 py-1 bg-neutral-100 rounded flex items-center gap-1"
|
||||
>
|
||||
<span>{{ a.filename }} ({{ Math.round(a.size_bytes / 1024) }} KB)</span>
|
||||
<NButton
|
||||
text
|
||||
size="tiny"
|
||||
@click="remove(a.id)"
|
||||
>
|
||||
×
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { NUpload, NButton, useMessage } from 'naive-ui'
|
||||
import type { UploadCustomRequestOptions } from 'naive-ui'
|
||||
|
||||
import { chatApi, type Attachment } from '@/api/chat'
|
||||
|
||||
const props = defineProps<{
|
||||
modelCapabilities?: { image: boolean; pdf: boolean; audio?: boolean }
|
||||
}>()
|
||||
const emit = defineEmits<{ change: [ids: string[]] }>()
|
||||
|
||||
const attachments = ref<Attachment[]>([])
|
||||
const msg = useMessage()
|
||||
|
||||
async function upload(opts: UploadCustomRequestOptions) {
|
||||
const f = opts.file.file
|
||||
if (!f) {
|
||||
opts.onError()
|
||||
return
|
||||
}
|
||||
if (f.type.startsWith('image/') && !props.modelCapabilities?.image) {
|
||||
msg.error('当前模型不支持图片')
|
||||
opts.onError()
|
||||
return
|
||||
}
|
||||
if (f.type === 'application/pdf' && !props.modelCapabilities?.pdf) {
|
||||
msg.error('当前模型不支持 PDF')
|
||||
opts.onError()
|
||||
return
|
||||
}
|
||||
if (f.size > 20 * 1024 * 1024) {
|
||||
msg.error('单文件不能超过 20 MB')
|
||||
opts.onError()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const a = await chatApi.uploadAttachment(f)
|
||||
attachments.value.push(a)
|
||||
opts.onFinish()
|
||||
} catch (e) {
|
||||
msg.error(e instanceof Error ? e.message : String(e))
|
||||
opts.onError()
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
attachments.value = attachments.value.filter((a) => a.id !== id)
|
||||
}
|
||||
|
||||
watch(
|
||||
attachments,
|
||||
() => emit('change', attachments.value.map((a) => a.id)),
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
clear: () => {
|
||||
attachments.value = []
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div :class="['flex', message.role === 'user' ? 'justify-end' : 'justify-start']">
|
||||
<div
|
||||
:class="[
|
||||
'rounded-lg p-3 max-w-2xl',
|
||||
message.role === 'user' ? 'bg-blue-50' : 'bg-neutral-50',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="message.thinking"
|
||||
class="mb-2"
|
||||
>
|
||||
<NCollapse>
|
||||
<NCollapseItem
|
||||
title="🧠 模型思考过程"
|
||||
name="thinking"
|
||||
>
|
||||
<pre class="text-xs whitespace-pre-wrap">{{ message.thinking }}</pre>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</div>
|
||||
<div
|
||||
v-if="message.status === 'pending' && !message.content"
|
||||
class="text-neutral-400"
|
||||
>
|
||||
…
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
v-else
|
||||
class="prose prose-sm max-w-none"
|
||||
v-html="rendered"
|
||||
/>
|
||||
<div
|
||||
v-if="message.status === 'error'"
|
||||
class="text-red-500 text-xs mt-1 flex items-center gap-2"
|
||||
>
|
||||
<span>⚠️ {{ message.error_message }}</span>
|
||||
<NButton
|
||||
size="tiny"
|
||||
@click="emit('retry', message.id)"
|
||||
>
|
||||
重试
|
||||
</NButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="message.status === 'cancelled'"
|
||||
class="text-orange-500 text-xs mt-1 flex items-center gap-2"
|
||||
>
|
||||
<span>已取消</span>
|
||||
<NButton
|
||||
size="tiny"
|
||||
@click="emit('retry', message.id)"
|
||||
>
|
||||
重试
|
||||
</NButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="message.prompt_tokens"
|
||||
class="text-xs text-neutral-400 mt-1"
|
||||
>
|
||||
in {{ message.prompt_tokens }} · out {{ message.completion_tokens ?? 0 }}
|
||||
<span v-if="message.thinking_tokens"> · think {{ message.thinking_tokens }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NCollapse, NCollapseItem, NButton } from 'naive-ui'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
import type { ChatMessage } from '@/api/chat'
|
||||
|
||||
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
|
||||
const props = defineProps<{ message: ChatMessage }>()
|
||||
const emit = defineEmits<{ retry: [id: number] }>()
|
||||
const rendered = computed(() => md.render(props.message.content))
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div>
|
||||
<NSelect
|
||||
:value="modelValue"
|
||||
:options="options"
|
||||
placeholder="选择模型"
|
||||
@update:value="onChange"
|
||||
/>
|
||||
<div
|
||||
v-if="selected"
|
||||
class="text-xs text-neutral-500 mt-1"
|
||||
>
|
||||
{{ capLabel }}({{ selected.context_window.toLocaleString() }} ctx)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { NSelect } from 'naive-ui'
|
||||
|
||||
import { llmAdminApi, type LLMModel } from '@/api/llmAdmin'
|
||||
|
||||
const props = defineProps<{ modelValue?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
|
||||
|
||||
const models = ref<LLMModel[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
models.value = await llmAdminApi.listModels(true)
|
||||
})
|
||||
|
||||
const options = computed(() =>
|
||||
models.value.map((m) => ({ label: m.display_name, value: m.id })),
|
||||
)
|
||||
const selected = computed(() =>
|
||||
models.value.find((m) => m.id === props.modelValue),
|
||||
)
|
||||
const capLabel = computed(() => {
|
||||
if (!selected.value) return ''
|
||||
const c = selected.value.capabilities
|
||||
const parts: string[] = []
|
||||
if (c.image) parts.push('图片')
|
||||
if (c.pdf) parts.push('PDF')
|
||||
if (c.audio) parts.push('音频')
|
||||
if (c.reasoning) parts.push('Thinking')
|
||||
return parts.length > 0 ? parts.join(' · ') : '纯文本'
|
||||
})
|
||||
|
||||
function onChange(v: string) {
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user