Initial commit: AI chat assistant with workflow chat, workspace, and profile tabs

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-12 17:23:00 +08:00
commit 3fc394921e
34 changed files with 3449 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
unpackage/
.hbuilderx/
.DS_Store

70
App.uvue Normal file
View File

@@ -0,0 +1,70 @@
<script setup lang="uts">
// #ifdef APP-ANDROID || APP-HARMONY
let firstBackTime = 0
// #endif
onLaunch(() => {
console.log('App Launch - AI助手')
})
onAppShow(() => {
console.log('App Show')
})
onAppHide(() => {
console.log('App Hide')
})
// #ifdef APP-ANDROID || APP-HARMONY
onLastPageBackPress(() => {
console.log('App LastPageBackPress')
if (firstBackTime == 0) {
uni.showToast({
title: '再按一次退出应用',
position: 'bottom',
})
firstBackTime = Date.now()
setTimeout(() => {
firstBackTime = 0
}, 2000)
} else if (Date.now() - firstBackTime < 2000) {
firstBackTime = Date.now()
uni.exit()
}
})
onExit(() => {
console.log('App Exit')
})
// #endif
</script>
<style>
/* 全局暗色主题基础样式 */
page {
background-color: #0f0f1a;
color: rgba(255, 255, 255, 0.92);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* 每个页面公共css */
.uni-row {
flex-direction: row;
}
.uni-column {
flex-direction: column;
}
/* 安全区域适配 */
.status-bar-placeholder {
height: var(--status-bar-height);
}
/* 滚动条隐藏 */
::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
</style>

67
CLAUDE.md Normal file
View File

@@ -0,0 +1,67 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a **uni-app x** project — DCloud's cross-platform app framework that compiles to native Android, iOS, HarmonyOS, H5, and various mini-programs. The `assistant` directory is the project root.
- **Language**: UTS (uni-app TypeScript) — a TypeScript superset that compiles to platform-native code
- **UI Framework**: Vue 3 with Composition API (`<script setup>` pattern)
- **Current target**: Android (`platformConfig.json` targets `APP-ANDROID`)
- **Template files**: `.uvue` (uni-app Vue single-file components)
## Build & Development
This project is designed for **HBuilderX IDE** (DCloud's IDE). Development, building, and debugging are done through the IDE rather than CLI commands.
- Open the `assistant` directory in HBuilderX
- Use HBuilderX's **Run** menu to launch on a device/emulator (Android, iOS, H5, or mini-program)
- Build outputs go to the `unpackage/` directory (gitignored)
- HBuilderX handles the UTS → platform-native compilation pipeline
## Architecture
### Entry Flow
```
index.html → main.uts → App.uvue → pages/index/index.uvue
```
1. **`index.html`** — H5/web entry point; for native apps this is largely a bootstrap shell
2. **`main.uts`** — Creates the Vue 3 app instance via `createSSRApp(App)` and exports `createApp()`. This SSR pattern is standard for uni-app x — the framework calls `createApp()` to instantiate the app on each platform.
3. **`App.uvue`** — Root component. Contains app-level lifecycle hooks (`onLaunch`, `onAppShow`, `onAppHide`, `onLastPageBackPress`, `onExit`) and global CSS classes (`.uni-row`, `.uni-column`).
4. **`pages/index/index.uvue`** — The single page of this app; defined in `pages.json`.
### Key Configuration Files
- **`pages.json`** — Page routing (the `pages` array), global navigation bar style, and `uniIdRouter` (for uni-id authentication routing). The first entry in `pages` is the launch page.
- **`manifest.json`** — App identity (`appid`, `name`, `version`), cross-platform config (WeChat/Alipay/Baidu/Toutiao mini-program settings), and Vue version (`"vueVersion": "3"`). The `uni-app-x` key enables uni-app x mode.
- **`platformConfig.json`** — Declares which native platforms to build for (currently Android only).
- **`uni.scss`** — SCSS design tokens (colors, font sizes, spacing, border radius, opacity). These variables are automatically available in all `.uvue` components without importing. Use these variables to maintain visual consistency.
### Platform-Specific Code
Use preprocessor directives in `.uts`/`.uvue` files to gate platform-specific logic:
```uts
// #ifdef APP-ANDROID || APP-HARMONY
// Android/HarmonyOS-only code
// #endif
// #ifdef MP-WEIXIN
// WeChat mini-program-only code
// #endif
```
See `App.uvue` for an example — the back-press double-tap-to-exit logic is Android-only.
### Static Assets
Place static files in `static/`. Reference them in templates with `/static/...` paths (e.g., `/static/logo.png`).
## UTS Notes
- UTS is structurally like TypeScript but compiles differently per platform target
- `uni.*` APIs (e.g., `uni.showToast()`, `uni.exit()`) are the cross-platform API surface — see [uni-app x API docs](https://doc.dcloud.net.cn/uni-app-x/api/)
- `ref()` and other Vue 3 Composition API functions are available globally in `<script setup>` blocks

208
components/AppDrawer.uvue Normal file
View File

@@ -0,0 +1,208 @@
<template>
<!-- Overlay -->
<view
v-if="visible"
class="drawer-overlay"
:class="{ 'drawer-overlay--visible': visible }"
@tap="close"
@touchmove.stop.prevent="() => {}"
></view>
<!-- Drawer -->
<view
class="drawer"
:class="{ 'drawer--open': visible }"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<!-- Header -->
<DrawerHeader />
<!-- Scrollable content -->
<scroll-view class="drawer__content" scroll-y="true" show-scrollbar="false">
<!-- New conversation button -->
<view class="drawer__new-chat" @tap="onNewConversation">
<text class="drawer__new-chat-icon">+</text>
<text class="drawer__new-chat-text">新建对话</text>
</view>
<!-- History section -->
<HistoryList
:conversations="conversations"
@select="onSelectConversation"
@delete="onDeleteConversation"
/>
<!-- Divider -->
<view class="drawer__divider"></view>
<!-- Workspace section -->
<WorkspaceView
:items="workspaceItems"
@select="onSelectWorkspace"
/>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import type { Conversation, WorkspaceItem } from '@/types/index'
import DrawerHeader from './DrawerHeader.uvue'
import HistoryList from './HistoryList.uvue'
import WorkspaceView from './WorkspaceView.uvue'
const props = defineProps<{
visible: boolean
conversations: Conversation[]
workspaceItems: WorkspaceItem[]
}>()
const emit = defineEmits<{
close: []
'new-conversation': []
'select-conversation': [id: string]
'delete-conversation': [id: string]
'select-workspace': [id: string]
}>()
// Swipe-to-close tracking
const touchStartX = ref(0)
const touchStartY = ref(0)
const drawerTranslateX = ref(0)
const isDragging = ref(false)
function close(): void {
emit('close')
}
function onTouchStart(e: any): void {
const touch = e.touches[0]
touchStartX.value = touch.clientX
touchStartY.value = touch.clientY
isDragging.value = true
}
function onTouchMove(e: any): void {
if (!isDragging.value) return
const touch = e.touches[0]
const deltaX = touch.clientX - touchStartX.value
const deltaY = touch.clientY - touchStartY.value
// Only track horizontal swipes (left direction)
if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX < 0) {
drawerTranslateX.value = deltaX
}
}
function onTouchEnd(_e: any): void {
isDragging.value = false
// If swiped more than 80rpx left, close drawer
if (drawerTranslateX.value < -80) {
close()
}
drawerTranslateX.value = 0
}
function onNewConversation(): void {
emit('new-conversation')
close()
}
function onSelectConversation(id: string): void {
emit('select-conversation', id)
close()
}
function onDeleteConversation(id: string): void {
emit('delete-conversation', id)
}
function onSelectWorkspace(id: string): void {
emit('select-workspace', id)
close()
}
</script>
<style scoped>
/* Overlay */
.drawer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 999;
opacity: 0;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.drawer-overlay--visible {
opacity: 1;
}
/* Drawer */
.drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 600rpx;
background: #1a1a2e;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
box-shadow: 8rpx 0 40rpx rgba(0, 0, 0, 0.5);
}
.drawer--open {
transform: translateX(0);
}
/* Content area */
.drawer__content {
flex: 1;
overflow-y: auto;
}
/* New chat button */
.drawer__new-chat {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 24rpx;
margin: 8rpx 20rpx;
border-radius: 16rpx;
background: linear-gradient(135deg, rgba(108, 92, 231, 0.2), rgba(162, 155, 254, 0.1));
border: 1px solid rgba(108, 92, 231, 0.25);
transition: all 0.2s ease;
}
.drawer__new-chat:active {
background: linear-gradient(135deg, rgba(108, 92, 231, 0.35), rgba(162, 155, 254, 0.2));
border-color: rgba(108, 92, 231, 0.45);
}
.drawer__new-chat-icon {
font-size: 36rpx;
color: #a29bfe;
font-weight: 300;
margin-right: 12rpx;
}
.drawer__new-chat-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
/* Divider between history and workspace */
.drawer__divider {
height: 1px;
background: rgba(255, 255, 255, 0.06);
margin: 16rpx 24rpx;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<view class="tabbar">
<view
v-for="tab in tabs"
:key="tab.key"
class="tabbar__item"
@tap="onTap(tab.key)"
>
<text class="tabbar__icon">{{ tab.icon }}</text>
<text
class="tabbar__label"
:class="{ 'tabbar__label--active': activeKey === tab.key }"
>{{ tab.label }}</text>
<view v-if="activeKey === tab.key" class="tabbar__indicator"></view>
</view>
</view>
</template>
<script setup lang="uts">
import type { TabKey } from '@/types/index'
const props = defineProps<{
activeKey: TabKey
}>()
const emit = defineEmits<{
change: [key: TabKey]
}>()
const tabs = [
{ key: 'workspace' as TabKey, icon: '📂', label: '工作空间' },
{ key: 'chat' as TabKey, icon: '💬', label: '对话' },
{ key: 'profile' as TabKey, icon: '👤', label: '我的' },
]
function onTap(key: TabKey): void {
if (key !== props.activeKey) {
emit('change', key)
}
}
</script>
<style scoped>
.tabbar {
display: flex;
flex-direction: row;
align-items: center;
background: rgba(15, 15, 26, 0.92);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-bottom: env(safe-area-inset-bottom);
}
.tabbar__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 14rpx 0 10rpx;
position: relative;
transition: opacity 0.2s ease;
}
.tabbar__item:active {
opacity: 0.7;
}
.tabbar__icon {
font-size: 40rpx;
margin-bottom: 4rpx;
}
.tabbar__label {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.4);
transition: color 0.2s ease;
}
.tabbar__label--active {
color: #a29bfe;
font-weight: 600;
}
.tabbar__indicator {
position: absolute;
top: 0;
width: 40rpx;
height: 4rpx;
border-radius: 2rpx;
background: linear-gradient(90deg, #6c5ce7, #a29bfe);
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<view class="drawer-header">
<!-- Avatar -->
<view class="drawer-header__avatar">
<text class="drawer-header__avatar-emoji">🤖</text>
</view>
<!-- App info -->
<text class="drawer-header__name">{{ appName }}</text>
<text class="drawer-header__subtitle">{{ appSubtitle }}</text>
</view>
</template>
<script setup lang="uts">
import { APP_NAME, APP_SUBTITLE } from '@/constants/index'
const appName = APP_NAME
const appSubtitle = APP_SUBTITLE
</script>
<style scoped>
.drawer-header {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 32rpx 32rpx;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.drawer-header__avatar {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(108, 92, 231, 0.3);
}
.drawer-header__avatar-emoji {
font-size: 48rpx;
}
.drawer-header__name {
font-size: 36rpx;
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
margin-bottom: 6rpx;
}
.drawer-header__subtitle {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
}
</style>

149
components/HistoryItem.uvue Normal file
View File

@@ -0,0 +1,149 @@
<template>
<view class="history-item" @tap="onTap">
<!-- Workflow color indicator -->
<view class="history-item__dot" :style="{ background: workflowColor }"></view>
<!-- Content -->
<view class="history-item__content">
<text class="history-item__title">{{ conversation.title }}</text>
<text class="history-item__preview">{{ lastMessagePreview }}</text>
</view>
<!-- Right side: time + delete -->
<view class="history-item__right">
<text class="history-item__time">{{ relativeTime }}</text>
<view class="history-item__delete" @tap.stop="onDelete">
<text class="history-item__delete-icon">🗑</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import type { Conversation } from '@/types/index'
import { WORKFLOWS } from '@/constants/index'
const props = defineProps<{
conversation: Conversation
}>()
const emit = defineEmits<{
tap: []
delete: []
}>()
// Find the workflow color for the dot
const workflowColor = computed<string>(() => {
const wf = WORKFLOWS.find((w) => w.id === props.conversation.workflowId)
return wf ? wf.color : '#6c5ce7'
})
// Last message preview (first 30 chars of last message)
const lastMessagePreview = computed<string>(() => {
const msgs = props.conversation.messages
if (msgs.length === 0) return '暂无消息'
const last = msgs[msgs.length - 1]
let preview = last.content.replace(/\n/g, ' ').replace(/```/g, '')
if (preview.length > 30) {
preview = preview.substring(0, 30) + '...'
}
return preview
})
// Relative time display
const relativeTime = computed<string>(() => {
const now = Date.now()
const diff = now - props.conversation.updatedAt
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
const d = new Date(props.conversation.updatedAt)
return `${d.getMonth() + 1}/${d.getDate()}`
})
function onTap(): void {
emit('tap')
}
function onDelete(): void {
emit('delete')
}
</script>
<style scoped>
.history-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 24rpx;
border-radius: 16rpx;
margin-bottom: 8rpx;
background: rgba(255, 255, 255, 0.03);
transition: background 0.2s ease;
}
.history-item:active {
background: rgba(255, 255, 255, 0.06);
}
.history-item__dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
flex-shrink: 0;
margin-right: 16rpx;
}
.history-item__content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.history-item__title {
font-size: 28rpx;
font-weight: 500;
color: rgba(255, 255, 255, 0.88);
margin-bottom: 6rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item__preview {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item__right {
display: flex;
flex-direction: column;
align-items: flex-end;
margin-left: 16rpx;
flex-shrink: 0;
}
.history-item__time {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.3);
margin-bottom: 8rpx;
}
.history-item__delete {
padding: 4rpx;
}
.history-item__delete-icon {
font-size: 28rpx;
opacity: 0.6;
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<view class="history-list">
<!-- Section header -->
<view class="history-list__header">
<text class="history-list__header-icon">💬</text>
<text class="history-list__header-title">历史对话</text>
</view>
<!-- Empty state -->
<view v-if="conversations.length === 0" class="history-list__empty">
<text class="history-list__empty-text">暂无历史对话</text>
</view>
<!-- List -->
<view v-else class="history-list__items">
<HistoryItem
v-for="conv in conversations"
:key="conv.id"
:conversation="conv"
@tap="onSelect(conv.id)"
@delete="onDelete(conv.id)"
/>
</view>
</view>
</template>
<script setup lang="uts">
import type { Conversation } from '@/types/index'
import HistoryItem from './HistoryItem.uvue'
const props = defineProps<{
conversations: Conversation[]
}>()
const emit = defineEmits<{
select: [id: string]
delete: [id: string]
}>()
function onSelect(id: string): void {
emit('select', id)
}
function onDelete(id: string): void {
emit('delete', id)
}
</script>
<style scoped>
.history-list {
padding: 16rpx 0;
}
.history-list__header {
display: flex;
flex-direction: row;
align-items: center;
padding: 16rpx 24rpx 8rpx;
}
.history-list__header-icon {
font-size: 28rpx;
margin-right: 10rpx;
}
.history-list__header-title {
font-size: 28rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
}
.history-list__empty {
padding: 40rpx 24rpx;
text-align: center;
}
.history-list__empty-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.3);
}
.history-list__items {
padding: 8rpx 24rpx;
}
</style>

126
components/InputArea.uvue Normal file
View File

@@ -0,0 +1,126 @@
<template>
<view class="input-area">
<view class="input-area__container">
<!-- Text input -->
<input
class="input-area__field"
:class="{ 'input-area__field--has-text': hasText }"
type="text"
:value="inputText"
:placeholder="placeholder"
:disabled="disabled"
:maxlength="2000"
placeholder-style="color: rgba(255, 255, 255, 0.3);"
@input="onInput"
@confirm="onSend"
confirm-type="send"
/>
<!-- Send button -->
<view
class="input-area__send-btn"
:class="{ 'input-area__send-btn--active': hasText && !disabled }"
@tap="onSend"
>
<text class="input-area__send-icon">↑</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
const props = defineProps<{
disabled: boolean
placeholder: string
}>()
const emit = defineEmits<{
send: [text: string]
}>()
const inputText = ref('')
const hasText = computed<boolean>(() => {
return inputText.value.trim().length > 0
})
function onInput(e: any): void {
inputText.value = e.detail.value
}
function onSend(): void {
const text = inputText.value.trim()
if (text && !props.disabled) {
emit('send', text)
inputText.value = ''
}
}
</script>
<style scoped>
.input-area {
position: relative;
z-index: 100;
background: rgba(15, 15, 26, 0.9);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 16rpx 24rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
}
.input-area__container {
display: flex;
flex-direction: row;
align-items: center;
}
.input-area__field {
flex: 1;
height: 72rpx;
border-radius: 36rpx;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 0 24rpx;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
transition: border-color 0.25s ease;
}
.input-area__field--has-text {
border-color: rgba(108, 92, 231, 0.4);
}
.input-area__field:focus {
border-color: rgba(108, 92, 231, 0.6);
}
.input-area__send-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
margin-left: 16rpx;
flex-shrink: 0;
transition: all 0.25s ease;
}
.input-area__send-btn--active {
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
box-shadow: 0 4rpx 16rpx rgba(108, 92, 231, 0.35);
}
.input-area__send-icon {
font-size: 40rpx;
color: rgba(255, 255, 255, 0.5);
font-weight: 600;
line-height: 1;
}
.input-area__send-btn--active .input-area__send-icon {
color: #ffffff;
}
</style>

View File

@@ -0,0 +1,288 @@
<template>
<view
class="message-bubble"
:class="{
'message-bubble--user': message.role === 'user',
'message-bubble--ai': message.role === 'assistant',
'message-bubble--error': message.status === 'error',
}"
>
<!-- AI Avatar placeholder -->
<view v-if="message.role === 'assistant'" class="message-bubble__avatar">
<text class="message-bubble__avatar-text">AI</text>
</view>
<view class="message-bubble__content-wrapper">
<!-- Bubble -->
<view class="message-bubble__bubble">
<!-- Parsed content segments -->
<view v-for="(segment, idx) in parsedSegments" :key="idx">
<!-- Code block -->
<view v-if="segment.type === 'code'" class="message-bubble__code-block">
<text class="message-bubble__code-text">{{ segment.value }}</text>
</view>
<!-- Regular text (with inline bold handled) -->
<text v-else class="message-bubble__text">{{ segment.value }}</text>
</view>
</view>
<!-- Timestamp & Status -->
<view class="message-bubble__meta" :class="{ 'message-bubble__meta--user': message.role === 'user' }">
<text class="message-bubble__time">{{ formatTime(message.timestamp) }}</text>
<text v-if="message.role === 'user' && message.status === 'sending'" class="message-bubble__status">⏳</text>
<text v-if="message.role === 'user' && message.status === 'sent'" class="message-bubble__status">✓</text>
<text v-if="message.role === 'user' && message.status === 'error'" class="message-bubble__status message-bubble__status--error">⚠️</text>
</view>
<!-- Error retry -->
<view v-if="message.status === 'error'" class="message-bubble__retry" @tap="onRetry">
<text class="message-bubble__retry-text">发送失败,点击重试</text>
</view>
</view>
<!-- User Avatar placeholder -->
<view v-if="message.role === 'user'" class="message-bubble__avatar message-bubble__avatar--user">
<text class="message-bubble__avatar-text">我</text>
</view>
</view>
</template>
<script setup lang="uts">
import type { Message, ParsedSegment } from '@/types/index'
const props = defineProps<{
message: Message
}>()
const emit = defineEmits<{
retry: []
}>()
/**
* Parse message content into segments: text, code, bold.
* Handles ``` code fences and **bold** markers.
*/
const parsedSegments = computed<ParsedSegment[]>(() => {
return parseContent(props.message.content)
})
function parseContent(content: string): ParsedSegment[] {
const segments: ParsedSegment[] = []
let remaining = content
while (remaining.length > 0) {
const codeStart = remaining.indexOf('```')
if (codeStart === -1) {
// No more code blocks — add remaining as text
if (remaining.trim()) {
segments.push({ type: 'text', value: remaining.trim() })
}
break
}
// Text before code block
if (codeStart > 0) {
const beforeText = remaining.substring(0, codeStart).trim()
if (beforeText) {
segments.push({ type: 'text', value: beforeText })
}
}
// Find code block end
const afterFence = remaining.substring(codeStart + 3)
const codeEnd = afterFence.indexOf('```')
if (codeEnd === -1) {
// Unclosed code block — treat rest as text
segments.push({ type: 'text', value: remaining.substring(codeStart).trim() })
break
}
const codeContent = afterFence.substring(0, codeEnd).trim()
if (codeContent) {
segments.push({ type: 'code', value: codeContent })
}
remaining = afterFence.substring(codeEnd + 3)
}
// If no segments at all, return the whole content as text
if (segments.length === 0 && content.trim()) {
segments.push({ type: 'text', value: content.trim() })
}
return segments
}
/**
* Format timestamp to HH:mm.
*/
function formatTime(timestamp: number): string {
const d = new Date(timestamp)
const h = d.getHours().toString().padStart(2, '0')
const m = d.getMinutes().toString().padStart(2, '0')
return `${h}:${m}`
}
function onRetry(): void {
emit('retry')
}
</script>
<style scoped>
.message-bubble {
display: flex;
flex-direction: row;
align-items: flex-end;
margin-bottom: 20rpx;
padding: 0 24rpx;
animation: messageEnter 0.3s ease-out;
}
.message-bubble--user {
justify-content: flex-end;
}
.message-bubble--ai {
justify-content: flex-start;
}
/* Avatar */
.message-bubble__avatar {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 12rpx;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
}
.message-bubble__avatar--user {
margin-right: 0;
margin-left: 12rpx;
background: linear-gradient(135deg, #00b894, #55efc4);
}
.message-bubble__avatar-text {
font-size: 22rpx;
font-weight: 700;
color: #ffffff;
}
/* Content wrapper */
.message-bubble__content-wrapper {
display: flex;
flex-direction: column;
max-width: 80%;
}
.message-bubble--ai .message-bubble__content-wrapper {
max-width: 78%;
}
/* Bubble box */
.message-bubble__bubble {
padding: 20rpx 24rpx;
border-radius: 24rpx 24rpx 4rpx 24rpx;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.message-bubble--user .message-bubble__bubble {
border-radius: 24rpx 24rpx 24rpx 4rpx;
background: linear-gradient(135deg, #6c5ce7 0%, #5a4bd1 100%);
border: none;
}
.message-bubble--error .message-bubble__bubble {
border-color: rgba(255, 82, 82, 0.4);
}
/* Text */
.message-bubble__text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
line-height: 1.6;
word-break: break-word;
}
.message-bubble--ai .message-bubble__text {
color: rgba(255, 255, 255, 0.88);
}
.message-bubble--user .message-bubble__text {
color: #ffffff;
}
/* Code block */
.message-bubble__code-block {
background: rgba(0, 0, 0, 0.35);
border-radius: 12rpx;
padding: 16rpx 20rpx;
margin: 12rpx 0;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.message-bubble__code-text {
font-size: 24rpx;
font-family: 'Courier New', Courier, monospace;
color: #a29bfe;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
/* Meta (time + status) */
.message-bubble__meta {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 6rpx;
padding: 0 8rpx;
}
.message-bubble__meta--user {
justify-content: flex-end;
}
.message-bubble__time {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.3);
}
.message-bubble__status {
font-size: 18rpx;
margin-left: 8rpx;
color: rgba(255, 255, 255, 0.4);
}
.message-bubble__status--error {
color: #ff5252;
}
/* Retry */
.message-bubble__retry {
margin-top: 8rpx;
padding: 8rpx 16rpx;
align-self: flex-end;
}
.message-bubble__retry-text {
font-size: 22rpx;
color: #ff5252;
}
/* Enter animation */
@keyframes messageEnter {
from {
opacity: 0;
transform: translateY(16rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

175
components/MessageList.uvue Normal file
View File

@@ -0,0 +1,175 @@
<template>
<view class="message-list">
<!-- Empty state -->
<view v-if="messages.length === 0 && !isLoading" class="message-list__empty">
<text class="message-list__empty-icon">💡</text>
<text class="message-list__empty-title">{{ emptyTitle }}</text>
<text class="message-list__empty-subtitle">{{ emptySubtitle }}</text>
</view>
<!-- Messages -->
<scroll-view
v-if="messages.length > 0 || isLoading"
class="message-list__scroll"
ref="scrollViewRef"
scroll-y="true"
show-scrollbar="false"
scroll-with-animation="true"
:scroll-top="scrollToBottom"
>
<view class="message-list__inner">
<MessageBubble
v-for="msg in messages"
:key="msg.id"
:message="msg"
@retry="onRetry(msg.id)"
/>
<!-- Typing indicator -->
<TypingIndicator :visible="isLoading" />
</view>
<!-- Bottom spacer for comfortable scrolling -->
<view class="message-list__spacer"></view>
</scroll-view>
<!-- Scroll-to-bottom FAB -->
<view
v-if="showScrollFab"
class="message-list__fab"
@tap="scrollToLatest"
>
<text class="message-list__fab-icon">↓</text>
</view>
</view>
</template>
<script setup lang="uts">
import type { Message } from '@/types/index'
import MessageBubble from './MessageBubble.uvue'
import TypingIndicator from './TypingIndicator.uvue'
const props = defineProps<{
messages: Message[]
isLoading: boolean
emptyTitle: string
emptySubtitle: string
}>()
const emit = defineEmits<{
retry: [messageId: string]
}>()
// Auto-scroll: increment this to scroll to bottom
const scrollToBottom = ref(0)
const showScrollFab = ref(false)
// Watch for new messages to auto-scroll
watch(
() => props.messages.length,
() => {
// Trigger scroll to bottom on new message
nextTick(() => {
scrollToBottom.value = Date.now()
})
}
)
// Watch for isLoading to auto-scroll when typing starts
watch(
() => props.isLoading,
(loading: boolean) => {
if (loading) {
nextTick(() => {
scrollToBottom.value = Date.now()
})
}
}
)
function scrollToLatest(): void {
scrollToBottom.value = Date.now()
showScrollFab.value = false
}
function onRetry(messageId: string): void {
emit('retry', messageId)
}
</script>
<style scoped>
.message-list {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
/* Empty state */
.message-list__empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64rpx;
}
.message-list__empty-icon {
font-size: 80rpx;
margin-bottom: 24rpx;
opacity: 0.6;
}
.message-list__empty-title {
font-size: 34rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 12rpx;
}
.message-list__empty-subtitle {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.3);
text-align: center;
line-height: 1.5;
}
/* Scroll area */
.message-list__scroll {
flex: 1;
}
.message-list__inner {
padding-top: 16rpx;
padding-bottom: 8rpx;
}
.message-list__spacer {
height: 32rpx;
}
/* Scroll-to-bottom FAB */
.message-list__fab {
position: absolute;
bottom: 16rpx;
right: 24rpx;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba(108, 92, 231, 0.85);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.4);
z-index: 10;
transition: opacity 0.25s ease, transform 0.25s ease;
}
.message-list__fab-icon {
font-size: 36rpx;
color: #ffffff;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<!-- Overlay -->
<view
v-if="visible"
class="profile-overlay"
:class="{ 'profile-overlay--visible': visible }"
@tap="close"
@touchmove.stop.prevent="() => {}"
></view>
<!-- Panel (slides from right) -->
<view class="profile-panel" :class="{ 'profile-panel--open': visible }">
<!-- Header: Avatar + Name -->
<view class="profile-panel__header">
<view class="profile-panel__avatar">
<text class="profile-panel__avatar-emoji">🧑‍💻</text>
</view>
<text class="profile-panel__name">用户</text>
<text class="profile-panel__id">ID: AI助手用户</text>
</view>
<!-- Stats -->
<view class="profile-panel__stats">
<view class="profile-panel__stat">
<text class="profile-panel__stat-value">{{ conversationsCount }}</text>
<text class="profile-panel__stat-label">对话数</text>
</view>
<view class="profile-panel__stat-divider"></view>
<view class="profile-panel__stat">
<text class="profile-panel__stat-value">{{ workspaceCount }}</text>
<text class="profile-panel__stat-label">工作结果</text>
</view>
</view>
<!-- Menu items -->
<view class="profile-panel__menu">
<view
v-for="item in menuItems"
:key="item.key"
class="profile-panel__menu-item"
@tap="onMenuItem(item.key)"
>
<text class="profile-panel__menu-icon">{{ item.icon }}</text>
<text class="profile-panel__menu-label">{{ item.label }}</text>
<text class="profile-panel__menu-arrow"></text>
</view>
</view>
<!-- Version -->
<text class="profile-panel__version">AI助手 v1.0.0</text>
</view>
</template>
<script setup lang="uts">
const props = defineProps<{
visible: boolean
conversationsCount: number
workspaceCount: number
}>()
const emit = defineEmits<{
close: []
}>()
const menuItems = [
{ key: 'settings', icon: '⚙️', label: '设置' },
{ key: 'about', icon: '', label: '关于' },
{ key: 'clear', icon: '🗑', label: '清除数据' },
]
function close(): void {
emit('close')
}
function onMenuItem(key: string): void {
if (key === 'clear') {
uni.showModal({
title: '确认清除',
content: '将清除所有对话历史和工作空间数据,此操作不可恢复。',
success: (res: any) => {
if (res.confirm) {
uni.clearStorageSync()
uni.showToast({
title: '数据已清除',
icon: 'success',
})
close()
}
},
})
} else {
uni.showToast({
title: `功能开发中: ${key}`,
icon: 'none',
})
}
}
</script>
<style scoped>
/* Overlay */
.profile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 999;
opacity: 0;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.profile-overlay--visible {
opacity: 1;
}
/* Panel */
.profile-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 600rpx;
background: #1a1a2e;
z-index: 1000;
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
box-shadow: -8rpx 0 40rpx rgba(0, 0, 0, 0.5);
}
.profile-panel--open {
transform: translateX(0);
}
/* Header */
.profile-panel__header {
display: flex;
flex-direction: column;
align-items: center;
padding: 64rpx 32rpx 32rpx;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.profile-panel__avatar {
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(108, 92, 231, 0.3);
}
.profile-panel__avatar-emoji {
font-size: 56rpx;
}
.profile-panel__name {
font-size: 36rpx;
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
margin-bottom: 8rpx;
}
.profile-panel__id {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.35);
}
/* Stats */
.profile-panel__stats {
display: flex;
flex-direction: row;
align-items: center;
padding: 28rpx 32rpx;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.profile-panel__stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.profile-panel__stat-value {
font-size: 40rpx;
font-weight: 700;
color: #a29bfe;
margin-bottom: 4rpx;
}
.profile-panel__stat-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
}
.profile-panel__stat-divider {
width: 1px;
height: 48rpx;
background: rgba(255, 255, 255, 0.1);
}
/* Menu */
.profile-panel__menu {
padding: 16rpx 0;
flex: 1;
}
.profile-panel__menu-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 28rpx 32rpx;
transition: background 0.2s ease;
}
.profile-panel__menu-item:active {
background: rgba(255, 255, 255, 0.04);
}
.profile-panel__menu-icon {
font-size: 32rpx;
margin-right: 20rpx;
}
.profile-panel__menu-label {
flex: 1;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
}
.profile-panel__menu-arrow {
font-size: 36rpx;
color: rgba(255, 255, 255, 0.25);
font-weight: 300;
}
/* Version */
.profile-panel__version {
text-align: center;
padding: 24rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.2);
}
</style>

129
components/TopBar.uvue Normal file
View File

@@ -0,0 +1,129 @@
<template>
<view class="topbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="topbar__inner">
<!-- Menu button -->
<view class="topbar__btn" @tap="onMenuClick">
<text class="topbar__menu-icon">☰</text>
</view>
<!-- Title -->
<view class="topbar__title-area">
<text class="topbar__title">{{ title }}</text>
</view>
<!-- Profile button -->
<view class="topbar__profile-btn" @tap="onProfileClick">
<text class="topbar__profile-text">我的</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
const props = defineProps<{
title: string
}>()
const emit = defineEmits<{
'menu-click': []
'profile-click': []
}>()
// Try to get status bar height from system info
const statusBarHeight = ref(0)
// Get system info on mount
uni.getSystemInfo({
success: (res) => {
statusBarHeight.value = res.statusBarHeight ?? 24
},
fail: () => {
statusBarHeight.value = 24
},
})
function onMenuClick(): void {
emit('menu-click')
}
function onProfileClick(): void {
emit('profile-click')
}
</script>
<style scoped>
.topbar {
position: relative;
z-index: 100;
background: rgba(15, 15, 26, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.topbar__inner {
display: flex;
flex-direction: row;
align-items: center;
height: 88rpx;
padding: 0 16rpx;
}
.topbar__btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s ease;
}
.topbar__btn:active {
background: rgba(255, 255, 255, 0.08);
}
.topbar__menu-icon {
font-size: 38rpx;
color: rgba(255, 255, 255, 0.8);
font-weight: 300;
}
.topbar__profile-btn {
height: 60rpx;
padding: 0 20rpx;
border-radius: 30rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.06);
transition: background 0.2s ease;
}
.topbar__profile-btn:active {
background: rgba(255, 255, 255, 0.12);
}
.topbar__profile-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
}
.topbar__title-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.topbar__title {
font-size: 32rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400rpx;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<view v-if="visible" class="typing-indicator">
<view class="typing-indicator__bubble">
<view class="typing-indicator__dot" style="animation-delay: 0ms;"></view>
<view class="typing-indicator__dot" style="animation-delay: 150ms;"></view>
<view class="typing-indicator__dot" style="animation-delay: 300ms;"></view>
</view>
</view>
</template>
<script setup lang="uts">
defineProps<{
visible: boolean
}>()
</script>
<style scoped>
.typing-indicator {
display: flex;
align-items: flex-start;
padding: 8rpx 24rpx;
}
.typing-indicator__bubble {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 28rpx;
border-radius: 24rpx 24rpx 24rpx 4rpx;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
gap: 8rpx;
}
.typing-indicator__dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #6c5ce7;
animation: dotBounce 0.6s ease-in-out infinite;
}
@keyframes dotBounce {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-10rpx);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<view
class="workflow-chip"
:class="{ 'workflow-chip--active': active }"
:style="active ? { background: workflow.color, boxShadow: `0 4rpx 16rpx ${workflow.color}44` } : {}"
@tap="onTap"
>
<text class="workflow-chip__icon">{{ workflow.icon }}</text>
<text class="workflow-chip__label" :class="{ 'workflow-chip__label--active': active }">{{ workflow.name }}</text>
</view>
</template>
<script setup lang="uts">
import type { WorkflowDef } from '@/types/index'
const props = defineProps<{
workflow: WorkflowDef
active: boolean
}>()
const emit = defineEmits<{
tap: []
}>()
function onTap(): void {
emit('tap')
}
</script>
<style scoped>
.workflow-chip {
display: flex;
flex-direction: row;
align-items: center;
height: 64rpx;
padding: 0 24rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.06);
margin-right: 16rpx;
flex-shrink: 0;
transition: all 0.25s ease;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.workflow-chip--active {
border-color: transparent;
}
.workflow-chip__icon {
font-size: 28rpx;
margin-right: 8rpx;
}
.workflow-chip__label {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.55);
white-space: nowrap;
transition: color 0.25s ease;
}
.workflow-chip__label--active {
color: #ffffff;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<view class="workflow-selector">
<view class="workflow-selector__scroll">
<view class="workflow-selector__inner">
<WorkflowChip
v-for="wf in workflows"
:key="wf.id"
:workflow="wf"
:active="wf.id === activeId"
@tap="onSelectChip(wf.id)"
/>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import type { WorkflowDef, WorkflowId } from '@/types/index'
import WorkflowChip from './WorkflowChip.uvue'
const props = defineProps<{
workflows: WorkflowDef[]
activeId: WorkflowId
}>()
const emit = defineEmits<{
select: [id: WorkflowId]
}>()
function onSelectChip(id: WorkflowId): void {
if (id !== props.activeId) {
emit('select', id)
}
}
</script>
<style scoped>
.workflow-selector {
padding: 8rpx 0;
background: transparent;
}
/* Outer: clips and scrolls */
.workflow-selector__scroll {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
/* Inner: forced wider than screen, flex row, no wrap */
.workflow-selector__inner {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
padding: 0 24rpx;
width: max-content;
min-width: 1100rpx;
}
/* Hide scrollbar */
.workflow-selector__scroll::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
</style>

View File

@@ -0,0 +1,132 @@
<template>
<view class="workspace-item" :style="{ borderLeftColor: accentColor }" @tap="onTap">
<!-- Header row -->
<view class="workspace-item__header">
<text class="workspace-item__icon">{{ typeIcon }}</text>
<text class="workspace-item__type">{{ typeLabel }}</text>
<text class="workspace-item__time">{{ formatTime(item.createdAt) }}</text>
</view>
<!-- Title -->
<text class="workspace-item__title">{{ item.title }}</text>
<!-- Summary -->
<text class="workspace-item__summary">{{ item.summary }}</text>
</view>
</template>
<script setup lang="uts">
import type { WorkspaceItem, WorkspaceItemType } from '@/types/index'
import { WORKFLOWS } from '@/constants/index'
const props = defineProps<{
item: WorkspaceItem
}>()
const emit = defineEmits<{
tap: []
}>()
// Type icon mapping
const typeIcon = computed<string>(() => {
const icons: Record<WorkspaceItemType, string> = {
code_snippet: '💻',
document: '📝',
analysis_report: '📊',
creative_output: '✨',
}
return icons[props.item.type] ?? '📄'
})
// Type label mapping
const typeLabel = computed<string>(() => {
const labels: Record<WorkspaceItemType, string> = {
code_snippet: '代码',
document: '文档',
analysis_report: '报告',
creative_output: '创意',
}
return labels[props.item.type] ?? '结果'
})
// Accent color from workflow
const accentColor = computed<string>(() => {
const wf = WORKFLOWS.find((w) => w.id === props.item.workflowId)
return wf ? wf.color : '#6c5ce7'
})
function formatTime(timestamp: number): string {
const d = new Date(timestamp)
const m = (d.getMonth() + 1).toString().padStart(2, '0')
const day = d.getDate().toString().padStart(2, '0')
const h = d.getHours().toString().padStart(2, '0')
const min = d.getMinutes().toString().padStart(2, '0')
return `${m}/${day} ${h}:${min}`
}
function onTap(): void {
emit('tap')
}
</script>
<style scoped>
.workspace-item {
display: flex;
flex-direction: column;
padding: 20rpx 24rpx;
border-radius: 12rpx;
margin-bottom: 16rpx;
background: rgba(255, 255, 255, 0.04);
border-left: 4rpx solid #6c5ce7;
transition: background 0.2s ease;
}
.workspace-item:active {
background: rgba(255, 255, 255, 0.07);
}
.workspace-item__header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 10rpx;
}
.workspace-item__icon {
font-size: 24rpx;
margin-right: 8rpx;
}
.workspace-item__type {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.5);
font-weight: 500;
flex: 1;
}
.workspace-item__time {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.3);
}
.workspace-item__title {
font-size: 28rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 8rpx;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.workspace-item__summary {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.45);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<view class="workspace-view">
<!-- Section header -->
<view class="workspace-view__header">
<text class="workspace-view__header-icon">📂</text>
<text class="workspace-view__header-title">工作空间</text>
</view>
<!-- Empty state -->
<view v-if="items.length === 0" class="workspace-view__empty">
<text class="workspace-view__empty-text">暂无工作结果</text>
</view>
<!-- Items list -->
<view v-else class="workspace-view__items">
<WorkspaceItem
v-for="item in items"
:key="item.id"
:item="item"
@tap="onSelect(item.id)"
/>
</view>
</view>
</template>
<script setup lang="uts">
import type { WorkspaceItem } from '@/types/index'
import WorkspaceItemComponent from './WorkspaceItem.uvue'
const props = defineProps<{
items: WorkspaceItem[]
}>()
const emit = defineEmits<{
select: [id: string]
}>()
function onSelect(id: string): void {
emit('select', id)
}
</script>
<style scoped>
.workspace-view {
padding: 16rpx 0;
}
.workspace-view__header {
display: flex;
flex-direction: row;
align-items: center;
padding: 16rpx 24rpx 4rpx;
}
.workspace-view__header-icon {
font-size: 28rpx;
margin-right: 10rpx;
}
.workspace-view__header-title {
font-size: 28rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
}
/* Empty */
.workspace-view__empty {
padding: 40rpx 24rpx;
text-align: center;
}
.workspace-view__empty-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.3);
}
/* Items */
.workspace-view__items {
padding: 8rpx 24rpx;
}
</style>

View File

@@ -0,0 +1,259 @@
/**
* useConversation — Active conversation management
*
* The core business logic composable:
* - Manages the currently active conversation
* - Handles sending messages and receiving mock AI responses
* - Auto-generates workspace items for substantial responses
* - Coordinates between useConversations, useWorkflow, and useWorkspace
*/
import type { Message, WorkflowId, Conversation, WorkspaceItem } from '@/types/index'
import { STORAGE_KEYS, generateId, MOCK_AI_DELAY_MS } from '@/constants/index'
import { useStorage } from './useStorage'
// Mock AI response templates per workflow
const AI_RESPONSES: Record<WorkflowId, string[]> = {
general: [
'这是一个很好的问题!让我来为你详细解答。\n\n首先我们需要理解核心概念。人工智能正在快速发展已经渗透到我们生活的方方面面。\n\n**关键要点:**\n- 保持好奇心,不断学习新知识\n- 实践是检验真理的唯一标准\n- 多角度思考问题能带来更好的解决方案\n\n希望这些对你有所帮助还有其他问题吗',
'感谢你的提问!关于这个问题,我有以下几点建议:\n\n1. **明确目标**:先确定你想要达到的具体结果\n2. **制定计划**:把大目标拆分成小步骤\n3. **持续迭代**:在执行过程中不断优化调整\n\n如果你能提供更多具体信息我可以给出更具针对性的建议。',
'让我从几个角度来分析这个问题:\n\n从技术层面看这涉及到系统的整体架构设计。从用户体验角度来说简洁直观的交互至关重要。\n\n综合考虑我建议采用渐进式的方法先验证核心功能再逐步扩展。你觉得这个思路如何',
],
code: [
'好的,让我来帮你解决这个编程问题。\n\n```\nfunction solve(arr: number[]): number {\n const n = arr.length\n let result = 0\n for (let i = 0; i < n; i++) {\n result += arr[i]\n }\n return result\n}\n```\n\n这段代码的时间复杂度是 O(n),空间复杂度是 O(1)。\n\n**注意事项:**\n- 记得处理边界情况(空数组)\n- 可以考虑使用 reduce 方法简化代码\n- 如果需要处理大数,注意整数溢出问题\n\n还有什么需要我帮忙的吗',
'这是一个经典的设计模式问题。我推荐使用以下架构:\n\n```\nclass EventBus {\n private handlers: Map<string, Function[]>\n \n on(event: string, handler: Function) {\n // 注册事件处理器\n }\n \n emit(event: string, data: any) {\n // 触发事件\n }\n}\n```\n\n这种发布-订阅模式可以有效解耦模块间的依赖,让代码更具可维护性。',
'让我看看你的代码逻辑...\n\n问题在于异步操作的处理。你需要使用 `await` 来等待 Promise 完成:\n\n```\nasync function fetchData(url: string) {\n try {\n const response = await fetch(url)\n const data = await response.json()\n return data\n } catch (error) {\n console.error(\'Failed to fetch:\', error)\n throw error\n }\n}\n```\n\n另外建议添加错误处理和重试机制提高代码的健壮性。',
],
document: [
'好的,我来帮你撰写这篇文档。\n\n---\n\n**引言**\n\n在当今快速发展的数字化时代企业面临着前所未有的机遇与挑战。如何有效利用技术手段提升业务效率已成为每个管理者必须思考的问题。\n\n**核心观点**\n\n- 数字化转型不仅仅是技术升级,更是思维方式的转变\n- 数据驱动决策能够显著提升业务精准度\n- 用户体验应始终放在产品设计的首位\n\n**总结**\n\n通过系统化的方法推进数字化转型企业能够在竞争激烈的市场中保持领先地位。\n\n---\n\n以上是初稿你觉得需要调整什么方向吗',
'帮你润色了一下这段文字:\n\n**原文**:这个产品很好用,功能很多,界面也好看。\n\n**润色后**:这款产品表现优秀,不仅功能丰富全面,界面设计也简洁美观,为用户带来了出色的使用体验。\n\n**修改要点:**\n- 用词更加正式专业\n- 句式更加丰富多变\n- 增强了文字的感染力\n\n需要继续优化其他内容吗',
],
data: [
'根据你提供的数据,我进行了详细分析:\n\n📊 **数据概览**\n\n| 指标 | 当前值 | 环比变化 |\n|------|--------|----------|\n| 用户数 | 12,580 | +8.3% |\n| 活跃率 | 67.2% | +2.1% |\n| 转化率 | 3.8% | -0.5% |\n\n🔍 **关键洞察**\n\n1. 用户增长保持良好势头,增长率超过行业平均水平\n2. 活跃率稳步提升,说明产品粘性增强\n3. 转化率略有下降,需要关注转化漏斗的优化\n\n💡 **建议措施**\n\n- 针对转化率问题,优化注册流程\n- 分析高活跃用户的行为特征并复制推广\n- 关注用户留存曲线的变化趋势\n\n需要我深入分析某个具体指标吗',
'让我从数据角度来分析这个趋势:\n\n**相关性分析结果:**\n- 变量A与变量B的相关系数0.87(强正相关)\n- 变量C与结果的相关系数-0.42(中等负相关)\n\n这意味着当A增加时B也倾向于增加。而C的增加反而会抑制结果的提升。\n\n**统计显著性**p值 < 0.01,结果具有统计意义。\n\n建议重点关注A和B的关系这可能是一个关键的驱动因素。',
],
creative: [
'好的,让我来一场头脑风暴!🎯\n\n**方案一:社交裂变**\n利用用户的社交网络通过分享激励机制实现病毒式传播。核心是设计一个让用户"忍不住想分享"的体验。\n\n**方案二内容IP化**\n打造独特的品牌IP形象通过故事化内容与用户建立情感连接。IP可以出现在产品各个触点。\n\n**方案三:场景化营销**\n深入用户真实使用场景在不同场景下提供定制化的解决方案。让产品"恰好"出现在用户需要的时候。\n\n💡 我个人最看好方案二的长期价值,但方案一能在短期内带来快速增长。你觉得哪个方向更符合你的目标?',
'让我们跳出常规思维,重新构想这个问题的解决方案!\n\n想象一下如果完全没有技术和资源的限制理想的解决方案会是什么样\n\n从这个"理想态"往回推导:\n1. 理想体验的核心要素是什么?\n2. 哪些要素可以通过现有技术实现?\n3. 哪些需要创新的替代方案?\n\n这种"反向思维"往往能激发出意想不到的创意。比如Airbnb就是通过"让陌生人住进家里"这个看似疯狂的创意颠覆了酒店行业。\n\n你有什么初步的想法吗我们一起碰撞出更多灵感',
],
}
/**
* @param conversationsApi — functions from useConversations(): getConversation, saveConversation, createConversation
* @param getActiveWorkflowId — getter function that returns the current active workflow ID from useWorkflow()
* @param addWorkspaceItemFn — function from useWorkspace() to add workspace items.
* All are passed in as dependencies so the same state is shared with the page.
*/
export function useConversation(
conversationsApi: {
getConversation: (id: string) => Conversation | undefined
saveConversation: (conv: Conversation) => void
createConversation: (workflowId: WorkflowId, title?: string) => Conversation
},
getActiveWorkflowId: () => WorkflowId,
addWorkspaceItemFn?: (item: WorkspaceItem) => void
) {
const { loadValue, saveValue } = useStorage()
const { getConversation, saveConversation, createConversation } = conversationsApi
// Fallback no-op if not provided
const addWorkspaceItem = addWorkspaceItemFn ?? (() => {})
// Active conversation ID
const activeConversationId = ref<string | null>(
loadValue<string | null>(STORAGE_KEYS.ACTIVE_CONVERSATION_ID, null)
)
// Whether the AI is currently "typing"
const isLoading = ref(false)
/**
* Messages of the currently active conversation (computed).
*/
const messages = computed<Message[]>(() => {
if (!activeConversationId.value) return []
const conv = getConversation(activeConversationId.value)
return conv ? conv.messages : []
})
/**
* Set the active conversation ID and persist.
*/
function setActiveConversationId(id: string | null): void {
activeConversationId.value = id
if (id) {
saveValue(STORAGE_KEYS.ACTIVE_CONVERSATION_ID, id)
} else {
saveValue(STORAGE_KEYS.ACTIVE_CONVERSATION_ID, null)
}
}
/**
* Start a new conversation with the given workflow.
*/
function startNewConversation(workflowId: WorkflowId): void {
const conv = createConversation(workflowId)
setActiveConversationId(conv.id)
isLoading.value = false
}
/**
* Load an existing conversation by ID.
*/
function loadConversation(id: string): void {
const conv = getConversation(id)
if (conv) {
setActiveConversationId(id)
isLoading.value = false
}
}
/**
* Send a message from the user.
* Adds the user message, then triggers a mock AI response after a delay.
*/
function sendMessage(content: string): void {
if (!content.trim() || isLoading.value) return
// Ensure we have an active conversation
if (!activeConversationId.value) {
startNewConversation(getActiveWorkflowId())
}
const convId: string = activeConversationId.value ?? ''
if (!convId) return
const conv = getConversation(convId)
if (!conv) {
startNewConversation(getActiveWorkflowId())
// Retry after creating
sendMessage(content)
return
}
// Add user message
const userMessage: Message = {
id: generateId(),
role: 'user',
content: content.trim(),
timestamp: Date.now(),
status: 'sent',
}
conv.messages.push(userMessage)
conv.updatedAt = Date.now()
// Auto-title: use first user message as conversation title
if (conv.messages.filter((m) => m.role === 'user').length === 1) {
let title = content.trim()
if (title.length > 20) {
title = title.substring(0, 20) + '...'
}
conv.title = title
}
saveConversation(conv)
// Trigger mock AI response
isLoading.value = true
setTimeout(() => {
simulateAiResponse(conv)
}, MOCK_AI_DELAY_MS)
}
/**
* Retry sending a message (for error recovery).
*/
function retryMessage(messageId: string): void {
const conv = getConversation(activeConversationId.value ?? '')
if (!conv) return
const msg = conv.messages.find((m) => m.id === messageId)
if (msg) {
msg.status = 'sent'
saveConversation(conv)
}
}
/**
* Simulate an AI response.
* In production, replace this with a real API call.
*/
function simulateAiResponse(conv: Conversation): void {
const responses = AI_RESPONSES[conv.workflowId] ?? AI_RESPONSES['general']
const responseContent = responses[Math.floor(Math.random() * responses.length)]
const aiMessage: Message = {
id: generateId(),
role: 'assistant',
content: responseContent,
timestamp: Date.now(),
status: 'sent',
}
// Get fresh reference in case conversations array changed
const freshConv = getConversation(conv.id)
if (!freshConv) {
isLoading.value = false
return
}
freshConv.messages.push(aiMessage)
freshConv.updatedAt = Date.now()
saveConversation(freshConv)
// Auto-generate workspace item for substantial responses
if (conv.workflowId !== 'general') {
generateWorkspaceItem(freshConv, aiMessage)
}
isLoading.value = false
}
/**
* Generate a workspace item from an AI response.
*/
function generateWorkspaceItem(conv: Conversation, message: Message): void {
const typeMap: Record<string, string> = {
code: 'code_snippet',
document: 'document',
data: 'analysis_report',
creative: 'creative_output',
}
const titles: Record<string, string> = {
code: '代码片段',
document: '文档草稿',
data: '分析报告',
creative: '创意方案',
}
const itemType = (typeMap[conv.workflowId] ?? 'document') as import('@/types/index').WorkspaceItemType
const autoTitle = titles[conv.workflowId] ?? '工作结果'
// Only create if response is long enough to be "substantial"
if (message.content.length < 50) return
addWorkspaceItem({
id: generateId(),
conversationId: conv.id,
workflowId: conv.workflowId,
type: itemType,
title: `${autoTitle} - ${conv.title}`,
content: message.content,
summary: message.content.substring(0, 120) + '...',
createdAt: Date.now(),
})
}
return {
activeConversationId,
messages,
isLoading,
sendMessage,
retryMessage,
loadConversation,
startNewConversation,
setActiveConversationId,
}
}

View File

@@ -0,0 +1,93 @@
/**
* useConversations — Conversation CRUD and history list management
*
* Manages the full array of all conversations. Persisted to storage.
* Provides create, delete, update, and query operations.
*/
import type { Conversation, WorkflowId } from '@/types/index'
import { STORAGE_KEYS, generateId } from '@/constants/index'
import { useStorage } from './useStorage'
export function useConversations() {
const { loadValue, saveValue } = useStorage()
// All conversations loaded from persistence
const conversations = ref<Conversation[]>(
loadValue<Conversation[]>(STORAGE_KEYS.CONVERSATIONS, [])
)
/**
* Persist the current conversations array to storage.
*/
function persist(): void {
saveValue(STORAGE_KEYS.CONVERSATIONS, conversations.value)
}
/**
* Create a new empty conversation with the given workflow.
*/
function createConversation(workflowId: WorkflowId, title?: string): Conversation {
const now = Date.now()
const conversation: Conversation = {
id: generateId(),
title: title ?? '新对话',
workflowId: workflowId,
messages: [],
createdAt: now,
updatedAt: now,
}
conversations.value.unshift(conversation)
persist()
return conversation
}
/**
* Delete a conversation by ID.
*/
function deleteConversation(id: string): void {
conversations.value = conversations.value.filter((c) => c.id !== id)
persist()
}
/**
* Update a conversation's title.
*/
function updateConversationTitle(id: string, title: string): void {
const conv = conversations.value.find((c) => c.id === id)
if (conv) {
conv.title = title
conv.updatedAt = Date.now()
persist()
}
}
/**
* Get a conversation by ID.
*/
function getConversation(id: string): Conversation | undefined {
return conversations.value.find((c) => c.id === id)
}
/**
* Update a conversation's messages and timestamp.
* Used after adding/removing messages.
*/
function saveConversation(conversation: Conversation): void {
const index = conversations.value.findIndex((c) => c.id === conversation.id)
if (index !== -1) {
conversations.value[index] = conversation
persist()
}
}
return {
conversations,
createConversation,
deleteConversation,
updateConversationTitle,
getConversation,
saveConversation,
persist,
}
}

29
composables/useDrawer.uts Normal file
View File

@@ -0,0 +1,29 @@
/**
* useDrawer — Drawer open/close state management
*
* Controls the left-slide drawer that contains history and workspace views.
* Simple boolean toggle — no persistence needed.
*/
export function useDrawer() {
const isDrawerOpen = ref(false)
function openDrawer(): void {
isDrawerOpen.value = true
}
function closeDrawer(): void {
isDrawerOpen.value = false
}
function toggleDrawer(): void {
isDrawerOpen.value = !isDrawerOpen.value
}
return {
isDrawerOpen,
openDrawer,
closeDrawer,
toggleDrawer,
}
}

View File

@@ -0,0 +1,53 @@
/**
* useStorage — Typed persistence wrapper around uni.getStorageSync/setStorageSync
*/
export function useStorage() {
/**
* Load a value from storage with a fallback default.
* Returns fallback if key doesn't exist or JSON parsing fails.
*/
function loadValue<T>(key: string, fallback: T): T {
try {
const raw = uni.getStorageSync(key)
if (raw === '' || raw === undefined || raw === null) {
return fallback
}
const parsed = JSON.parse(raw) as T
return parsed !== null ? parsed : fallback
} catch (_e) {
console.warn(`[useStorage] Failed to load key "${key}", using fallback`)
return fallback
}
}
/**
* Save a value to storage as JSON.
*/
function saveValue<T>(key: string, value: T): void {
try {
const raw = JSON.stringify(value)
uni.setStorageSync(key, raw)
} catch (e) {
console.error(`[useStorage] Failed to save key "${key}":`, e)
}
}
/**
* Remove a key from storage.
*/
function removeValue(key: string): void {
try {
uni.removeStorageSync(key)
} catch (e) {
console.error(`[useStorage] Failed to remove key "${key}":`, e)
}
}
return {
loadValue,
saveValue,
removeValue,
}
}

View File

@@ -0,0 +1,43 @@
/**
* useWorkflow — Current workflow selection state
*
* Manages which workflow is active (general, code, document, data, creative).
* The selection is persisted so it survives app restarts.
*/
import type { WorkflowId, WorkflowDef } from '@/types/index'
import { WORKFLOWS, STORAGE_KEYS } from '@/constants/index'
import { useStorage } from './useStorage'
export function useWorkflow() {
const { loadValue, saveValue } = useStorage()
// Initialize from storage, fallback to 'general'
const activeWorkflowId = ref<WorkflowId>(
loadValue<WorkflowId>(STORAGE_KEYS.ACTIVE_WORKFLOW_ID, 'general')
)
// All available workflows (static)
const workflows: WorkflowDef[] = WORKFLOWS
// Computed: the currently active workflow definition
const activeWorkflow = computed<WorkflowDef>(() => {
const found = workflows.find((w) => w.id === activeWorkflowId.value)
return found ?? workflows[0]
})
/**
* Select a workflow and persist the choice.
*/
function selectWorkflow(id: WorkflowId): void {
activeWorkflowId.value = id
saveValue(STORAGE_KEYS.ACTIVE_WORKFLOW_ID, id)
}
return {
activeWorkflowId,
workflows,
activeWorkflow,
selectWorkflow,
}
}

View File

@@ -0,0 +1,69 @@
/**
* useWorkspace — Workspace items management
*
* Manages workspace items generated from AI workflow responses.
* Items are persisted and filterable by workflow type.
*/
import type { WorkspaceItem, WorkflowId } from '@/types/index'
import { STORAGE_KEYS } from '@/constants/index'
import { useStorage } from './useStorage'
export function useWorkspace() {
const { loadValue, saveValue } = useStorage()
const workspaceItems = ref<WorkspaceItem[]>(
loadValue<WorkspaceItem[]>(STORAGE_KEYS.WORKSPACE, [])
)
function persist(): void {
saveValue(STORAGE_KEYS.WORKSPACE, workspaceItems.value)
}
/**
* Add a new workspace item.
*/
function addWorkspaceItem(item: WorkspaceItem): void {
workspaceItems.value.unshift(item)
persist()
}
/**
* Remove a workspace item by ID.
*/
function removeWorkspaceItem(id: string): void {
workspaceItems.value = workspaceItems.value.filter((item) => item.id !== id)
persist()
}
/**
* Get workspace items filtered by workflow type.
*/
function getItemsByWorkflow(workflowId: WorkflowId): WorkspaceItem[] {
return workspaceItems.value.filter((item) => item.workflowId === workflowId)
}
/**
* Get workspace item by ID.
*/
function getItemById(id: string): WorkspaceItem | undefined {
return workspaceItems.value.find((item) => item.id === id)
}
/**
* Clear all workspace items.
*/
function clearAll(): void {
workspaceItems.value = []
persist()
}
return {
workspaceItems,
addWorkspaceItem,
removeWorkspaceItem,
getItemsByWorkflow,
getItemById,
clearAll,
}
}

65
constants/index.uts Normal file
View File

@@ -0,0 +1,65 @@
/**
* AI Chat Assistant — Constants
*/
import type { WorkflowDef } from '@/types/index'
// Workflow definitions
export const WORKFLOWS: WorkflowDef[] = [
{
id: 'general',
name: '通用对话',
icon: '💬',
description: '自由提问获取AI智能回答',
color: '#6c5ce7',
},
{
id: 'code',
name: '代码助手',
icon: '💻',
description: '编程问题解答、代码生成与调试',
color: '#00b894',
},
{
id: 'document',
name: '文档写作',
icon: '📝',
description: '文章撰写、润色与总结',
color: '#fdcb6e',
},
{
id: 'data',
name: '数据分析',
icon: '📊',
description: '数据处理、图表解读与洞察分析',
color: '#e17055',
},
{
id: 'creative',
name: '创意生成',
icon: '✨',
description: '头脑风暴、创意点子与方案策划',
color: '#a29bfe',
},
]
// Storage keys
export const STORAGE_KEYS = {
CONVERSATIONS: 'asst_conversations',
WORKSPACE: 'asst_workspace',
ACTIVE_CONVERSATION_ID: 'asst_active_conv_id',
ACTIVE_WORKFLOW_ID: 'asst_workflow_id',
} as const
// App settings
export const MOCK_AI_DELAY_MS = 1500
export const MAX_INPUT_LENGTH = 2000
export const APP_NAME = 'AI助手'
export const APP_SUBTITLE = '智能工作助手'
// Generate a simple unique ID
export function generateId(): string {
const timestamp = Date.now().toString(36)
const random = Math.floor(Math.random() * 100000).toString(36)
return `${timestamp}${random}`
}

20
index.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main"></script>
</body>
</html>

9
main.uts Normal file
View File

@@ -0,0 +1,9 @@
import App from './App.uvue'
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
return {
app
}
}

43
manifest.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name" : "AI助手",
"appid" : "",
"description" : "智能AI聊天助手 - 多工作流对话、工作空间、历史记录",
"versionName" : "1.0.0",
"versionCode" : "100",
"uni-app-x" : {},
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3",
"app" : {
"distribute" : {
"icons" : {
"android" : {
"hdpi" : "",
"xhdpi" : "",
"xxhdpi" : "",
"xxxhdpi" : ""
}
}
}
}
}

19
pages.json Normal file
View File

@@ -0,0 +1,19 @@
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "AI助手",
"softinputMode": "adjustResize"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "AI助手",
"navigationBarBackgroundColor": "#0f0f1a",
"backgroundColor": "#0f0f1a"
},
"uniIdRouter": {}
}

