refactor: 修改为项目驱动
This commit is contained in:
parent
a24eb8e24f
commit
55e28b8d3b
|
|
@ -11,7 +11,8 @@ from backend.utils.workspace import (
|
||||||
create_project_directory,
|
create_project_directory,
|
||||||
delete_project_directory,
|
delete_project_directory,
|
||||||
get_project_path,
|
get_project_path,
|
||||||
save_uploaded_files
|
save_uploaded_files,
|
||||||
|
validate_path_in_project,
|
||||||
)
|
)
|
||||||
|
|
||||||
bp = Blueprint("projects", __name__)
|
bp = Blueprint("projects", __name__)
|
||||||
|
|
@ -325,3 +326,202 @@ def list_project_files(project_id):
|
||||||
"total_files": len(files),
|
"total_files": len(files),
|
||||||
"total_dirs": len(directories)
|
"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 {
|
class GLMClient {
|
||||||
-str api_url
|
-dict model_config
|
||||||
-str api_key
|
+_get_credentials(model) (api_url, api_key)
|
||||||
+call(model, messages, kwargs) Response
|
+call(model, messages, kwargs) Response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,7 +381,12 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
| `PUT` | `/api/projects/:id` | 更新项目 |
|
| `PUT` | `/api/projects/:id` | 更新项目 |
|
||||||
| `DELETE` | `/api/projects/:id` | 删除项目 |
|
| `DELETE` | `/api/projects/:id` | 删除项目 |
|
||||||
| `POST` | `/api/projects/upload` | 上传文件夹作为项目 |
|
| `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
|
backend_port: 3000
|
||||||
frontend_port: 4000
|
frontend_port: 4000
|
||||||
|
|
||||||
# LLM API
|
# LLM API(全局默认值,每个 model 可单独覆盖)
|
||||||
api_key: your-api-key
|
default_api_key: your-api-key
|
||||||
api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
|
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
|
workspace_root: ./workspaces
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@
|
||||||
"name": "nano-claw",
|
"name": "nano-claw",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"highlight.js": "^11.10.0",
|
"highlight.js": "^11.11.1",
|
||||||
"katex": "^0.16.40",
|
"katex": "^0.16.40",
|
||||||
"marked": "^15.0.0",
|
"marked": "^15.0.12",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.3",
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"highlight.js": "^11.10.0",
|
"highlight.js": "^11.11.1",
|
||||||
"katex": "^0.16.40",
|
"katex": "^0.16.40",
|
||||||
"marked": "^15.0.0",
|
"marked": "^15.0.12",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.3",
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,39 @@
|
||||||
:current-id="currentConvId"
|
:current-id="currentConvId"
|
||||||
:loading="loadingConvs"
|
:loading="loadingConvs"
|
||||||
:has-more="hasMoreConvs"
|
:has-more="hasMoreConvs"
|
||||||
:current-project="currentProject"
|
|
||||||
@select="selectConversation"
|
@select="selectConversation"
|
||||||
@create="createConversation"
|
|
||||||
@delete="deleteConversation"
|
@delete="deleteConversation"
|
||||||
@load-more="loadMoreConversations"
|
@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
|
<ChatView
|
||||||
|
v-else
|
||||||
:conversation="currentConv"
|
:conversation="currentConv"
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
:streaming="streaming"
|
:streaming="streaming"
|
||||||
|
|
@ -53,6 +77,37 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -60,10 +115,11 @@
|
||||||
import { ref, shallowRef, computed, onMounted, defineAsyncComponent } from 'vue'
|
import { ref, shallowRef, computed, onMounted, defineAsyncComponent } from 'vue'
|
||||||
import Sidebar from './components/Sidebar.vue'
|
import Sidebar from './components/Sidebar.vue'
|
||||||
import ChatView from './components/ChatView.vue'
|
import ChatView from './components/ChatView.vue'
|
||||||
|
import FileExplorer from './components/FileExplorer.vue'
|
||||||
|
|
||||||
const SettingsPanel = defineAsyncComponent(() => import('./components/SettingsPanel.vue'))
|
const SettingsPanel = defineAsyncComponent(() => import('./components/SettingsPanel.vue'))
|
||||||
const StatsPanel = defineAsyncComponent(() => import('./components/StatsPanel.vue'))
|
const StatsPanel = defineAsyncComponent(() => import('./components/StatsPanel.vue'))
|
||||||
import { conversationApi, messageApi } from './api'
|
import { conversationApi, messageApi, projectApi } from './api'
|
||||||
|
|
||||||
// -- Conversations state --
|
// -- Conversations state --
|
||||||
const conversations = shallowRef([])
|
const conversations = shallowRef([])
|
||||||
|
|
@ -88,28 +144,14 @@ const streamProcessSteps = shallowRef([])
|
||||||
// 保存每个对话的流式状态
|
// 保存每个对话的流式状态
|
||||||
const streamStates = new Map()
|
const streamStates = new Map()
|
||||||
|
|
||||||
// 重置当前流式状态(用于 sendMessage / regenerateMessage / onError)
|
function setStreamState(isActive) {
|
||||||
function resetStreamState() {
|
streaming.value = isActive
|
||||||
streaming.value = false
|
|
||||||
streamContent.value = ''
|
streamContent.value = ''
|
||||||
streamThinking.value = ''
|
streamThinking.value = ''
|
||||||
streamToolCalls.value = []
|
streamToolCalls.value = []
|
||||||
streamProcessSteps.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) {
|
function updateStreamField(convId, field, ref, valueOrUpdater) {
|
||||||
const isCurrent = currentConvId.value === convId
|
const isCurrent = currentConvId.value === convId
|
||||||
const current = isCurrent ? ref.value : (streamStates.get(convId) || {})[field]
|
const current = isCurrent ? ref.value : (streamStates.get(convId) || {})[field]
|
||||||
|
|
@ -125,8 +167,13 @@ function updateStreamField(convId, field, ref, valueOrUpdater) {
|
||||||
// -- UI state --
|
// -- UI state --
|
||||||
const showSettings = ref(false)
|
const showSettings = ref(false)
|
||||||
const showStats = ref(false)
|
const showStats = ref(false)
|
||||||
const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false') // 默认开启
|
const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false')
|
||||||
const currentProject = ref(null) // Current selected project
|
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) {
|
function togglePanel(panel) {
|
||||||
if (panel === 'settings') {
|
if (panel === 'settings') {
|
||||||
|
|
@ -142,13 +189,12 @@ const currentConv = computed(() =>
|
||||||
conversations.value.find(c => c.id === currentConvId.value) || null
|
conversations.value.find(c => c.id === currentConvId.value) || null
|
||||||
)
|
)
|
||||||
|
|
||||||
// -- Load conversations --
|
// -- Load conversations (all, no project filter) --
|
||||||
async function loadConversations(reset = true) {
|
async function loadConversations(reset = true) {
|
||||||
if (loadingConvs.value) return
|
if (loadingConvs.value) return
|
||||||
loadingConvs.value = true
|
loadingConvs.value = true
|
||||||
try {
|
try {
|
||||||
const projectId = currentProject.value?.id || null
|
const res = await conversationApi.list(reset ? null : nextConvCursor.value, 20)
|
||||||
const res = await conversationApi.list(reset ? null : nextConvCursor.value, 20, projectId)
|
|
||||||
if (reset) {
|
if (reset) {
|
||||||
conversations.value = res.data.items
|
conversations.value = res.data.items
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -167,12 +213,18 @@ function loadMoreConversations() {
|
||||||
if (hasMoreConvs.value) loadConversations(false)
|
if (hasMoreConvs.value) loadConversations(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Create conversation --
|
// -- Create conversation in specific project --
|
||||||
async function createConversation() {
|
async function createConversationInProject(project) {
|
||||||
|
showFileExplorer.value = false
|
||||||
|
if (project.id) {
|
||||||
|
currentProject.value = { id: project.id, name: project.name }
|
||||||
|
} else {
|
||||||
|
currentProject.value = null
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await conversationApi.create({
|
const res = await conversationApi.create({
|
||||||
title: '新对话',
|
title: '新对话',
|
||||||
project_id: currentProject.value?.id || null,
|
project_id: project.id || null,
|
||||||
})
|
})
|
||||||
conversations.value = [res.data, ...conversations.value]
|
conversations.value = [res.data, ...conversations.value]
|
||||||
await selectConversation(res.data.id)
|
await selectConversation(res.data.id)
|
||||||
|
|
@ -181,9 +233,22 @@ async function createConversation() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Select conversation --
|
|
||||||
|
// -- Select conversation (auto-set project context) --
|
||||||
async function selectConversation(id) {
|
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) {
|
if (currentConvId.value && streaming.value) {
|
||||||
streamStates.set(currentConvId.value, {
|
streamStates.set(currentConvId.value, {
|
||||||
streaming: true,
|
streaming: true,
|
||||||
|
|
@ -191,7 +256,7 @@ async function selectConversation(id) {
|
||||||
streamThinking: streamThinking.value,
|
streamThinking: streamThinking.value,
|
||||||
streamToolCalls: [...streamToolCalls.value],
|
streamToolCalls: [...streamToolCalls.value],
|
||||||
streamProcessSteps: [...streamProcessSteps.value],
|
streamProcessSteps: [...streamProcessSteps.value],
|
||||||
messages: [...messages.value], // 保存消息列表(包括临时用户消息)
|
messages: [...messages.value],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,7 +264,7 @@ async function selectConversation(id) {
|
||||||
nextMsgCursor.value = null
|
nextMsgCursor.value = null
|
||||||
hasMoreMessages.value = false
|
hasMoreMessages.value = false
|
||||||
|
|
||||||
// 恢复新对话的流式状态
|
// Restore streaming state for new conversation
|
||||||
const savedState = streamStates.get(id)
|
const savedState = streamStates.get(id)
|
||||||
if (savedState && savedState.streaming) {
|
if (savedState && savedState.streaming) {
|
||||||
streaming.value = true
|
streaming.value = true
|
||||||
|
|
@ -207,13 +272,12 @@ async function selectConversation(id) {
|
||||||
streamThinking.value = savedState.streamThinking
|
streamThinking.value = savedState.streamThinking
|
||||||
streamToolCalls.value = savedState.streamToolCalls
|
streamToolCalls.value = savedState.streamToolCalls
|
||||||
streamProcessSteps.value = savedState.streamProcessSteps
|
streamProcessSteps.value = savedState.streamProcessSteps
|
||||||
messages.value = savedState.messages || [] // 恢复消息列表
|
messages.value = savedState.messages || []
|
||||||
} else {
|
} else {
|
||||||
resetStreamState()
|
setStreamState(false)
|
||||||
messages.value = []
|
messages.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果不是正在流式传输,从服务器加载消息
|
|
||||||
if (!streaming.value) {
|
if (!streaming.value) {
|
||||||
await loadMessages(true)
|
await loadMessages(true)
|
||||||
}
|
}
|
||||||
|
|
@ -226,7 +290,6 @@ async function loadMessages(reset = true) {
|
||||||
try {
|
try {
|
||||||
const res = await messageApi.list(currentConvId.value, reset ? null : nextMsgCursor.value)
|
const res = await messageApi.list(currentConvId.value, reset ? null : nextMsgCursor.value)
|
||||||
if (reset) {
|
if (reset) {
|
||||||
// Filter out tool messages (they're merged into assistant messages)
|
|
||||||
messages.value = res.data.items.filter(m => m.role !== 'tool')
|
messages.value = res.data.items.filter(m => m.role !== 'tool')
|
||||||
} else {
|
} else {
|
||||||
messages.value = [...res.data.items.filter(m => m.role !== 'tool'), ...messages.value]
|
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,
|
token_count: data.token_count,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
}]
|
}]
|
||||||
resetStreamState()
|
setStreamState(false)
|
||||||
|
|
||||||
if (updateConvList) {
|
if (updateConvList) {
|
||||||
const idx = conversations.value.findIndex(c => c.id === convId)
|
const idx = conversations.value.findIndex(c => c.id === convId)
|
||||||
|
|
@ -329,7 +392,7 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
||||||
onError(msg) {
|
onError(msg) {
|
||||||
streamStates.delete(convId)
|
streamStates.delete(convId)
|
||||||
if (currentConvId.value === convId) {
|
if (currentConvId.value === convId) {
|
||||||
resetStreamState()
|
setStreamState(false)
|
||||||
console.error('Stream error:', msg)
|
console.error('Stream error:', msg)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -355,7 +418,7 @@ async function sendMessage(data) {
|
||||||
}
|
}
|
||||||
messages.value = [...messages.value, userMsg]
|
messages.value = [...messages.value, userMsg]
|
||||||
|
|
||||||
initStreamState()
|
setStreamState(true)
|
||||||
|
|
||||||
messageApi.send(convId, { text, attachments, projectId: currentProject.value?.id }, {
|
messageApi.send(convId, { text, attachments, projectId: currentProject.value?.id }, {
|
||||||
toolsEnabled: toolsEnabled.value,
|
toolsEnabled: toolsEnabled.value,
|
||||||
|
|
@ -384,7 +447,7 @@ async function regenerateMessage(msgId) {
|
||||||
|
|
||||||
messages.value = messages.value.slice(0, msgIndex)
|
messages.value = messages.value.slice(0, msgIndex)
|
||||||
|
|
||||||
initStreamState()
|
setStreamState(true)
|
||||||
|
|
||||||
messageApi.regenerate(convId, msgId, {
|
messageApi.regenerate(convId, msgId, {
|
||||||
toolsEnabled: toolsEnabled.value,
|
toolsEnabled: toolsEnabled.value,
|
||||||
|
|
@ -404,6 +467,7 @@ async function deleteConversation(id) {
|
||||||
await selectConversation(currentConvId.value)
|
await selectConversation(currentConvId.value)
|
||||||
} else {
|
} else {
|
||||||
messages.value = []
|
messages.value = []
|
||||||
|
currentProject.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -431,12 +495,30 @@ function updateToolsEnabled(val) {
|
||||||
localStorage.setItem('tools_enabled', String(val))
|
localStorage.setItem('tools_enabled', String(val))
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Select project --
|
// -- Browse project files --
|
||||||
function selectProject(project) {
|
function browseProject(project) {
|
||||||
currentProject.value = project
|
currentProject.value = project
|
||||||
// Reload conversations filtered by the selected project
|
showFileExplorer.value = true
|
||||||
nextConvCursor.value = null
|
}
|
||||||
loadConversations(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 --
|
// -- Init --
|
||||||
|
|
@ -451,6 +533,65 @@ onMounted(() => {
|
||||||
height: 100%;
|
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 {
|
.modal-content {
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
|
|
@ -464,4 +605,114 @@ onMounted(() => {
|
||||||
border: 1px solid var(--border-medium);
|
border: 1px solid var(--border-medium);
|
||||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.2);
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -233,4 +233,44 @@ export const projectApi = {
|
||||||
return json
|
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>
|
<template>
|
||||||
<div class="chat-view">
|
<div class="chat-view main-panel">
|
||||||
<div v-if="!conversation" class="welcome">
|
<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>
|
<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>
|
<h1>Chat</h1>
|
||||||
|
|
@ -13,21 +13,6 @@
|
||||||
<span class="model-badge">{{ formatModelName(conversation.model) }}</span>
|
<span class="model-badge">{{ formatModelName(conversation.model) }}</span>
|
||||||
<span v-if="conversation.thinking_enabled" class="thinking-badge">思考</span>
|
<span v-if="conversation.thinking_enabled" class="thinking-badge">思考</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div ref="scrollContainer" class="messages-container">
|
<div ref="scrollContainer" class="messages-container">
|
||||||
|
|
@ -202,12 +187,8 @@ watch(() => props.conversation?.id, () => {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
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;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome {
|
.welcome {
|
||||||
|
|
@ -288,15 +269,8 @@ watch(() => props.conversation?.id, () => {
|
||||||
color: var(--success-color);
|
color: var(--success-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-actions .btn-icon {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-container {
|
.messages-container {
|
||||||
flex: 1 1 auto;
|
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 内代码块
|
// 增强 processBlock 内代码块
|
||||||
const { enhance, debouncedEnhance } = useCodeEnhancement(processRef, processItems, { deep: true })
|
const { debouncedEnhance } = useCodeEnhancement(processRef, processItems, { deep: true })
|
||||||
|
|
||||||
// 流式时使用节流的代码块增强,减少 DOM 操作
|
// 流式时使用节流的代码块增强,减少 DOM 操作
|
||||||
watch(() => props.streamingContent?.length, () => {
|
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>
|
<template>
|
||||||
<aside class="sidebar">
|
<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">
|
<div class="sidebar-header">
|
||||||
<button class="btn-new" @click="$emit('create')">
|
<button class="btn-new-project" @click="$emit('createProject')">
|
||||||
<span class="icon">+</span>
|
<span class="icon">+</span>
|
||||||
<span>新对话</span>
|
<span>新建项目</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="conversation-list" @scroll="onScroll">
|
<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
|
<div
|
||||||
v-for="conv in conversations"
|
v-for="conv in group.conversations"
|
||||||
:key="conv.id"
|
:key="conv.id"
|
||||||
class="conversation-item"
|
class="conversation-item"
|
||||||
:class="{ active: conv.id === currentId }"
|
:class="{ active: conv.id === currentId }"
|
||||||
|
|
@ -66,7 +48,6 @@
|
||||||
<div class="conv-title">{{ conv.title || '新对话' }}</div>
|
<div class="conv-title">{{ conv.title || '新对话' }}</div>
|
||||||
<div class="conv-meta">
|
<div class="conv-meta">
|
||||||
<span>{{ conv.message_count || 0 }} 条消息 · {{ formatTime(conv.updated_at) }}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-delete" @click.stop="$emit('delete', conv.id)" title="删除">
|
<button class="btn-delete" @click.stop="$emit('delete', conv.id)" title="删除">
|
||||||
|
|
@ -76,49 +57,120 @@
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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" class="loading-more">加载中...</div>
|
||||||
<div v-if="!loading && conversations.length === 0" class="empty-hint">
|
<div v-if="!loading && conversations.length === 0" class="empty-hint">暂无对话</div>
|
||||||
{{ currentProject ? '该项目暂无对话' : '暂无对话' }}
|
|
||||||
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { computed, reactive } from 'vue'
|
||||||
import { formatTime } from '../utils/format'
|
import { formatTime } from '../utils/format'
|
||||||
import ProjectManager from './ProjectManager.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
conversations: { type: Array, required: true },
|
conversations: { type: Array, required: true },
|
||||||
currentId: { type: String, default: null },
|
currentId: { type: String, default: null },
|
||||||
loading: { type: Boolean, default: false },
|
loading: { type: Boolean, default: false },
|
||||||
hasMore: { 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 expandedGroups = reactive({})
|
||||||
const projectManagerRef = ref(null)
|
|
||||||
|
|
||||||
function selectProject(project) {
|
const groupedData = computed(() => {
|
||||||
emit('selectProject', project)
|
const groups = {}
|
||||||
showProjects.value = false
|
const standalone = []
|
||||||
}
|
|
||||||
|
|
||||||
function onProjectCreated(project) {
|
for (const conv of props.conversations) {
|
||||||
// Auto-select newly created project and refresh list
|
if (conv.project_id) {
|
||||||
projectManagerRef.value?.loadProjects()
|
if (!groups[conv.project_id]) {
|
||||||
emit('selectProject', project)
|
groups[conv.project_id] = {
|
||||||
}
|
id: conv.project_id,
|
||||||
|
name: conv.project_name || '未知项目',
|
||||||
function onProjectDeleted(projectId) {
|
conversations: [],
|
||||||
// If deleted project is current, clear selection
|
|
||||||
if (props.currentProject?.id === projectId) {
|
|
||||||
emit('selectProject', null)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
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) {
|
function onScroll(e) {
|
||||||
|
|
@ -144,86 +196,15 @@ function onScroll(e) {
|
||||||
overflow: hidden;
|
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 {
|
.sidebar-header {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-new {
|
.btn-new-project {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
background: var(--accent-primary-light);
|
background: var(--accent-primary-light);
|
||||||
border: 1px dashed var(--accent-primary);
|
border: 1px solid var(--accent-primary);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
@ -234,20 +215,22 @@ function onScroll(e) {
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-new:hover {
|
.btn-new-project:hover {
|
||||||
background: var(--accent-primary-medium);
|
background: var(--accent-primary-medium);
|
||||||
border-color: var(--accent-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-new .icon {
|
.btn-new-project .icon {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.conversation-list {
|
.conversation-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 8px 16px;
|
padding: 0 16px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-list::-webkit-scrollbar {
|
.conversation-list::-webkit-scrollbar {
|
||||||
|
|
@ -259,10 +242,88 @@ function onScroll(e) {
|
||||||
border-radius: 2px;
|
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 {
|
.conversation-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 12px;
|
padding: 8px 12px 8px 36px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
|
@ -283,7 +344,7 @@ function onScroll(e) {
|
||||||
}
|
}
|
||||||
|
|
||||||
.conv-title {
|
.conv-title {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -291,20 +352,12 @@ function onScroll(e) {
|
||||||
}
|
}
|
||||||
|
|
||||||
.conv-meta {
|
.conv-meta {
|
||||||
display: flex;
|
font-size: 11px;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
.conv-project-badge {
|
text-overflow: ellipsis;
|
||||||
font-size: 11px;
|
|
||||||
color: var(--accent-primary);
|
|
||||||
opacity: 0.8;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete {
|
.btn-delete {
|
||||||
|
|
@ -335,4 +388,33 @@ function onScroll(e) {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 20px;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,20 @@ body {
|
||||||
height: 100%;
|
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 ============ */
|
/* ============ Transitions ============ */
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
.fade-leave-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;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content pre::-webkit-scrollbar-track {
|
.md-content pre::-webkit-scrollbar-track,
|
||||||
|
.code-block pre code::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
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);
|
background: var(--scrollbar-thumb);
|
||||||
border-radius: 3px;
|
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);
|
background: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,22 +215,7 @@ body {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-block pre code::-webkit-scrollbar {
|
/* (merged above with .md-content pre selectors) */
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-header {
|
.code-header {
|
||||||
display: flex;
|
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");
|
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 { marked } from 'marked'
|
||||||
import { markedHighlight } from 'marked-highlight'
|
import { markedHighlight } from 'marked-highlight'
|
||||||
import katex from 'katex'
|
import katex from 'katex'
|
||||||
import hljs from 'highlight.js'
|
import { highlightCode } from './highlight'
|
||||||
|
|
||||||
function renderMath(text, displayMode) {
|
function renderMath(text, displayMode) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -58,10 +58,7 @@ marked.use({
|
||||||
...markedHighlight({
|
...markedHighlight({
|
||||||
langPrefix: 'hljs language-',
|
langPrefix: 'hljs language-',
|
||||||
highlight(code, lang) {
|
highlight(code, lang) {
|
||||||
if (lang && hljs.getLanguage(lang)) {
|
return highlightCode(code, lang)
|
||||||
return hljs.highlight(code, { language: lang }).value
|
|
||||||
}
|
|
||||||
return hljs.highlightAuto(code).value
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
@ -111,10 +108,10 @@ export function enhanceCodeBlocks(container) {
|
||||||
|
|
||||||
copyBtn.addEventListener('click', () => {
|
copyBtn.addEventListener('click', () => {
|
||||||
const raw = code?.textContent || ''
|
const raw = code?.textContent || ''
|
||||||
navigator.clipboard.writeText(raw).then(() => {
|
const copy = () => { copyBtn.innerHTML = CHECK_SVG; setTimeout(() => { copyBtn.innerHTML = COPY_SVG }, 1500) }
|
||||||
copyBtn.innerHTML = CHECK_SVG
|
if (navigator.clipboard) {
|
||||||
setTimeout(() => { copyBtn.innerHTML = COPY_SVG }, 1500)
|
navigator.clipboard.writeText(raw).then(copy)
|
||||||
}).catch(() => {
|
} else {
|
||||||
const ta = document.createElement('textarea')
|
const ta = document.createElement('textarea')
|
||||||
ta.value = raw
|
ta.value = raw
|
||||||
ta.style.position = 'fixed'
|
ta.style.position = 'fixed'
|
||||||
|
|
@ -123,9 +120,8 @@ export function enhanceCodeBlocks(container) {
|
||||||
ta.select()
|
ta.select()
|
||||||
document.execCommand('copy')
|
document.execCommand('copy')
|
||||||
document.body.removeChild(ta)
|
document.body.removeChild(ta)
|
||||||
copyBtn.innerHTML = CHECK_SVG
|
copy()
|
||||||
setTimeout(() => { copyBtn.innerHTML = COPY_SVG }, 1500)
|
}
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
header.appendChild(langSpan)
|
header.appendChild(langSpan)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue