refactor: 修改为项目驱动
This commit is contained in:
parent
a24eb8e24f
commit
55e28b8d3b
|
|
@ -11,7 +11,8 @@ from backend.utils.workspace import (
|
|||
create_project_directory,
|
||||
delete_project_directory,
|
||||
get_project_path,
|
||||
save_uploaded_files
|
||||
save_uploaded_files,
|
||||
validate_path_in_project,
|
||||
)
|
||||
|
||||
bp = Blueprint("projects", __name__)
|
||||
|
|
@ -325,3 +326,202 @@ def list_project_files(project_id):
|
|||
"total_files": len(files),
|
||||
"total_dirs": len(directories)
|
||||
})
|
||||
|
||||
|
||||
# --- REST file operation endpoints ---
|
||||
|
||||
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB read limit
|
||||
TEXT_EXTENSIONS = {
|
||||
".py", ".js", ".ts", ".jsx", ".tsx", ".vue", ".html", ".css", ".scss", ".less",
|
||||
".json", ".yaml", ".yml", ".toml", ".xml", ".csv", ".md", ".txt", ".log",
|
||||
".sh", ".bash", ".zsh", ".bat", ".ps1", ".cmd",
|
||||
".c", ".h", ".cpp", ".hpp", ".java", ".go", ".rs", ".rb", ".php",
|
||||
".sql", ".r", ".swift", ".kt", ".dart", ".lua", ".pl", ".m",
|
||||
".ini", ".cfg", ".conf", ".env", ".gitignore", ".dockerignore",
|
||||
".dockerfile", ".makefile", ".cmake", ".gradle", ".properties",
|
||||
".proto", ".graphql", ".tf", ".hcl",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_file_path(project_id, filepath):
|
||||
"""Resolve and validate a file path within a project directory."""
|
||||
project = Project.query.get(project_id)
|
||||
if not project:
|
||||
return None, None, err(404, "Project not found")
|
||||
project_dir = get_project_path(project.id, project.path)
|
||||
try:
|
||||
target = validate_path_in_project(filepath, project_dir)
|
||||
except ValueError:
|
||||
return None, None, err(403, "Invalid path: outside project directory")
|
||||
return project_dir, target, None
|
||||
|
||||
|
||||
@bp.route("/api/projects/<project_id>/files/<path:filepath>", methods=["GET"])
|
||||
def read_project_file(project_id, filepath):
|
||||
"""Read a single file's content (text only)."""
|
||||
project_dir, target, error = _resolve_file_path(project_id, filepath)
|
||||
if error:
|
||||
return error
|
||||
|
||||
if not target.exists():
|
||||
return err(404, "File not found")
|
||||
if not target.is_file():
|
||||
return err(400, "Path is not a file")
|
||||
|
||||
if target.stat().st_size > MAX_FILE_SIZE:
|
||||
return err(400, "File too large (max 5 MB)")
|
||||
|
||||
try:
|
||||
content = target.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return err(400, "Binary file, cannot preview as text")
|
||||
|
||||
return ok({
|
||||
"name": target.name,
|
||||
"path": str(target.relative_to(project_dir)),
|
||||
"size": target.stat().st_size,
|
||||
"extension": target.suffix,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/projects/<project_id>/files/<path:filepath>", methods=["PUT"])
|
||||
def write_project_file(project_id, filepath):
|
||||
"""Create or overwrite a file."""
|
||||
data = request.get_json()
|
||||
if not data or "content" not in data:
|
||||
return err(400, "Missing 'content' in request body")
|
||||
|
||||
project_dir, target, error = _resolve_file_path(project_id, filepath)
|
||||
if error:
|
||||
return error
|
||||
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(data["content"], encoding="utf-8")
|
||||
except Exception as e:
|
||||
return err(500, f"Failed to write file: {str(e)}")
|
||||
|
||||
return ok({
|
||||
"name": target.name,
|
||||
"path": str(target.relative_to(project_dir)),
|
||||
"size": target.stat().st_size,
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/projects/<project_id>/files/<path:filepath>", methods=["DELETE"])
|
||||
def delete_project_file(project_id, filepath):
|
||||
"""Delete a file or empty directory."""
|
||||
project_dir, target, error = _resolve_file_path(project_id, filepath)
|
||||
if error:
|
||||
return error
|
||||
|
||||
if not target.exists():
|
||||
return err(404, "File not found")
|
||||
|
||||
try:
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
except Exception as e:
|
||||
return err(500, f"Failed to delete: {str(e)}")
|
||||
|
||||
return ok({"message": f"Deleted '{filepath}'"})
|
||||
|
||||
|
||||
@bp.route("/api/projects/<project_id>/files/mkdir", methods=["POST"])
|
||||
def create_project_directory_endpoint(project_id):
|
||||
"""Create a directory in the project."""
|
||||
data = request.get_json()
|
||||
if not data or "path" not in data:
|
||||
return err(400, "Missing 'path' in request body")
|
||||
|
||||
project_dir, target, error = _resolve_file_path(project_id, data["path"])
|
||||
if error:
|
||||
return error
|
||||
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
except FileExistsError:
|
||||
return err(400, "Directory already exists")
|
||||
except Exception as e:
|
||||
return err(500, f"Failed to create directory: {str(e)}")
|
||||
|
||||
return ok({
|
||||
"path": str(target.relative_to(project_dir)),
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/projects/<project_id>/search", methods=["POST"])
|
||||
def search_project_files(project_id):
|
||||
"""Search file contents (grep-like)."""
|
||||
data = request.get_json()
|
||||
if not data or "query" not in data:
|
||||
return err(400, "Missing 'query' in request body")
|
||||
|
||||
query = data["query"]
|
||||
subdir = data.get("path", "")
|
||||
max_results = min(data.get("max_results", 50), 200)
|
||||
case_sensitive = data.get("case_sensitive", False)
|
||||
|
||||
project = Project.query.get(project_id)
|
||||
if not project:
|
||||
return err(404, "Project not found")
|
||||
|
||||
project_dir = get_project_path(project.id, project.path)
|
||||
target_dir = project_dir / subdir if subdir else project_dir
|
||||
|
||||
try:
|
||||
target_dir = target_dir.resolve()
|
||||
target_dir.relative_to(project_dir.resolve())
|
||||
except ValueError:
|
||||
return err(403, "Invalid path: outside project directory")
|
||||
|
||||
if not target_dir.exists():
|
||||
return err(404, "Directory not found")
|
||||
|
||||
import re
|
||||
flags = 0 if case_sensitive else re.IGNORECASE
|
||||
try:
|
||||
pattern = re.compile(re.escape(query), flags)
|
||||
except re.error:
|
||||
return err(400, "Invalid search pattern")
|
||||
|
||||
results = []
|
||||
try:
|
||||
for file_path in target_dir.rglob("*"):
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
if file_path.name.startswith("."):
|
||||
continue
|
||||
# Skip binary files by extension
|
||||
if file_path.suffix.lower() not in TEXT_EXTENSIONS and file_path.suffix != "":
|
||||
continue
|
||||
|
||||
try:
|
||||
text = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
matches = []
|
||||
for i, line in enumerate(text.splitlines(), 1):
|
||||
if pattern.search(line):
|
||||
matches.append({"line": i, "content": line})
|
||||
if sum(len(m.get("content", "")) for m in matches) > 10000:
|
||||
break
|
||||
if matches:
|
||||
results.append({
|
||||
"path": str(file_path.relative_to(project_dir)),
|
||||
"matches": matches,
|
||||
})
|
||||
except Exception as e:
|
||||
return err(500, f"Search failed: {str(e)}")
|
||||
|
||||
return ok({
|
||||
"query": query,
|
||||
"results": results,
|
||||
"total_matches": len(results),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -200,8 +200,8 @@ classDiagram
|
|||
}
|
||||
|
||||
class GLMClient {
|
||||
-str api_url
|
||||
-str api_key
|
||||
-dict model_config
|
||||
+_get_credentials(model) (api_url, api_key)
|
||||
+call(model, messages, kwargs) Response
|
||||
}
|
||||
|
||||
|
|
@ -381,7 +381,12 @@ def process_tool_calls(self, tool_calls, context=None):
|
|||
| `PUT` | `/api/projects/:id` | 更新项目 |
|
||||
| `DELETE` | `/api/projects/:id` | 删除项目 |
|
||||
| `POST` | `/api/projects/upload` | 上传文件夹作为项目 |
|
||||
| `GET` | `/api/projects/:id/files` | 列出项目文件 |
|
||||
| `GET` | `/api/projects/:id/files` | 列出项目文件(支持 `?path=subdir` 子目录) |
|
||||
| `GET` | `/api/projects/:id/files/:filepath` | 读取文件内容(文本文件,最大 5 MB) |
|
||||
| `PUT` | `/api/projects/:id/files/:filepath` | 创建或覆盖文件(Body: `{"content": "..."}`) |
|
||||
| `DELETE` | `/api/projects/:id/files/:filepath` | 删除文件或目录 |
|
||||
| `POST` | `/api/projects/:id/files/mkdir` | 创建目录(Body: `{"path": "src/utils"}`) |
|
||||
| `POST` | `/api/projects/:id/search` | 搜索文件内容(Body: `{"query": "...", "path": "", "max_results": 50, "case_sensitive": false}`) |
|
||||
|
||||
### 其他
|
||||
|
||||
|
|
@ -723,9 +728,23 @@ if name.startswith("file_") and context and "project_id" in context:
|
|||
backend_port: 3000
|
||||
frontend_port: 4000
|
||||
|
||||
# LLM API
|
||||
api_key: your-api-key
|
||||
api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
|
||||
# LLM API(全局默认值,每个 model 可单独覆盖)
|
||||
default_api_key: your-api-key
|
||||
default_api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
|
||||
|
||||
# 可用模型列表
|
||||
models:
|
||||
- id: glm-5
|
||||
name: GLM-5
|
||||
# api_key: ... # 可选,不指定则用 default_api_key
|
||||
# api_url: ... # 可选,不指定则用 default_api_url
|
||||
- id: glm-5-turbo
|
||||
name: GLM-5 Turbo
|
||||
api_key: another-key # 该模型使用独立凭证
|
||||
api_url: https://other.api.com/chat/completions
|
||||
|
||||
# 默认模型
|
||||
default_model: glm-5
|
||||
|
||||
# 工作区根目录
|
||||
workspace_root: ./workspaces
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
"name": "nano-claw",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.10.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.40",
|
||||
"marked": "^15.0.0",
|
||||
"marked": "^15.0.12",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.10.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.40",
|
||||
"marked": "^15.0.0",
|
||||
"marked": "^15.0.12",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,15 +5,39 @@
|
|||
:current-id="currentConvId"
|
||||
:loading="loadingConvs"
|
||||
:has-more="hasMoreConvs"
|
||||
:current-project="currentProject"
|
||||
@select="selectConversation"
|
||||
@create="createConversation"
|
||||
@delete="deleteConversation"
|
||||
@load-more="loadMoreConversations"
|
||||
@select-project="selectProject"
|
||||
@create-project="showCreateModal = true"
|
||||
@browse-project="browseProject"
|
||||
@create-in-project="createConversationInProject"
|
||||
@toggle-settings="togglePanel('settings')"
|
||||
@toggle-stats="togglePanel('stats')"
|
||||
/>
|
||||
|
||||
<!-- File Explorer (replaces ChatView when active) -->
|
||||
<div v-if="showFileExplorer" class="file-explorer-wrap main-panel">
|
||||
<div class="explorer-topbar">
|
||||
<div class="topbar-label">浏览文件</div>
|
||||
<div v-if="currentProject" class="topbar-project-name">{{ currentProject.name }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentProject" class="explorer-body">
|
||||
<FileExplorer
|
||||
:project-id="currentProject.id"
|
||||
:project-name="currentProject.name"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="explorer-body explorer-empty">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" style="color: var(--text-tertiary); opacity: 0.5;">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<p>当前对话未关联项目</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatView
|
||||
v-else
|
||||
:conversation="currentConv"
|
||||
:messages="messages"
|
||||
:streaming="streaming"
|
||||
|
|
@ -53,6 +77,37 @@
|
|||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Create project modal -->
|
||||
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
|
||||
<div class="create-modal">
|
||||
<div class="modal-header">
|
||||
<h3>创建项目</h3>
|
||||
<button class="btn-icon" @click="showCreateModal = false">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>项目名称</label>
|
||||
<input v-model="newProjectName" type="text" placeholder="输入项目名称" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>描述(可选)</label>
|
||||
<textarea v-model="newProjectDesc" placeholder="输入项目描述" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="showCreateModal = false">取消</button>
|
||||
<button class="btn-primary" @click="createProject" :disabled="!newProjectName.trim() || creatingProject">
|
||||
{{ creatingProject ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -60,10 +115,11 @@
|
|||
import { ref, shallowRef, computed, onMounted, defineAsyncComponent } from 'vue'
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
import ChatView from './components/ChatView.vue'
|
||||
import FileExplorer from './components/FileExplorer.vue'
|
||||
|
||||
const SettingsPanel = defineAsyncComponent(() => import('./components/SettingsPanel.vue'))
|
||||
const StatsPanel = defineAsyncComponent(() => import('./components/StatsPanel.vue'))
|
||||
import { conversationApi, messageApi } from './api'
|
||||
import { conversationApi, messageApi, projectApi } from './api'
|
||||
|
||||
// -- Conversations state --
|
||||
const conversations = shallowRef([])
|
||||
|
|
@ -88,28 +144,14 @@ const streamProcessSteps = shallowRef([])
|
|||
// 保存每个对话的流式状态
|
||||
const streamStates = new Map()
|
||||
|
||||
// 重置当前流式状态(用于 sendMessage / regenerateMessage / onError)
|
||||
function resetStreamState() {
|
||||
streaming.value = false
|
||||
function setStreamState(isActive) {
|
||||
streaming.value = isActive
|
||||
streamContent.value = ''
|
||||
streamThinking.value = ''
|
||||
streamToolCalls.value = []
|
||||
streamProcessSteps.value = []
|
||||
}
|
||||
|
||||
// 初始化流式状态(用于 sendMessage / regenerateMessage 开始时)
|
||||
function initStreamState() {
|
||||
streaming.value = true
|
||||
streamContent.value = ''
|
||||
streamThinking.value = ''
|
||||
streamToolCalls.value = []
|
||||
streamProcessSteps.value = []
|
||||
}
|
||||
|
||||
// 辅助:更新当前对话或缓存的流式字段
|
||||
// field: streamStates 中保存的字段名
|
||||
// ref: 当前激活对话对应的 Vue ref
|
||||
// valueOrUpdater: 静态值或 (current) => newValue
|
||||
function updateStreamField(convId, field, ref, valueOrUpdater) {
|
||||
const isCurrent = currentConvId.value === convId
|
||||
const current = isCurrent ? ref.value : (streamStates.get(convId) || {})[field]
|
||||
|
|
@ -125,8 +167,13 @@ function updateStreamField(convId, field, ref, valueOrUpdater) {
|
|||
// -- UI state --
|
||||
const showSettings = ref(false)
|
||||
const showStats = ref(false)
|
||||
const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false') // 默认开启
|
||||
const currentProject = ref(null) // Current selected project
|
||||
const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false')
|
||||
const currentProject = ref(null)
|
||||
const showFileExplorer = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const newProjectName = ref('')
|
||||
const newProjectDesc = ref('')
|
||||
const creatingProject = ref(false)
|
||||
|
||||
function togglePanel(panel) {
|
||||
if (panel === 'settings') {
|
||||
|
|
@ -142,13 +189,12 @@ const currentConv = computed(() =>
|
|||
conversations.value.find(c => c.id === currentConvId.value) || null
|
||||
)
|
||||
|
||||
// -- Load conversations --
|
||||
// -- Load conversations (all, no project filter) --
|
||||
async function loadConversations(reset = true) {
|
||||
if (loadingConvs.value) return
|
||||
loadingConvs.value = true
|
||||
try {
|
||||
const projectId = currentProject.value?.id || null
|
||||
const res = await conversationApi.list(reset ? null : nextConvCursor.value, 20, projectId)
|
||||
const res = await conversationApi.list(reset ? null : nextConvCursor.value, 20)
|
||||
if (reset) {
|
||||
conversations.value = res.data.items
|
||||
} else {
|
||||
|
|
@ -167,12 +213,18 @@ function loadMoreConversations() {
|
|||
if (hasMoreConvs.value) loadConversations(false)
|
||||
}
|
||||
|
||||
// -- Create conversation --
|
||||
async function createConversation() {
|
||||
// -- Create conversation in specific project --
|
||||
async function createConversationInProject(project) {
|
||||
showFileExplorer.value = false
|
||||
if (project.id) {
|
||||
currentProject.value = { id: project.id, name: project.name }
|
||||
} else {
|
||||
currentProject.value = null
|
||||
}
|
||||
try {
|
||||
const res = await conversationApi.create({
|
||||
title: '新对话',
|
||||
project_id: currentProject.value?.id || null,
|
||||
project_id: project.id || null,
|
||||
})
|
||||
conversations.value = [res.data, ...conversations.value]
|
||||
await selectConversation(res.data.id)
|
||||
|
|
@ -181,9 +233,22 @@ async function createConversation() {
|
|||
}
|
||||
}
|
||||
|
||||
// -- Select conversation --
|
||||
|
||||
// -- Select conversation (auto-set project context) --
|
||||
async function selectConversation(id) {
|
||||
// 保存当前对话的流式状态和消息列表(如果有)
|
||||
showFileExplorer.value = false
|
||||
|
||||
// Auto-set project context based on conversation
|
||||
const conv = conversations.value.find(c => c.id === id)
|
||||
if (conv?.project_id) {
|
||||
if (!currentProject.value || currentProject.value.id !== conv.project_id) {
|
||||
currentProject.value = { id: conv.project_id, name: conv.project_name || '' }
|
||||
}
|
||||
} else {
|
||||
currentProject.value = null
|
||||
}
|
||||
|
||||
// Save current streaming state
|
||||
if (currentConvId.value && streaming.value) {
|
||||
streamStates.set(currentConvId.value, {
|
||||
streaming: true,
|
||||
|
|
@ -191,7 +256,7 @@ async function selectConversation(id) {
|
|||
streamThinking: streamThinking.value,
|
||||
streamToolCalls: [...streamToolCalls.value],
|
||||
streamProcessSteps: [...streamProcessSteps.value],
|
||||
messages: [...messages.value], // 保存消息列表(包括临时用户消息)
|
||||
messages: [...messages.value],
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -199,7 +264,7 @@ async function selectConversation(id) {
|
|||
nextMsgCursor.value = null
|
||||
hasMoreMessages.value = false
|
||||
|
||||
// 恢复新对话的流式状态
|
||||
// Restore streaming state for new conversation
|
||||
const savedState = streamStates.get(id)
|
||||
if (savedState && savedState.streaming) {
|
||||
streaming.value = true
|
||||
|
|
@ -207,13 +272,12 @@ async function selectConversation(id) {
|
|||
streamThinking.value = savedState.streamThinking
|
||||
streamToolCalls.value = savedState.streamToolCalls
|
||||
streamProcessSteps.value = savedState.streamProcessSteps
|
||||
messages.value = savedState.messages || [] // 恢复消息列表
|
||||
messages.value = savedState.messages || []
|
||||
} else {
|
||||
resetStreamState()
|
||||
setStreamState(false)
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
// 如果不是正在流式传输,从服务器加载消息
|
||||
if (!streaming.value) {
|
||||
await loadMessages(true)
|
||||
}
|
||||
|
|
@ -226,7 +290,6 @@ async function loadMessages(reset = true) {
|
|||
try {
|
||||
const res = await messageApi.list(currentConvId.value, reset ? null : nextMsgCursor.value)
|
||||
if (reset) {
|
||||
// Filter out tool messages (they're merged into assistant messages)
|
||||
messages.value = res.data.items.filter(m => m.role !== 'tool')
|
||||
} else {
|
||||
messages.value = [...res.data.items.filter(m => m.role !== 'tool'), ...messages.value]
|
||||
|
|
@ -295,7 +358,7 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
|||
token_count: data.token_count,
|
||||
created_at: new Date().toISOString(),
|
||||
}]
|
||||
resetStreamState()
|
||||
setStreamState(false)
|
||||
|
||||
if (updateConvList) {
|
||||
const idx = conversations.value.findIndex(c => c.id === convId)
|
||||
|
|
@ -329,7 +392,7 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
|||
onError(msg) {
|
||||
streamStates.delete(convId)
|
||||
if (currentConvId.value === convId) {
|
||||
resetStreamState()
|
||||
setStreamState(false)
|
||||
console.error('Stream error:', msg)
|
||||
}
|
||||
},
|
||||
|
|
@ -355,7 +418,7 @@ async function sendMessage(data) {
|
|||
}
|
||||
messages.value = [...messages.value, userMsg]
|
||||
|
||||
initStreamState()
|
||||
setStreamState(true)
|
||||
|
||||
messageApi.send(convId, { text, attachments, projectId: currentProject.value?.id }, {
|
||||
toolsEnabled: toolsEnabled.value,
|
||||
|
|
@ -384,7 +447,7 @@ async function regenerateMessage(msgId) {
|
|||
|
||||
messages.value = messages.value.slice(0, msgIndex)
|
||||
|
||||
initStreamState()
|
||||
setStreamState(true)
|
||||
|
||||
messageApi.regenerate(convId, msgId, {
|
||||
toolsEnabled: toolsEnabled.value,
|
||||
|
|
@ -404,6 +467,7 @@ async function deleteConversation(id) {
|
|||
await selectConversation(currentConvId.value)
|
||||
} else {
|
||||
messages.value = []
|
||||
currentProject.value = null
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -431,12 +495,30 @@ function updateToolsEnabled(val) {
|
|||
localStorage.setItem('tools_enabled', String(val))
|
||||
}
|
||||
|
||||
// -- Select project --
|
||||
function selectProject(project) {
|
||||
// -- Browse project files --
|
||||
function browseProject(project) {
|
||||
currentProject.value = project
|
||||
// Reload conversations filtered by the selected project
|
||||
nextConvCursor.value = null
|
||||
loadConversations(true)
|
||||
showFileExplorer.value = true
|
||||
}
|
||||
|
||||
// -- Create project --
|
||||
async function createProject() {
|
||||
if (!newProjectName.value.trim()) return
|
||||
creatingProject.value = true
|
||||
try {
|
||||
await projectApi.create({
|
||||
user_id: 1,
|
||||
name: newProjectName.value.trim(),
|
||||
description: newProjectDesc.value.trim(),
|
||||
})
|
||||
showCreateModal.value = false
|
||||
newProjectName.value = ''
|
||||
newProjectDesc.value = ''
|
||||
} catch (e) {
|
||||
console.error('Failed to create project:', e)
|
||||
} finally {
|
||||
creatingProject.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// -- Init --
|
||||
|
|
@ -451,6 +533,65 @@ onMounted(() => {
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.file-explorer-wrap {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.explorer-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.topbar-project-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.explorer-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.explorer-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
|
|
@ -464,4 +605,114 @@ onMounted(() => {
|
|||
border: 1px solid var(--border-medium);
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* -- Create project modal -- */
|
||||
.create-modal {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-medium);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 440px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 8px 16px;
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -233,4 +233,44 @@ export const projectApi = {
|
|||
return json
|
||||
})
|
||||
},
|
||||
|
||||
listFiles(projectId, path = '') {
|
||||
return request(`/projects/${projectId}/files${buildQueryParams({ path })}`)
|
||||
},
|
||||
|
||||
readFile(projectId, filepath) {
|
||||
return request(`/projects/${projectId}/files/${filepath}`)
|
||||
},
|
||||
|
||||
readFileRaw(projectId, filepath) {
|
||||
return fetch(`${BASE}/projects/${projectId}/files/${filepath}`).then(res => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res
|
||||
})
|
||||
},
|
||||
|
||||
writeFile(projectId, filepath, content) {
|
||||
return request(`/projects/${projectId}/files/${filepath}`, {
|
||||
method: 'PUT',
|
||||
body: { content },
|
||||
})
|
||||
},
|
||||
|
||||
deleteFile(projectId, filepath) {
|
||||
return request(`/projects/${projectId}/files/${filepath}`, { method: 'DELETE' })
|
||||
},
|
||||
|
||||
mkdir(projectId, dirPath) {
|
||||
return request(`/projects/${projectId}/files/mkdir`, {
|
||||
method: 'POST',
|
||||
body: { path: dirPath },
|
||||
})
|
||||
},
|
||||
|
||||
search(projectId, query, options = {}) {
|
||||
return request(`/projects/${projectId}/search`, {
|
||||
method: 'POST',
|
||||
body: { query, ...options },
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="chat-view">
|
||||
<div class="chat-view main-panel">
|
||||
<div v-if="!conversation" class="welcome">
|
||||
<div class="welcome-icon"><svg viewBox="0 0 64 64" width="36" height="36"><rect width="64" height="64" rx="14" fill="url(#favBg)"/><defs><linearGradient id="favBg" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#2563eb"/><stop offset="100%" stop-color="#60a5fa"/></linearGradient></defs><text x="32" y="40" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,sans-serif" font-size="18" font-weight="800" fill="#fff" letter-spacing="-0.5">claw</text></svg></div>
|
||||
<h1>Chat</h1>
|
||||
|
|
@ -13,21 +13,6 @@
|
|||
<span class="model-badge">{{ formatModelName(conversation.model) }}</span>
|
||||
<span v-if="conversation.thinking_enabled" class="thinking-badge">思考</span>
|
||||
</div>
|
||||
<div class="chat-actions">
|
||||
<button class="btn-icon" @click="$emit('toggleStats')" title="使用统计">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 20V10"/>
|
||||
<path d="M12 20V4"/>
|
||||
<path d="M6 20v-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" @click="$emit('toggleSettings')" title="设置">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="scrollContainer" class="messages-container">
|
||||
|
|
@ -202,12 +187,8 @@ watch(() => props.conversation?.id, () => {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 80%, transparent);
|
||||
backdrop-filter: blur(30px);
|
||||
-webkit-backdrop-filter: blur(30px);
|
||||
border-left: 1px solid var(--border-light);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
|
|
@ -288,15 +269,8 @@ watch(() => props.conversation?.id, () => {
|
|||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-actions .btn-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
|
||||
.messages-container {
|
||||
flex: 1 1 auto;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,637 @@
|
|||
<template>
|
||||
<div class="file-explorer">
|
||||
<!-- File tree sidebar -->
|
||||
<div class="explorer-sidebar">
|
||||
<div class="explorer-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<span class="explorer-title">{{ projectName }}</span>
|
||||
<div class="explorer-actions">
|
||||
<button class="btn-icon-sm" @click="createNewFile" title="新建文件">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon-sm" @click="createNewFolder" title="新建文件夹">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingTree" class="explorer-loading">
|
||||
<svg class="spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div v-else-if="treeItems.length === 0" class="explorer-empty">
|
||||
空项目
|
||||
</div>
|
||||
|
||||
<div v-else class="tree-container">
|
||||
<FileTreeItem
|
||||
v-for="item in treeItems"
|
||||
:key="item.path"
|
||||
:item="item"
|
||||
:active-path="activeFile"
|
||||
:depth="0"
|
||||
:project-id="projectId"
|
||||
@select="openFile"
|
||||
@refresh="loadTree"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File viewer panel -->
|
||||
<div v-if="activeFile" class="file-viewer">
|
||||
<div class="viewer-header">
|
||||
<div class="viewer-breadcrumb">
|
||||
<span v-for="(seg, i) in activeFile.split('/')" :key="i" class="breadcrumb-seg">
|
||||
{{ seg }}
|
||||
<span v-if="i < activeFile.split('/').length - 1" class="breadcrumb-sep">/</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="viewer-actions">
|
||||
<button v-if="!editing" class="btn-icon-sm" @click="startEdit" title="编辑">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button v-if="editing" class="btn-icon-sm save" @click="saveFile" title="保存 (Ctrl+S)" :disabled="saving">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||
<polyline points="17 21 17 13 7 13 7 21"/>
|
||||
<polyline points="7 3 7 8 15 8"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button v-if="editing" class="btn-icon-sm" @click="cancelEdit" title="取消">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon-sm danger" @click="deleteFile" title="删除文件">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon-sm" @click="activeFile = null" title="关闭">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingFile" class="viewer-loading">
|
||||
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
</svg>
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="fileError" class="viewer-error">
|
||||
<span>{{ fileError }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Read-only viewer (code highlighted, markdown rendered) -->
|
||||
<div
|
||||
v-else-if="!editing && fileType !== 'image'"
|
||||
class="file-content-viewer md-content"
|
||||
v-html="renderedContent"
|
||||
></div>
|
||||
|
||||
<!-- Image viewer -->
|
||||
<div v-else-if="!editing && fileType === 'image'" class="image-viewer">
|
||||
<img :src="imageUrl" :alt="activeFile" />
|
||||
</div>
|
||||
|
||||
<!-- Highlighted editor -->
|
||||
<div v-else class="editor-container">
|
||||
<pre class="editor-highlight" aria-hidden="true"><code v-html="editorHighlighted"></code></pre>
|
||||
<textarea
|
||||
ref="editorRef"
|
||||
v-model="editContent"
|
||||
class="file-editor"
|
||||
spellcheck="false"
|
||||
@keydown="onEditorKeydown"
|
||||
@scroll="syncScroll"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="viewer-placeholder">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" style="color: var(--text-tertiary); opacity: 0.5;">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
<span>选择文件以预览</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { projectApi } from '../api'
|
||||
import FileTreeItem from './FileTreeItem.vue'
|
||||
import { renderMarkdown } from '../utils/markdown'
|
||||
import { highlightCode } from '../utils/highlight'
|
||||
import { normalizeFileTree } from '../utils/fileTree'
|
||||
|
||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico'])
|
||||
|
||||
// Treat all text/code files as markdown (code blocks in ``` get auto-highlighted)
|
||||
// For pure code files without markdown, wrap in code fence
|
||||
|
||||
const props = defineProps({
|
||||
projectId: { type: String, required: true },
|
||||
projectName: { type: String, default: '' },
|
||||
})
|
||||
|
||||
// -- Tree state --
|
||||
const treeItems = ref([])
|
||||
const loadingTree = ref(false)
|
||||
|
||||
// -- Viewer state --
|
||||
const activeFile = ref(null)
|
||||
const fileContent = ref('')
|
||||
const fileError = ref('')
|
||||
const loadingFile = ref(false)
|
||||
const editing = ref(false)
|
||||
const editContent = ref('')
|
||||
const saving = ref(false)
|
||||
const editorRef = ref(null)
|
||||
const imageUrl = ref('')
|
||||
|
||||
// -- File type detection --
|
||||
const isMarkdownFile = computed(() => {
|
||||
return ['md', 'markdown', 'mdx'].includes(fileExt.value)
|
||||
})
|
||||
|
||||
const fileExt = computed(() => {
|
||||
if (!activeFile.value) return ''
|
||||
const parts = activeFile.value.split('.')
|
||||
return parts.length > 1 ? parts.pop().toLowerCase() : ''
|
||||
})
|
||||
|
||||
const fileType = computed(() => {
|
||||
if (IMAGE_EXTS.has(fileExt.value)) return 'image'
|
||||
return 'text'
|
||||
})
|
||||
|
||||
// -- Content rendering --
|
||||
const renderedContent = computed(() => {
|
||||
if (!fileContent.value) return ''
|
||||
if (isMarkdownFile.value) return renderMarkdown(fileContent.value)
|
||||
// Wrap code in fenced code block — rendered by same markdown pipeline, same CSS
|
||||
const lang = fileExt.value || ''
|
||||
return renderMarkdown('```' + lang + '\n' + fileContent.value + '\n```')
|
||||
})
|
||||
|
||||
const editorHighlighted = computed(() => {
|
||||
if (!editContent.value) return ''
|
||||
const lang = fileExt.value || ''
|
||||
return highlightCode(editContent.value, lang)
|
||||
})
|
||||
|
||||
function syncScroll() {
|
||||
const ta = editorRef.value
|
||||
const pre = ta?.previousElementSibling
|
||||
if (pre) {
|
||||
pre.scrollTop = ta.scrollTop
|
||||
pre.scrollLeft = ta.scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTree(path = '') {
|
||||
loadingTree.value = true
|
||||
try {
|
||||
const res = await projectApi.listFiles(props.projectId, path)
|
||||
treeItems.value = normalizeFileTree(res.data, { expanded: false })
|
||||
} catch (e) {
|
||||
console.error('Failed to load tree:', e)
|
||||
} finally {
|
||||
loadingTree.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openFile(filepath) {
|
||||
activeFile.value = filepath
|
||||
fileContent.value = ''
|
||||
fileError.value = ''
|
||||
editing.value = false
|
||||
imageUrl.value = ''
|
||||
loadingFile.value = true
|
||||
|
||||
const ext = filepath.split('.').pop().toLowerCase()
|
||||
if (IMAGE_EXTS.has(ext)) {
|
||||
try {
|
||||
const res = await projectApi.readFileRaw(props.projectId, filepath)
|
||||
const blob = await res.blob()
|
||||
imageUrl.value = URL.createObjectURL(blob)
|
||||
} catch (e) {
|
||||
fileError.value = '加载图片失败: ' + (e.message || '')
|
||||
} finally {
|
||||
loadingFile.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await projectApi.readFile(props.projectId, filepath)
|
||||
fileContent.value = res.data.content
|
||||
} catch (e) {
|
||||
fileError.value = e.message || '加载文件失败'
|
||||
} finally {
|
||||
loadingFile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
editContent.value = fileContent.value
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editing.value = false
|
||||
editContent.value = ''
|
||||
}
|
||||
|
||||
async function saveFile() {
|
||||
if (!activeFile.value || saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await projectApi.writeFile(props.projectId, activeFile.value, editContent.value)
|
||||
fileContent.value = editContent.value
|
||||
editing.value = false
|
||||
} catch (e) {
|
||||
alert('保存失败: ' + e.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function deleteFile() {
|
||||
if (!activeFile.value) return
|
||||
const name = activeFile.value.split('/').pop()
|
||||
if (!confirm(`确定要删除 ${name} 吗?`)) return
|
||||
|
||||
projectApi.deleteFile(props.projectId, activeFile.value).then(() => {
|
||||
activeFile.value = null
|
||||
fileContent.value = ''
|
||||
loadTree()
|
||||
}).catch(e => {
|
||||
alert('删除失败: ' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
async function createNewFile() {
|
||||
const name = prompt('文件名(例如 utils.py)')
|
||||
if (!name?.trim()) return
|
||||
const path = name.trim()
|
||||
try {
|
||||
await projectApi.writeFile(props.projectId, path, '')
|
||||
await loadTree()
|
||||
openFile(path)
|
||||
startEdit()
|
||||
} catch (e) {
|
||||
alert('创建失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewFolder() {
|
||||
const name = prompt('文件夹名称')
|
||||
if (!name?.trim()) return
|
||||
try {
|
||||
await projectApi.mkdir(props.projectId, name.trim())
|
||||
await loadTree()
|
||||
} catch (e) {
|
||||
alert('创建失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function onEditorKeydown(e) {
|
||||
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
saveFile()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
cancelEdit()
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+S global shortcut
|
||||
function onGlobalKeydown(e) {
|
||||
if (e.key === 's' && (e.ctrlKey || e.metaKey) && editing.value) {
|
||||
e.preventDefault()
|
||||
saveFile()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.projectId, () => {
|
||||
activeFile.value = null
|
||||
fileContent.value = ''
|
||||
imageUrl.value = ''
|
||||
editing.value = false
|
||||
loadTree()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadTree()
|
||||
document.addEventListener('keydown', onGlobalKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onGlobalKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-explorer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* -- Sidebar -- */
|
||||
.explorer-sidebar {
|
||||
width: 20%;
|
||||
min-width: 140px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--border-light);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.explorer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.explorer-header svg:first-child {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explorer-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.explorer-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon-sm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-icon-sm:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-icon-sm.save:hover {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.btn-icon-sm.danger:hover {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.tree-container::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.tree-container::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.explorer-loading,
|
||||
.explorer-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* -- Viewer -- */
|
||||
.file-viewer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background: var(--bg-secondary);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.viewer-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breadcrumb-seg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.breadcrumb-sep {
|
||||
margin: 0 2px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.viewer-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.viewer-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.viewer-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: var(--danger-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.file-editor {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 12px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-code);
|
||||
tab-size: 4;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.file-editor::-webkit-scrollbar,
|
||||
.file-content-viewer::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.file-editor::-webkit-scrollbar-thumb,
|
||||
.file-content-viewer::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.viewer-placeholder {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* -- Editor (highlighted textarea overlay) -- */
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin: 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-code);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-container pre.editor-highlight {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
overflow: auto;
|
||||
pointer-events: none;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-code);
|
||||
white-space: pre;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.editor-container .file-editor {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
border: none;
|
||||
color: transparent;
|
||||
caret-color: var(--text-primary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.file-content-viewer {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px 24px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* -- Image viewer -- */
|
||||
.image-viewer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: var(--bg-code);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.image-viewer img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
<template>
|
||||
<div class="tree-item" :class="{ active: isActive }" @click="onClick">
|
||||
<span class="tree-indent" :style="{ width: depth * 16 + 'px' }"></span>
|
||||
|
||||
<span v-if="item.type === 'dir'" class="tree-arrow" :class="{ open: expanded }">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span v-else class="tree-arrow-placeholder"></span>
|
||||
|
||||
<!-- File icon -->
|
||||
<span v-if="item.type === 'file'" class="tree-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" :stroke="iconColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span v-else class="tree-icon folder-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<span class="tree-name" :title="item.name">{{ item.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Children -->
|
||||
<div v-if="item.type === 'dir' && expanded" class="tree-children">
|
||||
<div v-if="loading" class="tree-loading">
|
||||
<svg class="spinner" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
</svg>
|
||||
</div>
|
||||
<FileTreeItem
|
||||
v-for="child in item.children"
|
||||
:key="child.path"
|
||||
:item="child"
|
||||
:active-path="activePath"
|
||||
:depth="depth + 1"
|
||||
:project-id="projectId"
|
||||
@select="$emit('select', $event)"
|
||||
@refresh="$emit('refresh')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { projectApi } from '../api'
|
||||
import { normalizeFileTree } from '../utils/fileTree'
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true },
|
||||
activePath: { type: String, default: null },
|
||||
depth: { type: Number, default: 0 },
|
||||
projectId: { type: String, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'refresh'])
|
||||
|
||||
const expanded = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const isActive = computed(() => props.activePath === props.item.path)
|
||||
|
||||
const iconColor = computed(() => {
|
||||
const ext = props.item.extension?.toLowerCase() || ''
|
||||
const colorMap = {
|
||||
'.py': '#3572A5', '.js': '#f1e05a', '.ts': '#3178c6', '.vue': '#41b883',
|
||||
'.html': '#e34c26', '.css': '#563d7c', '.json': '#292929', '.md': '#083fa1',
|
||||
'.yml': '#cb171e', '.yaml': '#cb171e', '.toml': '#9c4221', '.sql': '#e38c00',
|
||||
'.sh': '#89e051', '.java': '#b07219', '.go': '#00ADD8', '.rs': '#dea584',
|
||||
}
|
||||
return colorMap[ext] || 'var(--text-tertiary)'
|
||||
})
|
||||
|
||||
async function onClick() {
|
||||
if (props.item.type === 'dir') {
|
||||
expanded.value = !expanded.value
|
||||
if (expanded.value && props.item.children.length === 0) {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await projectApi.listFiles(props.projectId, props.item.path)
|
||||
props.item.children = normalizeFileTree(res.data)
|
||||
} catch (e) {
|
||||
console.error('Failed to load dir:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
emit('select', props.item.path)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px 3px 0;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.1s;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tree-item.active {
|
||||
background: var(--accent-primary-light);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tree-indent {
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tree-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.tree-arrow.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tree-arrow svg {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.tree-arrow-placeholder {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-icon svg {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.tree-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
/* no extra indent, tree-item handles it via tree-indent */
|
||||
}
|
||||
|
||||
.tree-loading {
|
||||
padding: 4px 0 4px 40px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -212,7 +212,7 @@ const processItems = computed(() => {
|
|||
})
|
||||
|
||||
// 增强 processBlock 内代码块
|
||||
const { enhance, debouncedEnhance } = useCodeEnhancement(processRef, processItems, { deep: true })
|
||||
const { debouncedEnhance } = useCodeEnhancement(processRef, processItems, { deep: true })
|
||||
|
||||
// 流式时使用节流的代码块增强,减少 DOM 操作
|
||||
watch(() => props.streamingContent?.length, () => {
|
||||
|
|
|
|||
|
|
@ -1,432 +0,0 @@
|
|||
<template>
|
||||
<div class="project-manager">
|
||||
<div class="project-header">
|
||||
<h3>项目管理</h3>
|
||||
<button class="btn-icon" @click="showCreateModal = true" title="创建项目">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" @click="showUploadModal = true" title="上传文件夹">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17,8 12,3 7,8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="project-list">
|
||||
<div v-if="loading" class="loading">加载中...</div>
|
||||
|
||||
<div v-else-if="projects.length === 0" class="empty">
|
||||
<p>暂无项目</p>
|
||||
<p class="hint">创建项目或上传文件夹开始使用</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
class="project-item"
|
||||
:class="{ active: currentProject?.id === project.id }"
|
||||
@click="$emit('select', project)"
|
||||
>
|
||||
<div class="project-info">
|
||||
<div class="project-name">{{ project.name }}</div>
|
||||
<div class="project-meta">
|
||||
<span>{{ project.conversation_count || 0 }} 个对话</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon danger" @click.stop="confirmDelete(project)" title="删除项目">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3,6 5,6 21,6"></polyline>
|
||||
<path d="M19,6v14a2,2,0,0,1-2,2H7a2,2,0,0,1-2-2V6m3,0V4a2,2,0,0,1,2-2h4a2,2,0,0,1,2,2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建项目模态框 -->
|
||||
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>创建项目</h3>
|
||||
<CloseButton @click="showCreateModal = false" />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>项目名称</label>
|
||||
<input v-model="newProject.name" type="text" placeholder="输入项目名称" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>描述(可选)</label>
|
||||
<textarea v-model="newProject.description" placeholder="输入项目描述" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="showCreateModal = false">取消</button>
|
||||
<button class="btn-primary" @click="createProject" :disabled="!newProject.name.trim() || creating">
|
||||
{{ creating ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传文件夹模态框 -->
|
||||
<div v-if="showUploadModal" class="modal-overlay" @click.self="showUploadModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>上传文件夹</h3>
|
||||
<CloseButton @click="closeUploadModal" />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>选择文件夹</label>
|
||||
<div class="upload-drop-zone" :class="{ 'has-files': selectedFiles.length > 0 }" @click="triggerFolderInput">
|
||||
<template v-if="selectedFiles.length === 0">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--text-tertiary)">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<span>点击选择文件夹</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--success-color)" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
<span>{{ folderName }} <small>({{ selectedFiles.length }} 个文件)</small></span>
|
||||
</template>
|
||||
</div>
|
||||
<input ref="folderInput" type="file" webkitdirectory directory multiple style="display:none" @change="onFolderSelected" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>项目名称</label>
|
||||
<input v-model="uploadData.name" type="text" placeholder="留空则使用文件夹名称" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>描述(可选)</label>
|
||||
<textarea v-model="uploadData.description" placeholder="输入项目描述" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="closeUploadModal">取消</button>
|
||||
<button class="btn-primary" @click="uploadFolder" :disabled="selectedFiles.length === 0 || uploading">
|
||||
{{ uploading ? '上传中...' : '上传' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认模态框 -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>确认删除</h3>
|
||||
<CloseButton @click="showDeleteModal = false" />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>确定要删除项目 <strong>{{ projectToDelete?.name }}</strong> 吗?</p>
|
||||
<p class="warning">这将同时删除项目中的所有文件和对话记录,此操作不可恢复。</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="showDeleteModal = false">取消</button>
|
||||
<button class="btn-danger" @click="deleteProject" :disabled="deleting">
|
||||
{{ deleting ? '删除中...' : '删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { projectApi } from '../api'
|
||||
import CloseButton from './CloseButton.vue'
|
||||
|
||||
const props = defineProps({
|
||||
currentProject: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'created', 'deleted'])
|
||||
|
||||
const projects = ref([])
|
||||
const loading = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const showUploadModal = ref(false)
|
||||
const showDeleteModal = ref(false)
|
||||
const creating = ref(false)
|
||||
const uploading = ref(false)
|
||||
const deleting = ref(false)
|
||||
const projectToDelete = ref(null)
|
||||
|
||||
const newProject = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const uploadData = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const selectedFiles = ref([])
|
||||
const folderName = ref('')
|
||||
const folderInput = ref(null)
|
||||
|
||||
function triggerFolderInput() {
|
||||
folderInput.value?.click()
|
||||
}
|
||||
|
||||
function onFolderSelected(e) {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length === 0) return
|
||||
|
||||
// 提取文件夹名称(所有文件的公共父目录名)
|
||||
const relativePaths = files.map(f => f.webkitRelativePath)
|
||||
folderName.value = relativePaths[0].split('/')[0]
|
||||
selectedFiles.value = files
|
||||
|
||||
// 自动填入项目名(如未填写)
|
||||
if (!uploadData.value.name.trim()) {
|
||||
uploadData.value.name = folderName.value
|
||||
}
|
||||
}
|
||||
|
||||
function closeUploadModal() {
|
||||
showUploadModal.value = false
|
||||
uploadData.value = { name: '', description: '' }
|
||||
selectedFiles.value = []
|
||||
folderName.value = ''
|
||||
if (folderInput.value) folderInput.value.value = ''
|
||||
}
|
||||
|
||||
// 固定用户ID(实际应用中应从登录状态获取)
|
||||
const userId = 1
|
||||
|
||||
async function loadProjects() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await projectApi.list(userId)
|
||||
projects.value = res.data.projects || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load projects:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
if (!newProject.value.name.trim()) return
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await projectApi.create({
|
||||
user_id: userId,
|
||||
name: newProject.value.name.trim(),
|
||||
description: newProject.value.description.trim(),
|
||||
})
|
||||
projects.value.unshift(res.data)
|
||||
showCreateModal.value = false
|
||||
newProject.value = { name: '', description: '' }
|
||||
emit('created', res.data)
|
||||
} catch (e) {
|
||||
console.error('Failed to create project:', e)
|
||||
alert('创建项目失败: ' + e.message)
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFolder() {
|
||||
if (selectedFiles.value.length === 0) return
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
const res = await projectApi.uploadFolder({
|
||||
user_id: userId,
|
||||
name: uploadData.value.name.trim() || folderName.value,
|
||||
description: uploadData.value.description.trim(),
|
||||
files: selectedFiles.value,
|
||||
})
|
||||
projects.value.unshift(res.data)
|
||||
closeUploadModal()
|
||||
emit('created', res.data)
|
||||
} catch (e) {
|
||||
console.error('Failed to upload folder:', e)
|
||||
alert('上传文件夹失败: ' + e.message)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(project) {
|
||||
projectToDelete.value = project
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteProject() {
|
||||
if (!projectToDelete.value) return
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
await projectApi.delete(projectToDelete.value.id)
|
||||
projects.value = projects.value.filter(p => p.id !== projectToDelete.value.id)
|
||||
showDeleteModal.value = false
|
||||
emit('deleted', projectToDelete.value.id)
|
||||
projectToDelete.value = null
|
||||
} catch (e) {
|
||||
console.error('Failed to delete project:', e)
|
||||
alert('删除项目失败: ' + e.message)
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProjects()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
loadProjects,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.project-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.project-header h3 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* .btn-icon is defined in global.css */
|
||||
|
||||
.btn-icon.danger:hover {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.project-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty .hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.project-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.project-item.active {
|
||||
background: var(--accent-primary-light);
|
||||
}
|
||||
|
||||
.project-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Modal z-index override for nested modals */
|
||||
.modal-overlay {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.upload-drop-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px 16px;
|
||||
border: 2px dashed var(--border-input);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upload-drop-zone:hover {
|
||||
border-color: var(--accent-primary);
|
||||
background: var(--accent-primary-light);
|
||||
}
|
||||
|
||||
.upload-drop-zone.has-files {
|
||||
border-color: var(--success-color);
|
||||
border-style: solid;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.upload-drop-zone small {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.warning {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: var(--danger-bg);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--danger-color);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,62 +1,44 @@
|
|||
<template>
|
||||
<aside class="sidebar">
|
||||
<!-- Project Selector -->
|
||||
<div class="project-section">
|
||||
<div class="project-selector" @click="showProjects = !showProjects">
|
||||
<div class="project-current">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<span>{{ currentProject?.name || '全部对话' }}</span>
|
||||
</div>
|
||||
<div class="project-selector-actions">
|
||||
<button
|
||||
v-if="currentProject"
|
||||
class="btn-clear-project"
|
||||
@click.stop="$emit('selectProject', null)"
|
||||
title="显示全部对话"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
:style="{ transform: showProjects ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Manager Panel -->
|
||||
<div v-if="showProjects" class="project-panel">
|
||||
<ProjectManager
|
||||
ref="projectManagerRef"
|
||||
:current-project="currentProject"
|
||||
@select="selectProject"
|
||||
@created="onProjectCreated"
|
||||
@deleted="onProjectDeleted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-header">
|
||||
<button class="btn-new" @click="$emit('create')">
|
||||
<button class="btn-new-project" @click="$emit('createProject')">
|
||||
<span class="icon">+</span>
|
||||
<span>新对话</span>
|
||||
<span>新建项目</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="conversation-list" @scroll="onScroll">
|
||||
<!-- Project groups -->
|
||||
<div v-for="group in groupedData.groups" :key="group.id" class="project-group">
|
||||
<div class="project-header" @click="toggleGroup(group.id)">
|
||||
<svg class="chevron" :class="{ collapsed: !expandedGroups[group.id] }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
<span class="project-name">{{ group.name }}</span>
|
||||
<span class="conv-count">{{ group.conversations.length }}</span>
|
||||
<button
|
||||
class="btn-group-action"
|
||||
title="新建对话"
|
||||
@click.stop="$emit('createInProject', { id: group.id, name: group.name })"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn-group-action"
|
||||
title="浏览文件"
|
||||
@click.stop="$emit('browseProject', { id: group.id, name: group.name })"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="expandedGroups[group.id]">
|
||||
<div
|
||||
v-for="conv in conversations"
|
||||
v-for="conv in group.conversations"
|
||||
:key="conv.id"
|
||||
class="conversation-item"
|
||||
:class="{ active: conv.id === currentId }"
|
||||
|
|
@ -66,7 +48,6 @@
|
|||
<div class="conv-title">{{ conv.title || '新对话' }}</div>
|
||||
<div class="conv-meta">
|
||||
<span>{{ conv.message_count || 0 }} 条消息 · {{ formatTime(conv.updated_at) }}</span>
|
||||
<span v-if="!currentProject && conv.project_name" class="conv-project-badge">{{ conv.project_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-delete" @click.stop="$emit('delete', conv.id)" title="删除">
|
||||
|
|
@ -76,49 +57,120 @@
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Standalone conversations -->
|
||||
<div v-if="groupedData.standalone.length > 0" class="project-group">
|
||||
<div class="project-header" @click="toggleGroup('__standalone__')">
|
||||
<svg class="chevron" :class="{ collapsed: !expandedGroups['__standalone__'] }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
<span class="conv-count">{{ groupedData.standalone.length }}</span>
|
||||
<button
|
||||
class="btn-group-action"
|
||||
title="新建对话"
|
||||
@click.stop="$emit('createInProject', { id: null, name: null })"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="btn-placeholder"></span>
|
||||
</div>
|
||||
<div v-show="expandedGroups['__standalone__']">
|
||||
<div
|
||||
v-for="conv in groupedData.standalone"
|
||||
:key="conv.id"
|
||||
class="conversation-item"
|
||||
:class="{ active: conv.id === currentId }"
|
||||
@click="$emit('select', conv.id)"
|
||||
>
|
||||
<div class="conv-info">
|
||||
<div class="conv-title">{{ conv.title || '新对话' }}</div>
|
||||
<div class="conv-meta">
|
||||
<span>{{ conv.message_count || 0 }} 条消息 · {{ formatTime(conv.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-delete" @click.stop="$emit('delete', conv.id)" title="删除">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-more">加载中...</div>
|
||||
<div v-if="!loading && conversations.length === 0" class="empty-hint">
|
||||
{{ currentProject ? '该项目暂无对话' : '暂无对话' }}
|
||||
<div v-if="!loading && conversations.length === 0" class="empty-hint">暂无对话</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button class="btn-footer" title="使用统计" @click="$emit('toggleStats')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 20V10"/>
|
||||
<path d="M12 20V4"/>
|
||||
<path d="M6 20v-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-footer" title="设置" @click="$emit('toggleSettings')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { computed, reactive } from 'vue'
|
||||
import { formatTime } from '../utils/format'
|
||||
import ProjectManager from './ProjectManager.vue'
|
||||
|
||||
const props = defineProps({
|
||||
conversations: { type: Array, required: true },
|
||||
currentId: { type: String, default: null },
|
||||
loading: { type: Boolean, default: false },
|
||||
hasMore: { type: Boolean, default: false },
|
||||
currentProject: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'create', 'delete', 'loadMore', 'selectProject'])
|
||||
const emit = defineEmits(['select', 'delete', 'loadMore', 'createProject', 'browseProject', 'createInProject', 'toggleSettings', 'toggleStats'])
|
||||
|
||||
const showProjects = ref(false)
|
||||
const projectManagerRef = ref(null)
|
||||
const expandedGroups = reactive({})
|
||||
|
||||
function selectProject(project) {
|
||||
emit('selectProject', project)
|
||||
showProjects.value = false
|
||||
}
|
||||
const groupedData = computed(() => {
|
||||
const groups = {}
|
||||
const standalone = []
|
||||
|
||||
function onProjectCreated(project) {
|
||||
// Auto-select newly created project and refresh list
|
||||
projectManagerRef.value?.loadProjects()
|
||||
emit('selectProject', project)
|
||||
}
|
||||
|
||||
function onProjectDeleted(projectId) {
|
||||
// If deleted project is current, clear selection
|
||||
if (props.currentProject?.id === projectId) {
|
||||
emit('selectProject', null)
|
||||
for (const conv of props.conversations) {
|
||||
if (conv.project_id) {
|
||||
if (!groups[conv.project_id]) {
|
||||
groups[conv.project_id] = {
|
||||
id: conv.project_id,
|
||||
name: conv.project_name || '未知项目',
|
||||
conversations: [],
|
||||
}
|
||||
}
|
||||
groups[conv.project_id].conversations.push(conv)
|
||||
} else {
|
||||
standalone.push(conv)
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of Object.keys(groups)) {
|
||||
if (!(id in expandedGroups)) expandedGroups[id] = true
|
||||
}
|
||||
if (standalone.length > 0 && !('__standalone__' in expandedGroups)) {
|
||||
expandedGroups['__standalone__'] = true
|
||||
}
|
||||
|
||||
return { groups: Object.values(groups), standalone }
|
||||
})
|
||||
|
||||
function toggleGroup(id) {
|
||||
expandedGroups[id] = !expandedGroups[id]
|
||||
}
|
||||
|
||||
function onScroll(e) {
|
||||
|
|
@ -144,86 +196,15 @@ function onScroll(e) {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-section {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.project-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.project-selector:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.project-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-current span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.project-selector-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-clear-project {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-clear-project:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.project-panel {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.btn-new {
|
||||
.btn-new-project {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: var(--accent-primary-light);
|
||||
border: 1px dashed var(--accent-primary);
|
||||
border: 1px solid var(--accent-primary);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-primary);
|
||||
font-size: 14px;
|
||||
|
|
@ -234,20 +215,22 @@ function onScroll(e) {
|
|||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-new:hover {
|
||||
.btn-new-project:hover {
|
||||
background: var(--accent-primary-medium);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.btn-new .icon {
|
||||
.btn-new-project .icon {
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.conversation-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px 16px;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.conversation-list::-webkit-scrollbar {
|
||||
|
|
@ -259,10 +242,88 @@ function onScroll(e) {
|
|||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.project-group {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.project-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
user-select: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.project-header:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chevron.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-placeholder { width: 24px; flex-shrink: 0; }
|
||||
|
||||
.conv-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-secondary);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-group-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.project-header:hover .btn-group-action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-group-action:hover {
|
||||
color: var(--accent-primary);
|
||||
background: var(--accent-primary-light);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
padding: 8px 12px 8px 36px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
|
@ -283,7 +344,7 @@ function onScroll(e) {
|
|||
}
|
||||
|
||||
.conv-title {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
|
@ -291,20 +352,12 @@ function onScroll(e) {
|
|||
}
|
||||
|
||||
.conv-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.conv-project-badge {
|
||||
font-size: 11px;
|
||||
color: var(--accent-primary);
|
||||
opacity: 0.8;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
|
|
@ -335,4 +388,33 @@ function onScroll(e) {
|
|||
font-size: 13px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-footer:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -96,6 +96,20 @@ body {
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
/* ============ Main Panel (shared by ChatView, FileExplorer, etc.) ============ */
|
||||
.main-panel {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 80%, transparent);
|
||||
backdrop-filter: blur(30px);
|
||||
-webkit-backdrop-filter: blur(30px);
|
||||
border-left: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* ============ Transitions ============ */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
|
|
@ -156,20 +170,24 @@ body {
|
|||
}
|
||||
|
||||
/* 代码块滚动条 */
|
||||
.md-content pre::-webkit-scrollbar {
|
||||
.md-content pre::-webkit-scrollbar,
|
||||
.code-block pre code::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.md-content pre::-webkit-scrollbar-track {
|
||||
.md-content pre::-webkit-scrollbar-track,
|
||||
.code-block pre code::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.md-content pre::-webkit-scrollbar-thumb {
|
||||
.md-content pre::-webkit-scrollbar-thumb,
|
||||
.code-block pre code::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.md-content pre::-webkit-scrollbar-thumb:hover {
|
||||
.md-content pre::-webkit-scrollbar-thumb:hover,
|
||||
.code-block pre code::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
|
|
@ -197,22 +215,7 @@ body {
|
|||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-block pre code::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.code-block pre code::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.code-block pre code::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.code-block pre code::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
/* (merged above with .md-content pre selectors) */
|
||||
|
||||
.code-header {
|
||||
display: flex;
|
||||
|
|
@ -681,11 +684,4 @@ input[type="range"]::-moz-range-thumb {
|
|||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23a0a0a0' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Normalize API listFiles response into a sorted tree item array.
|
||||
* Directories first, then files, alphabetically within each group.
|
||||
*/
|
||||
export function normalizeFileTree(data, { expanded = false } = {}) {
|
||||
const items = []
|
||||
for (const d of (data.directories || [])) {
|
||||
items.push({ name: d.name, path: d.path, type: 'dir', children: [], expanded })
|
||||
}
|
||||
for (const f of (data.files || [])) {
|
||||
items.push({ name: f.name, path: f.path, type: 'file', size: f.size, extension: f.extension })
|
||||
}
|
||||
items.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import hljs from 'highlight.js'
|
||||
|
||||
/**
|
||||
* Syntax-highlight code and return HTML string.
|
||||
* @param {string} code - raw source code
|
||||
* @param {string} lang - language hint (e.g. 'python', 'js')
|
||||
* @returns {string} highlighted HTML
|
||||
*/
|
||||
export function highlightCode(code, lang = '') {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
return hljs.highlight(code, { language: lang }).value
|
||||
}
|
||||
return hljs.highlightAuto(code).value
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { marked } from 'marked'
|
||||
import { markedHighlight } from 'marked-highlight'
|
||||
import katex from 'katex'
|
||||
import hljs from 'highlight.js'
|
||||
import { highlightCode } from './highlight'
|
||||
|
||||
function renderMath(text, displayMode) {
|
||||
try {
|
||||
|
|
@ -58,10 +58,7 @@ marked.use({
|
|||
...markedHighlight({
|
||||
langPrefix: 'hljs language-',
|
||||
highlight(code, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
return hljs.highlight(code, { language: lang }).value
|
||||
}
|
||||
return hljs.highlightAuto(code).value
|
||||
return highlightCode(code, lang)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
|
@ -111,10 +108,10 @@ export function enhanceCodeBlocks(container) {
|
|||
|
||||
copyBtn.addEventListener('click', () => {
|
||||
const raw = code?.textContent || ''
|
||||
navigator.clipboard.writeText(raw).then(() => {
|
||||
copyBtn.innerHTML = CHECK_SVG
|
||||
setTimeout(() => { copyBtn.innerHTML = COPY_SVG }, 1500)
|
||||
}).catch(() => {
|
||||
const copy = () => { copyBtn.innerHTML = CHECK_SVG; setTimeout(() => { copyBtn.innerHTML = COPY_SVG }, 1500) }
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(raw).then(copy)
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = raw
|
||||
ta.style.position = 'fixed'
|
||||
|
|
@ -123,9 +120,8 @@ export function enhanceCodeBlocks(container) {
|
|||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
copyBtn.innerHTML = CHECK_SVG
|
||||
setTimeout(() => { copyBtn.innerHTML = COPY_SVG }, 1500)
|
||||
})
|
||||
copy()
|
||||
}
|
||||
})
|
||||
|
||||
header.appendChild(langSpan)
|
||||
|
|
|
|||
Loading…
Reference in New Issue