425
pages/index/index.uvue Normal file
View File

@@ -0,0 +1,425 @@
<template>
<view class="app-root">
<!-- ========== Tab: Workspace ========== -->
<view v-if="activeTab === 'workspace'" class="app-page">
<view class="app-page__header" :style="{ paddingTop: statusBarHeight + 'px' }">
<text class="app-page__title">工作空间</text>
</view>
<scroll-view class="app-page__body" scroll-y="true" show-scrollbar="false">
<view v-if="workspaceItems.length === 0" class="app-empty">
<text class="app-empty__icon">📂</text>
<text class="app-empty__text">暂无工作结果</text>
<text class="app-empty__hint">使用工作流对话生成的结果会显示在这里</text>
</view>
<view v-else class="app-page__content">
<WorkspaceItemComponent
v-for="item in workspaceItems"
:key="item.id"
:item="item"
@tap="onWorkspaceItemTap(item.id)"
/>
</view>
</scroll-view>
</view>
<!-- ========== Tab: Chat ========== -->
<view v-if="activeTab === 'chat'" class="app-page">
<!-- Chat Top Bar -->
<view class="chat-topbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="chat-topbar__inner">
<text class="chat-topbar__title">{{ topBarTitle }}</text>
</view>
</view>
<!-- Messages -->
<MessageList
:messages="messages"
:isLoading="isLoading"
:emptyTitle="emptyTitle"
:emptySubtitle="emptySubtitle"
@retry="onRetryMessage"
/>
<!-- Workflow Selector -->
<WorkflowSelector
:workflows="workflows"
:activeId="activeWorkflowId"
@select="onWorkflowChange"
/>
<!-- Input -->
<InputArea
:disabled="isLoading"
:placeholder="inputPlaceholder"
@send="onSendMessage"
/>
</view>
<!-- ========== Tab: Profile ========== -->
<view v-if="activeTab === 'profile'" class="app-page">
<view class="app-page__header" :style="{ paddingTop: statusBarHeight + 'px' }">
<text class="app-page__title">我的</text>
</view>
<scroll-view class="app-page__body" scroll-y="true" show-scrollbar="false">
<!-- Avatar -->
<view class="profile-avatar-area">
<view class="profile-avatar">
<text class="profile-avatar-icon">🧑‍💻</text>
</view>
<text class="profile-name">用户</text>
<text class="profile-id">AI助手</text>
</view>
<!-- Stats -->
<view class="profile-stats">
<view class="profile-stat">
<text class="profile-stat-val">{{ conversations.length }}</text>
<text class="profile-stat-lbl">对话数</text>
</view>
<view class="profile-stat-div"></view>
<view class="profile-stat">
<text class="profile-stat-val">{{ workspaceItems.length }}</text>
<text class="profile-stat-lbl">工作结果</text>
</view>
</view>
<!-- Menu -->
<view class="profile-menu">
<view class="profile-menu-item" @tap="onProfileMenu('settings')">
<text class="profile-menu-icon">⚙️</text>
<text class="profile-menu-label">设置</text>
<text class="profile-menu-arrow"></text>
</view>
<view class="profile-menu-item" @tap="onProfileMenu('about')">
<text class="profile-menu-icon"></text>
<text class="profile-menu-label">关于</text>
<text class="profile-menu-arrow"></text>
</view>
<view class="profile-menu-item" @tap="onProfileMenu('clear')">
<text class="profile-menu-icon">🗑</text>
<text class="profile-menu-label">清除数据</text>
<text class="profile-menu-arrow"></text>
</view>
</view>
<text class="profile-version">AI助手 v1.0.0</text>
</scroll-view>
</view>
<!-- ========== Bottom Tab Bar ========== -->
<BottomTabBar :activeKey="activeTab" @change="activeTab = $event" />
</view>
</template>
<script setup lang="uts">
import type { WorkflowId, TabKey } from '@/types/index'
import { useWorkflow } from '@/composables/useWorkflow'
import { useConversations } from '@/composables/useConversations'
import { useConversation } from '@/composables/useConversation'
import { useWorkspace } from '@/composables/useWorkspace'
import BottomTabBar from '@/components/BottomTabBar.uvue'
import WorkflowSelector from '@/components/WorkflowSelector.uvue'
import MessageList from '@/components/MessageList.uvue'
import InputArea from '@/components/InputArea.uvue'
import WorkspaceItemComponent from '@/components/WorkspaceItem.uvue'
// ---- Tab state ----
const activeTab = ref<TabKey>('chat')
// ---- Status bar ----
const statusBarHeight = ref(24)
uni.getSystemInfo({
success: (res) => { statusBarHeight.value = res.statusBarHeight ?? 24 },
fail: () => { statusBarHeight.value = 24 },
})
// ---- Composables ----
const { activeWorkflowId, workflows, activeWorkflow, selectWorkflow } = useWorkflow()
const { conversations, deleteConversation, getConversation, saveConversation, createConversation } = useConversations()
const { workspaceItems, addWorkspaceItem } = useWorkspace()
const {
messages,
isLoading,
sendMessage,
retryMessage,
startNewConversation,
} = useConversation(
{ getConversation, saveConversation, createConversation },
() => activeWorkflowId.value,
addWorkspaceItem
)
// ---- Chat tab computed ----
const topBarTitle = computed<string>(() => activeWorkflow.value.name)
const inputPlaceholder = computed<string>(() => {
const m: Record<string, string> = {
general: '输入消息...', code: '描述你的编程问题...',
document: '输入写作需求...', data: '输入数据分析需求...', creative: '描述你的创意想法...',
}
return m[activeWorkflowId.value] ?? '输入消息...'
})
const emptyTitle = computed<string>(() => {
const m: Record<string, string> = {
general: '开始对话', code: '代码助手', document: '文档写作', data: '数据分析', creative: '创意生成',
}
return m[activeWorkflowId.value] ?? '开始对话'
})
const emptySubtitle = computed<string>(() => {
const m: Record<string, string> = {
general: '提出任何问题AI 将为你详细解答',
code: '提出编程问题,获取代码帮助与调试建议',
document: '告诉我你想写什么,我来帮你撰写与润色',
data: '分享你的数据需求,获取分析洞察与建议',
creative: '描述你的目标,一起头脑风暴创意方案',
}
return m[activeWorkflowId.value] ?? '开始与 AI 对话'
})
// ---- Chat handlers ----
function onWorkflowChange(id: WorkflowId): void {
selectWorkflow(id)
startNewConversation(id)
}
function onSendMessage(text: string): void { sendMessage(text) }
function onRetryMessage(messageId: string): void { retryMessage(messageId) }
// ---- Workspace handlers ----
function onWorkspaceItemTap(id: string): void {
const item = workspaceItems.value.find((w) => w.id === id)
if (item) {
uni.showToast({ title: `查看: ${item.title}`, icon: 'none' })
}
}
// ---- Profile handlers ----
function onProfileMenu(key: string): void {
if (key === 'clear') {
uni.showModal({
title: '确认清除',
content: '将清除所有对话历史和工作空间数据,此操作不可恢复。',
success: (res: any) => {
if (res.confirm) {
uni.clearStorageSync()
uni.showToast({ title: '数据已清除', icon: 'success' })
}
},
})
} else {
uni.showToast({ title: `功能开发中: ${key}`, icon: 'none' })
}
}
</script>
<style scoped>
/* ========== Root ========== */
.app-root {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #0f0f1a;
overflow: hidden;
}
/* ========== Page wrapper ========== */
.app-page {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ========== Generic header ========== */
.app-page__header {
padding: 16rpx 32rpx;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.app-page__title {
font-size: 36rpx;
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
}
.app-page__body {
flex: 1;
}
.app-page__content {
padding: 24rpx;
}
/* ========== Empty state ========== */
.app-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64rpx;
}
.app-empty__icon {
font-size: 80rpx;
margin-bottom: 24rpx;
opacity: 0.5;
}
.app-empty__text {
font-size: 30rpx;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 12rpx;
}
.app-empty__hint {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.3);
text-align: center;
}
/* ========== Chat top bar ========== */
.chat-topbar {
background: rgba(15, 15, 26, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.chat-topbar__inner {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
}
.chat-topbar__title {
font-size: 32rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
/* ========== Profile ========== */
.profile-avatar-area {
display: flex;
flex-direction: column;
align-items: center;
padding: 56rpx 0 32rpx;
}
.profile-avatar {
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(108, 92, 231, 0.3);
}
.profile-avatar-icon {
font-size: 56rpx;
}
.profile-name {
font-size: 36rpx;
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
margin-bottom: 6rpx;
}
.profile-id {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.35);
}
.profile-stats {
display: flex;
flex-direction: row;
align-items: center;
padding: 28rpx 48rpx;
margin: 0 24rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.profile-stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.profile-stat-val {
font-size: 40rpx;
font-weight: 700;
color: #a29bfe;
margin-bottom: 4rpx;
}
.profile-stat-lbl {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
}
.profile-stat-div {
width: 1px;
height: 48rpx;
background: rgba(255, 255, 255, 0.1);
}
.profile-menu {
margin: 24rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.profile-menu-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 28rpx 24rpx;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.profile-menu-item:last-child {
border-bottom: none;
}
.profile-menu-item:active {
background: rgba(255, 255, 255, 0.04);
}
.profile-menu-icon {
font-size: 32rpx;
margin-right: 20rpx;
}
.profile-menu-label {
flex: 1;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
}
.profile-menu-arrow {
font-size: 36rpx;
color: rgba(255, 255, 255, 0.25);
}
.profile-version {
text-align: center;
padding: 16rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.2);
display: block;
}
</style>

7
platformConfig.json Normal file
View File

@@ -0,0 +1,7 @@
// 参考链接 https://doc.dcloud.net.cn/uni-app-x/tutorial/ls-plugin.html#setting
{
"targets": [
"APP-ANDROID"
]
}

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

59
types/index.uts Normal file
View File

@@ -0,0 +1,59 @@
/**
* AI Chat Assistant — Type Definitions
*/
// Workflow types
export type WorkflowId = 'general' | 'code' | 'document' | 'data' | 'creative'
export interface WorkflowDef {
id: WorkflowId
name: string
icon: string
description: string
color: string
}
// Message types
export type MessageRole = 'user' | 'assistant'
export type MessageStatus = 'sending' | 'sent' | 'error'
export interface Message {
id: string
role: MessageRole
content: string
timestamp: number
status: MessageStatus
}
// Conversation types
export interface Conversation {
id: string
title: string
workflowId: WorkflowId
messages: Message[]
createdAt: number
updatedAt: number
}
// Workspace types
export type WorkspaceItemType = 'code_snippet' | 'document' | 'analysis_report' | 'creative_output'
export interface WorkspaceItem {
id: string
conversationId: string
workflowId: WorkflowId
type: WorkspaceItemType
title: string
content: string
summary: string
createdAt: number
}
// Parsed markdown segment
export interface ParsedSegment {
type: 'text' | 'code' | 'bold'
value: string
}
// Bottom tab keys
export type TabKey = 'workspace' | 'chat' | 'profile'

156
uni.scss Normal file
View File

@@ -0,0 +1,156 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场https://ext.dcloud.net.cn上很多三方插件均使用了这些样式变量
* 如果你是插件开发者建议你使用scss预处理并在插件代码中直接使用这些变量无需 import 这个文件方便用户通过搭积木的方式开发整体风格一致的App
*/
/**
* 如果你是App开发者插件使用者你可以通过修改这些变量来定制自己的插件主题实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color:#333;//基本色
$uni-text-color-inverse:#fff;//反色
$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable:#c0c0c0;
/* 背景颜色 */
$uni-bg-color:#ffffff;
$uni-bg-color-grey:#f8f8f8;
$uni-bg-color-hover:#f1f1f1;//点击状态颜色
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
/* 边框颜色 */
$uni-border-color:#c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm:12px;
$uni-font-size-base:14px;
$uni-font-size-lg:16px;
/* 图片尺寸 */
$uni-img-size-sm:20px;
$uni-img-size-base:26px;
$uni-img-size-lg:40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2C405A; // 文章标题颜色
$uni-font-size-title:20px;
$uni-color-subtitle: #555555; // 二级标题颜色
$uni-font-size-subtitle:26px;
$uni-color-paragraph: #3F536E; // 文章段落颜色
$uni-font-size-paragraph:15px;
// ==========================================
// Dark AI Theme Design Tokens
// ==========================================
/* 暗色背景 */
$dark-bg-primary: #0f0f1a;
$dark-bg-secondary: #1a1a2e;
$dark-bg-card: #222240;
$dark-bg-input: rgba(255, 255, 255, 0.06);
$dark-bg-hover: rgba(255, 255, 255, 0.08);
/* 暗色强调色 */
$dark-accent-primary: #6c5ce7;
$dark-accent-secondary: #a29bfe;
$dark-accent-gradient: linear-gradient(135deg, #6c5ce7 0%, #a29bfe 100%);
$dark-accent-glow: 0 0 20px rgba(108, 92, 231, 0.3);
/* 暗色文字 */
$dark-text-primary: rgba(255, 255, 255, 0.92);
$dark-text-secondary: rgba(255, 255, 255, 0.6);
$dark-text-tertiary: rgba(255, 255, 255, 0.38);
/* 暗色边框 */
$dark-border: rgba(255, 255, 255, 0.08);
$dark-border-light: rgba(255, 255, 255, 0.12);
/* 暗色遮罩 */
$dark-mask: rgba(0, 0, 0, 0.55);
/* 气泡尺寸 */
$chat-bubble-radius: 24rpx;
$chat-bubble-padding: 20rpx 24rpx;
$chat-bubble-max-width: 80%;
/* 芯片尺寸 */
$chip-height: 64rpx;
$chip-padding: 0 24rpx;
$chip-radius: 32rpx;
/* 输入区域 */
$input-height: 72rpx;
$input-radius: 36rpx;
$input-padding: 16rpx 24rpx;
/* 顶栏 */
$topbar-height: 88rpx;
/* 抽屉 */
$drawer-width: 600rpx;
/* 动画 */
$transition-fast: 0.2s ease;
$transition-normal: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
$transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
/* 字体大小 */
$font-size-xs: 20rpx;
$font-size-sm: 24rpx;
$font-size-base: 28rpx;
$font-size-md: 32rpx;
$font-size-lg: 36rpx;
/* 间距 */
$spacing-xs: 8rpx;
$spacing-sm: 16rpx;
$spacing-md: 24rpx;
$spacing-lg: 32rpx;
$spacing-xl: 48rpx;
/* 玻璃效果 */
@mixin glass {
background: rgba(15, 15, 26, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
@mixin glass-light {
background: rgba(255, 255, 255, 0.06);
border: 1px solid $dark-border;
}