diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..4017212 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,24 @@ +# Gradle +.gradle/ +build/ +app/build/ +*.apk +*.aab +*.jks +*.keystore + +# IDE +.idea/ +*.iml +local.properties + +# Google Services +google-services.json +app/google-services.json + +# Logs +*.log +logs/ + +# Local config (API keys, tokens) +local.properties diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index da1a05e..c098df6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "com.tiangong.aiagent" minSdk = 26 targetSdk = 34 - versionCode = 1 - versionName = "1.0.0" + versionCode = 4 + versionName = "1.3.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -25,7 +25,11 @@ android { } // 真机调试用本机 WiFi IP,模拟器用 10.0.2.2 -buildConfigField("String", "BASE_URL", "\"http://192.168.31.135:8037/\"") +buildConfigField("String", "BASE_URL", "\"http://101.43.95.130:8037/\"") + + // Bugly keys — set in local.properties: BUGLY_APPID=xxx, BUGLY_APP_KEY=xxx + manifestPlaceholders["BUGLY_APPID"] = project.findProperty("BUGLY_APPID") as String? ?: "" + manifestPlaceholders["BUGLY_APP_KEY"] = project.findProperty("BUGLY_APP_KEY") as String? ?: "" } buildTypes { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 27f1e89..679ad3e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,21 +10,22 @@ - + + android:value="${BUGLY_APPID}" /> + android:value="${BUGLY_APP_KEY}" /> ? { - val username = prefs.getString("username", null) ?: return null - val password = prefs.getString("password", null) ?: return null - return Pair(username, password) - } - - fun clearCredentials() { - prefs.edit() - .remove("username") - .remove("password") - .apply() - } - - // ─── Encrypted tokens ─── + // ─── Encrypted tokens (V1.4.0: tokens only, no password storage) ─── fun saveToken(token: String) { prefs.edit().putString("access_token", token).apply() diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt b/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt index bb80b03..714c95f 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.* import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -15,11 +16,10 @@ private val Context.dataStore: DataStore by preferencesDataStore(na @Singleton class TokenDataStore @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val credentialStore: CredentialStore ) { companion object { - private val KEY_TOKEN = stringPreferencesKey("access_token") - private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token") private val KEY_SERVER_URL = stringPreferencesKey("server_url") private val KEY_CURRENT_AGENT_ID = stringPreferencesKey("current_agent_id") private val KEY_CURRENT_AGENT_NAME = stringPreferencesKey("current_agent_name") @@ -33,34 +33,48 @@ class TokenDataStore @Inject constructor( private val KEY_ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed") } - // ─── Token ─── - val token: Flow = context.dataStore.data.map { it[KEY_TOKEN] } + // ─── Token — delegated to encrypted CredentialStore (V1.4.0 安全加固) ─── + private val _tokenFlow = MutableStateFlow(null) + val token: Flow = _tokenFlow suspend fun saveToken(token: String) { - context.dataStore.edit { it[KEY_TOKEN] = token } + credentialStore.saveToken(token) + _tokenFlow.value = token } suspend fun getToken(): String? { - return context.dataStore.data.first()[KEY_TOKEN] + val cached = _tokenFlow.value + if (cached != null) return cached + val stored = credentialStore.getToken() + if (stored != null) _tokenFlow.value = stored + return stored } suspend fun clearToken() { - context.dataStore.edit { it.remove(KEY_TOKEN) } + credentialStore.clearTokens() + _tokenFlow.value = null } - // ─── Refresh Token ─── - val refreshToken: Flow = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] } + // ─── Refresh Token — delegated to encrypted CredentialStore ─── + private val _refreshTokenFlow = MutableStateFlow(null) + val refreshToken: Flow = _refreshTokenFlow suspend fun saveRefreshToken(token: String) { - context.dataStore.edit { it[KEY_REFRESH_TOKEN] = token } + credentialStore.saveRefreshToken(token) + _refreshTokenFlow.value = token } suspend fun getRefreshToken(): String? { - return context.dataStore.data.first()[KEY_REFRESH_TOKEN] + val cached = _refreshTokenFlow.value + if (cached != null) return cached + val stored = credentialStore.getRefreshToken() + if (stored != null) _refreshTokenFlow.value = stored + return stored } suspend fun clearRefreshToken() { - context.dataStore.edit { it.remove(KEY_REFRESH_TOKEN) } + _refreshTokenFlow.value = null + // Token cleared together in CredentialStore.clearTokens() } // ─── Server URL ─── diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt index a614fee..86f6395 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt @@ -32,7 +32,7 @@ interface ApiService { @PUT("api/v1/auth/phone") suspend fun bindPhone(@Body request: BindPhoneRequest): MessageResponse - @DELETE("api/v1/auth/account") + @HTTP(method = "DELETE", path = "api/v1/auth/account", hasBody = true) suspend fun deleteAccount(@Body request: DeleteAccountRequest): MessageResponse // ─── Agents ─── diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/AuthInterceptor.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/AuthInterceptor.kt index 2fe5b62..ad14ce5 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/AuthInterceptor.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/AuthInterceptor.kt @@ -1,7 +1,6 @@ package com.tiangong.aiagent.data.remote import com.google.gson.JsonParser -import com.tiangong.aiagent.data.local.CredentialStore import com.tiangong.aiagent.data.local.TokenDataStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -12,8 +11,7 @@ import okhttp3.MediaType.Companion.toMediaType import java.io.IOException class AuthInterceptor( - private val tokenDataStore: TokenDataStore, - private val credentialStore: CredentialStore + private val tokenDataStore: TokenDataStore ) : Interceptor { @Volatile @@ -24,6 +22,9 @@ class AuthInterceptor( // Pre-load token and server URL into memory to avoid blocking OkHttp dispatcher threads private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + // Known server hosts — Authorization header only sent to these + private val trustedHosts = setOf("101.43.95.130", "192.168.31.135", "10.0.2.2", "localhost", "127.0.0.1") + init { scope.launch { cachedToken = tokenDataStore.getToken() @@ -37,9 +38,14 @@ class AuthInterceptor( override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() + val isLoginPath = originalRequest.url.encodedPath.endsWith("/login") - // Use memory-cached token — no runBlocking needed - val request = if (cachedToken != null && !originalRequest.url.encodedPath.endsWith("/login")) { + // 安全检查:仅在请求目标为已知服务器时携带 Authorization 头 + val shouldAttachAuth = cachedToken != null + && !isLoginPath + && trustedHosts.any { originalRequest.url.host == it } + + val request = if (shouldAttachAuth) { originalRequest.newBuilder() .header("Authorization", "Bearer $cachedToken") .build() @@ -49,11 +55,10 @@ class AuthInterceptor( var response = chain.proceed(request) - // 401 auto refresh → re-login - if (response.code == 401 && !originalRequest.url.encodedPath.endsWith("/login") + // 401 自动刷新 token(不再静默重登录——凭据不应本地留存) + if (response.code == 401 && !isLoginPath && !originalRequest.url.encodedPath.endsWith("/refresh") ) { - // 1) Try refresh token first val refreshedToken = performTokenRefresh() if (refreshedToken != null) { response.close() @@ -63,20 +68,6 @@ class AuthInterceptor( .build() return chain.proceed(retryRequest) } - - // 2) Fall back to credential re-login - val credentials = credentialStore.getCredentials() - if (credentials != null) { - val newToken = performReLogin(credentials) - if (newToken != null) { - response.close() - cachedToken = newToken - val retryRequest = request.newBuilder() - .header("Authorization", "Bearer $newToken") - .build() - return chain.proceed(retryRequest) - } - } } return response @@ -118,37 +109,4 @@ class AuthInterceptor( null } } - - private fun performReLogin(credentials: Pair): String? { - return try { - val client = OkHttpClient() - val body = FormBody.Builder() - .add("username", credentials.first) - .add("password", credentials.second) - .build() - val loginUrl = (cachedServerUrl ?: com.tiangong.aiagent.BuildConfig.BASE_URL).trimEnd('/') + "/api/v1/auth/login" - val loginRequest = Request.Builder() - .url(loginUrl) - .post(body) - .build() - - val loginResponse = client.newCall(loginRequest).execute() - if (loginResponse.isSuccessful) { - val json = loginResponse.body?.string() ?: return null - val obj = JsonParser.parseString(json).asJsonObject - val newToken = obj["access_token"].asString - val newRefreshToken = obj["refresh_token"]?.asString - // Persist the new tokens - scope.launch { - tokenDataStore.saveToken(newToken) - if (newRefreshToken != null) { - tokenDataStore.saveRefreshToken(newRefreshToken) - } - } - newToken - } else null - } catch (_: IOException) { - null - } - } } diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/DynamicUrlInterceptor.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/DynamicUrlInterceptor.kt index 4e1de33..7825e38 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/DynamicUrlInterceptor.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/DynamicUrlInterceptor.kt @@ -32,8 +32,17 @@ class DynamicUrlInterceptor @Inject constructor( } } + // Known safe hosts (allow HTTP for local dev servers) + private val localHosts = setOf("192.168.", "10.0.2.2", "localhost", "127.0.0.1") + fun updateBaseUrl(url: String) { - cachedBaseUrl = url + // 安全检查:非本地地址必须使用 HTTPS + val trimmed = url.trimEnd('/') + if (trimmed.startsWith("http://") && localHosts.none { trimmed.contains(it) }) { + android.util.Log.w("DynamicUrlInterceptor", "拒绝非 HTTPS 的外部地址: $trimmed") + return + } + cachedBaseUrl = trimmed } override fun intercept(chain: Interceptor.Chain): Response { diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt index d275f0c..047ca6b 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt @@ -519,7 +519,9 @@ data class AppVersionResponse( @SerializedName("has_update") val hasUpdate: Boolean, @SerializedName("force_update") val forceUpdate: Boolean, @SerializedName("update_url") val updateUrl: String = "", - @SerializedName("release_notes") val releaseNotes: String = "" + @SerializedName("release_notes") val releaseNotes: String = "", + @SerializedName("sha256") val sha256: String = "", + @SerializedName("min_version_code") val minVersionCode: Int = 0 ) // ─────────── Vector Memories ─────────── diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt index 5866702..13d7730 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt @@ -31,14 +31,36 @@ class AuthRepository @Inject constructor( if (response.refreshToken != null) { tokenDataStore.saveRefreshToken(response.refreshToken) } - credentialStore.saveCredentials(username, password) // Update AuthInterceptor memory cache so subsequent requests carry the token authInterceptor.updateToken(response.accessToken) // Umeng: track user sign-in UmengHelper.onProfileSignIn(username) Result.success(response) - } catch (e: Exception) { - Result.failure(e) + } catch (e: HttpException) { + Result.failure(Exception(parseLoginError(e))) + } catch (e: IOException) { + Result.failure(Exception("网络连接失败,请检查网络设置")) + } + } + + private fun parseLoginError(e: HttpException): String { + val serverMsg = try { + val body = e.response()?.errorBody()?.string() + body?.let { + val json = JsonParser.parseString(it).asJsonObject + json.get("message")?.asString + ?: json.get("detail")?.asString + ?: json.get("error")?.asString + } + } catch (_: Exception) { null } + + return when (e.code()) { + 401 -> serverMsg ?: "用户名或密码错误" + 403 -> "账号已被禁用,请联系管理员" + 422 -> serverMsg ?: "输入信息不符合要求" + 429 -> "操作过于频繁,请稍后重试" + in 500..599 -> "服务器繁忙,请稍后重试" + else -> serverMsg ?: "登录失败 (${e.code()})" } } @@ -103,7 +125,6 @@ class AuthRepository @Inject constructor( } } catch (_: Exception) { } tokenDataStore.clearAll() - credentialStore.clearCredentials() authInterceptor.updateToken(null) // Umeng: track user sign-out UmengHelper.onProfileSignOff() diff --git a/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt b/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt index 4b4f415..681a43d 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt @@ -75,10 +75,9 @@ object AppModule { @Provides @Singleton fun provideAuthInterceptor( - tokenDataStore: TokenDataStore, - credentialStore: CredentialStore + tokenDataStore: TokenDataStore ): AuthInterceptor { - return AuthInterceptor(tokenDataStore, credentialStore) + return AuthInterceptor(tokenDataStore) } @Provides diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt index b70ffae..c9cdcdb 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt @@ -171,14 +171,6 @@ fun ChatScreen( } } - // Error auto-dismiss - LaunchedEffect(uiState.error) { - uiState.error?.let { error -> - kotlinx.coroutines.delay(5000) - viewModel.dismissError() - } - } - // Voice transcription result LaunchedEffect(uiState.transcribedText) { uiState.transcribedText?.let { text -> @@ -273,7 +265,8 @@ fun ChatScreen( if (uiState.isStreaming) { Row( modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), - horizontalArrangement = Arrangement.Center + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically ) { FilledTonalButton( onClick = { @@ -295,6 +288,20 @@ fun ChatScreen( } } + // v1.3.0: Pending message queue indicator + if (uiState.pendingMessage != null && uiState.isStreaming) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), + horizontalArrangement = Arrangement.Center + ) { + Text( + "消息已排队,等待发送...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + // Input row Row( modifier = Modifier.fillMaxWidth(), @@ -324,12 +331,13 @@ fun ChatScreen( placeholder = { Text( if (uiState.editingMessageId != null) "编辑消息..." + else if (uiState.currentAgent == null) "请先在左上角选择一个 Agent" else "给 ${uiState.currentAgent?.name ?: "AI"} 发送消息..." ) }, modifier = Modifier.weight(1f), maxLines = 8, - enabled = !uiState.isLoading && !uiState.isStreaming, + enabled = !uiState.isLoading && uiState.currentAgent != null, supportingText = { Text( "${inputText.length}/2000", @@ -359,7 +367,7 @@ fun ChatScreen( } } - val sendEnabled = inputText.isNotBlank() && !uiState.isLoading && !uiState.isStreaming && !uiState.isOffline + val sendEnabled = inputText.isNotBlank() && !uiState.isLoading && !uiState.isOffline && uiState.currentAgent != null val sendColor by animateColorAsState( targetValue = if (sendEnabled) MaterialTheme.colorScheme.primary @@ -397,9 +405,83 @@ fun ChatScreen( Column( modifier = Modifier.fillMaxSize().padding(paddingValues) ) { - // ── Reconnection banner ───────────────────────────────────── - when (val rs = uiState.reconnectionState) { - is ReconnectionState.Reconnecting -> { + // ── Consolidated priority banner ────────────────────────────── + // Priority: error > reconnect-failed > offline > reconnecting > tool approval + val errorText = uiState.error + val pendingTool = uiState.pendingToolCall + val showToolApproval = pendingTool != null && pendingTool.isApproved == null + when { + errorText != null -> { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.errorContainer + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = errorText, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall + ) + TextButton(onClick = { viewModel.retryLastMessage() }) { + Text("重试") + } + TextButton(onClick = { viewModel.dismissError() }) { + Text("关闭") + } + } + } + } + uiState.reconnectionState is ReconnectionState.Failed -> { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.errorContainer + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("连接失败", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer) + TextButton(onClick = { viewModel.dismissError() }) { + Text("关闭") + } + } + } + } + uiState.isOffline -> { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.errorContainer + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.WifiOff, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onErrorContainer + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "当前无网络连接", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + uiState.reconnectionState is ReconnectionState.Reconnecting -> { + val rs = uiState.reconnectionState as ReconnectionState.Reconnecting Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.tertiaryContainer @@ -418,72 +500,7 @@ fun ChatScreen( } } } - is ReconnectionState.Failed -> { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.errorContainer - ) { - Row( - modifier = Modifier.padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("连接失败", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer) - TextButton(onClick = { viewModel.dismissError() }) { - Text("关闭") - } - } - } - } - ReconnectionState.Idle -> { /* no banner */ } - } - - // ── Offline banner (v1.2.0) ───────────────────────────────── - if (uiState.isOffline) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.errorContainer - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.WifiOff, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onErrorContainer - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - "当前无网络连接", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer - ) - } - } - } - - // ── Error banner ─────────────────────────────────────────── - val errorText = uiState.error - if (errorText != null && uiState.reconnectionState is ReconnectionState.Idle) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.errorContainer - ) { - Text( - text = errorText, - modifier = Modifier.padding(12.dp), - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.bodySmall - ) - } - } - - // ── Tool approval dialog (v1.2.0) ────────────────────────── - uiState.pendingToolCall?.let { tool -> - if (tool.isApproved == null) { + showToolApproval -> { Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.primaryContainer @@ -500,12 +517,12 @@ fun ChatScreen( Spacer(modifier = Modifier.width(8.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = "工具调用: ${tool.toolName}", + text = "工具调用: ${pendingTool!!.toolName}", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold ) Text( - text = tool.toolInput.take(100), + text = pendingTool.toolInput.take(100), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2 diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt index 9b44891..24647a8 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt @@ -75,7 +75,10 @@ data class ChatUiState( // Pull-to-load history (v1.3.0) val isLoadingMore: Boolean = false, - val hasMoreMessages: Boolean = true + val hasMoreMessages: Boolean = true, + + // Message queue during streaming (v1.3.0) + val pendingMessage: String? = null ) data class ThinkTraceEntry( @@ -428,6 +431,12 @@ class ChatViewModel @Inject constructor( if (text.isBlank()) return audioPlayer.stop() + // v1.3.0: Queue message if currently streaming + if (_uiState.value.isStreaming) { + _uiState.value = _uiState.value.copy(pendingMessage = text) + return + } + // Capture agent context at call time to avoid any race condition val agentId = _uiState.value.currentAgent?.id val sessionId = _uiState.value.sessionId ?: UUID.randomUUID().toString() @@ -507,9 +516,10 @@ class ChatViewModel @Inject constructor( isLoading = false, isStreaming = false, streamingContent = "", - error = e.message ?: "连接失败", - reconnectionState = ReconnectionState.Failed(e.message ?: "连接失败") + error = friendlyError(e), + reconnectionState = ReconnectionState.Failed(friendlyError(e)) ) + dispatchPendingMessage() } .collect { event -> when (event) { @@ -646,6 +656,20 @@ class ChatViewModel @Inject constructor( isLoading = false, isStreaming = false, streamingContent = "" ) } + + // v1.3.0: Auto-send queued message after stream completes + dispatchPendingMessage() + } + + /** + * Sends the queued pending message (if any) after the current stream completes. + */ + private fun dispatchPendingMessage() { + val pending = _uiState.value.pendingMessage + if (pending != null) { + _uiState.value = _uiState.value.copy(pendingMessage = null) + sendMessage(pending) + } } private fun updateAssistantMsg(msgId: String, content: String, streaming: Boolean = true) { @@ -657,6 +681,26 @@ class ChatViewModel @Inject constructor( } } + private fun friendlyError(e: Throwable): String { + return when { + e is java.net.ConnectException -> "网络连接失败,请检查网络设置" + e is java.net.SocketTimeoutException -> "请求超时,请稍后重试" + e is java.net.UnknownHostException -> "无法连接到服务器,请检查地址" + e is javax.net.ssl.SSLHandshakeException -> "安全连接失败,请检查服务器证书" + e is java.io.IOException && e.message?.contains("cancel") == true -> "请求已取消" + else -> e.message?.let { msg -> + when { + msg.contains("401", ignoreCase = true) -> "登录已过期,请重新登录" + msg.contains("403", ignoreCase = true) -> "无权访问,请联系管理员" + msg.contains("500", ignoreCase = true) -> "服务器内部错误,请稍后重试" + msg.contains("timeout", ignoreCase = true) -> "请求超时,请稍后重试" + msg.contains("Unable to resolve host", ignoreCase = true) -> "无法连接到服务器,请检查地址" + else -> "请求失败: $msg" + } + } ?: "连接失败,请稍后重试" + } + } + // ─── Feedback ──────────────────────────────────────────────────────── fun submitFeedback(messageId: String, rating: FeedbackRating) { @@ -814,18 +858,33 @@ class ChatViewModel @Inject constructor( fun stopGeneration() { sseJob?.cancel() sseJob = null - _uiState.value = _uiState.value.copy( + + // 保留已流式输出的内容到消息列表,避免半截内容丢失 + val state = _uiState.value + val partialContent = state.streamingContent + val msgs = state.messages.toMutableList() + val streamingIdx = msgs.indexOfLast { it.isStreaming && it.role == Message.Role.ASSISTANT } + if (streamingIdx >= 0 && partialContent.isNotEmpty()) { + msgs[streamingIdx] = msgs[streamingIdx].copy( + content = partialContent, + isStreaming = false + ) + } + + _uiState.value = state.copy( isLoading = false, isStreaming = false, streamingContent = "", currentToolCall = null, pendingToolCall = null, reconnectionState = ReconnectionState.Idle, - thinkTraces = emptyList() + thinkTraces = emptyList(), + messages = msgs ) - } - // ─── v1.1.0: Retry Failed Message ───────────────────────────────── + // v1.3.0: Auto-send queued message after stopping + dispatchPendingMessage() + } // ─── v1.1.0: Retry Failed Message ───────────────────────────────── fun retryLastMessage() { if (_uiState.value.isStreaming || _uiState.value.isLoading) return diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/NavigationDrawerContent.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/NavigationDrawerContent.kt index 8549c9f..b3cd15c 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/NavigationDrawerContent.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/NavigationDrawerContent.kt @@ -12,7 +12,7 @@ import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SmartToy import androidx.compose.material3.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -35,6 +35,8 @@ fun NavigationDrawerContent( ModalDrawerSheet( modifier = Modifier.width(300.dp) ) { + var showLogoutDialog by remember { mutableStateOf(false) } + // Agent info header Surface( modifier = Modifier.fillMaxWidth(), @@ -169,10 +171,32 @@ fun NavigationDrawerContent( }, label = { Text("退出登录", color = MaterialTheme.colorScheme.error) }, selected = false, - onClick = onLogoutClick, + onClick = { showLogoutDialog = true }, modifier = Modifier.padding(horizontal = 12.dp) ) Spacer(modifier = Modifier.height(16.dp)) + + // Logout confirmation dialog + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + title = { Text("确认退出") }, + text = { Text("确定要退出登录吗?") }, + confirmButton = { + TextButton(onClick = { + showLogoutDialog = false + onLogoutClick() + }) { + Text("确定", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showLogoutDialog = false }) { + Text("取消") + } + } + ) + } } } diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt index 44199bc..f32a2d3 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt @@ -2,6 +2,8 @@ package com.tiangong.aiagent.ui.login import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions @@ -31,12 +33,12 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.tiangong.aiagent.util.AnalyticsViewModel // ── zhini_im inspired colors ────────────────────────────────────────────── -private val ZhiNiBg = Color(0xFFF5F7FA) private val ZhiNiBlue = Color(0xFF3B62E0) private val ZhiNiTextPrimary = Color(0xFF1A193E) private val ZhiNiTextHint = Color(0xFFABB1C1) private val ZhiNiDivider = Color(0xFFE8E8E8) +@OptIn(ExperimentalFoundationApi::class) @Composable fun LoginScreen( onLoginSuccess: () -> Unit, @@ -60,7 +62,7 @@ fun LoginScreen( Surface( modifier = Modifier.fillMaxSize(), - color = ZhiNiBg + color = MaterialTheme.colorScheme.background ) { Column( modifier = Modifier @@ -71,19 +73,26 @@ fun LoginScreen( ) { Spacer(modifier = Modifier.height(100.dp)) - // ── Title ───────────────────────────────────────────────────── + // ── Title(长按可配置服务器地址) ──────────────────────────────── Text( text = "密码登录", fontSize = 26.sp, fontWeight = FontWeight.Bold, - color = ZhiNiTextPrimary + color = ZhiNiTextPrimary, + modifier = Modifier.combinedClickable( + enabled = !uiState.isLoading, + onClick = {}, + onLongClick = { + editedUrl = uiState.serverUrl + isEditingUrl = true + } + ) ) Spacer(modifier = Modifier.height(32.dp)) - // ── Server URL (compact, tap to edit) ───────────────────────── + // ── Server URL — 长按标题 5 次可配置(仅供高级用户/调试) ───── if (isEditingUrl) { - // Preset quick-select buttons Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -131,18 +140,6 @@ fun LoginScreen( enabled = !uiState.isLoading ) { Text("保存") } } - } else { - Text( - text = if (uiState.serverUrl.isNotBlank()) uiState.serverUrl else "点击配置服务器地址", - style = MaterialTheme.typography.bodySmall, - color = ZhiNiTextHint, - modifier = Modifier - .clickable(enabled = !uiState.isLoading) { - editedUrl = uiState.serverUrl - isEditingUrl = true - } - .padding(vertical = 4.dp) - ) } Spacer(modifier = Modifier.height(24.dp)) diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/splash/SplashScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/splash/SplashScreen.kt index 3efac55..dcfc116 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/splash/SplashScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/splash/SplashScreen.kt @@ -77,8 +77,8 @@ fun SplashScreen( // Determine destination when { - token == null -> onNavigateToLogin() !onboardingCompleted -> onNavigateToOnboarding() + token == null -> onNavigateToLogin() else -> onNavigateToChat() } } diff --git a/android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt b/android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt index 579c36a..730353a 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt @@ -97,6 +97,15 @@ class AppUpdateManager @Inject constructor( return } + // 安全校验:仅允许 HTTPS 或内网地址 + if (!isUpdateUrlAllowed(url)) { + Log.w(TAG, "Update URL rejected by security policy: $url") + _updateState.value = _updateState.value.copy( + downloadError = "更新地址不安全,已拒绝下载" + ) + return + } + _updateState.value = _updateState.value.copy( downloading = true, downloadProgress = 0, @@ -105,11 +114,22 @@ class AppUpdateManager @Inject constructor( ) scope.launch { - downloadApk(url) + downloadApk(url, response.sha256) } } - private suspend fun downloadApk(urlStr: String) { + private fun isUpdateUrlAllowed(url: String): Boolean { + val lower = url.lowercase() + // 仅允许 HTTPS + if (!lower.startsWith("https://")) return false + // 仅允许已知域名 + val allowedHosts = listOf("tiangong.ai", "api.tiangong.ai") + return allowedHosts.any { host -> + lower.contains("://$host") || lower.contains("://$host/") + } + } + + private suspend fun downloadApk(urlStr: String, expectedSha256: String) { withContext(Dispatchers.IO) { var conn: HttpURLConnection? = null var fos: FileOutputStream? = null @@ -138,6 +158,10 @@ class AppUpdateManager @Inject constructor( val input = conn.inputStream fos = FileOutputStream(apkFile) + val md = if (expectedSha256.isNotBlank()) { + java.security.MessageDigest.getInstance("SHA-256") + } else null + val buffer = ByteArray(8192) var downloaded = 0L var lastProgress = -1 @@ -147,6 +171,7 @@ class AppUpdateManager @Inject constructor( if (read == -1) break fos.write(buffer, 0, read) downloaded += read + md?.update(buffer, 0, read) if (totalSize > 0) { val progress = ((downloaded * 100) / totalSize).toInt() @@ -157,6 +182,28 @@ class AppUpdateManager @Inject constructor( } } + fos.flush() + fos.close() + fos = null + input.close() + conn.disconnect() + conn = null + + // SHA256 校验 + if (md != null) { + val actual = md.digest().joinToString("") { "%02x".format(it) } + if (!actual.equals(expectedSha256, ignoreCase = true)) { + apkFile.delete() + _updateState.value = _updateState.value.copy( + downloading = false, + downloadError = "安装包校验失败(文件可能已被篡改),请重新下载" + ) + Log.w(TAG, "SHA256 mismatch: expected=$expectedSha256 actual=$actual") + return@withContext + } + Log.d(TAG, "SHA256 verified: $actual") + } + _updateState.value = _updateState.value.copy( downloading = false, downloadProgress = 100, @@ -167,6 +214,8 @@ class AppUpdateManager @Inject constructor( Log.d(TAG, "APK download complete: ${apkFile.absolutePath} (${apkFile.length()} bytes)") } catch (e: Exception) { Log.e(TAG, "APK download failed", e) + // 清理未完成的文件 + try { getApkFile().delete() } catch (_: Exception) {} _updateState.value = _updateState.value.copy( downloading = false, downloadError = when { diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index ca5d97d..0000000 --- a/android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/res/drawable/ic_launcher_logo.png b/android/app/src/main/res/drawable/ic_launcher_logo.png new file mode 100644 index 0000000..a24b178 Binary files /dev/null and b/android/app/src/main/res/drawable/ic_launcher_logo.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..36b5bb1 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-hdpi/ic_launcher.xml deleted file mode 100644 index 5ed0a2d..0000000 --- a/android/app/src/main/res/mipmap-hdpi/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..1e7ef76 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-mdpi/ic_launcher.xml deleted file mode 100644 index 5ed0a2d..0000000 --- a/android/app/src/main/res/mipmap-mdpi/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..ae16945 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.xml deleted file mode 100644 index 5ed0a2d..0000000 --- a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..7b6b767 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml deleted file mode 100644 index 5ed0a2d..0000000 --- a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..de86bcd Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml deleted file mode 100644 index 5ed0a2d..0000000 --- a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index 40f3722..d9072a8 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -5,7 +5,7 @@ diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..8470e5e --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + 101.43.95.130 + 192.168.31.135 + 10.0.2.2 + localhost + + diff --git a/android/logo.png b/android/logo.png new file mode 100644 index 0000000..4d9835c Binary files /dev/null and b/android/logo.png differ diff --git a/backend/alembic/versions/028_add_agent_avatar.py b/backend/alembic/versions/028_add_agent_avatar.py new file mode 100644 index 0000000..bf555e1 --- /dev/null +++ b/backend/alembic/versions/028_add_agent_avatar.py @@ -0,0 +1,24 @@ +"""add agent avatar column + +Revision ID: 028_add_agent_avatar +Revises: d684c9b30c1c +Create Date: 2026-07-31 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '028_add_agent_avatar' +down_revision = 'd684c9b30c1c' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('agents', sa.Column('avatar', sa.Text, nullable=True, comment='头像(emoji 或图片URL)')) + + +def downgrade() -> None: + op.drop_column('agents', 'avatar') diff --git a/backend/app/agent_runtime/core.py b/backend/app/agent_runtime/core.py index 923d4c4..c0f0c42 100644 --- a/backend/app/agent_runtime/core.py +++ b/backend/app/agent_runtime/core.py @@ -88,6 +88,48 @@ class LLMCallMetrics(TypedDict, total=False): _error_classifier = ErrorClassifier() +# W5.2: post-final 后台流水线 — final 事件发出后异步执行记忆/学习/知识提取 +async def _post_final_pipeline( + memory, user_input: str, final_text: str, messages: list, + learning_enabled: bool, steps: list, + iterations_used: int, tool_calls_made: int, + review_score: float | None, + extract_global_knowledge_fn, +): + """在后台协程中完成记忆保存、学习模式提取、知识沉淀。 + 自建 DB session,不依赖请求作用域。""" + import asyncio as _asyncio + try: + await _asyncio.sleep(0) # yield control back to event loop first + await memory.save_context(user_input, final_text, messages) + if learning_enabled: + # 重建 steps 对象(跨线程安全) + try: + from app.agent_runtime.schemas import AgentStep + _steps = [ + AgentStep( + iteration=s.get("iteration", 0), + type=s.get("type", "final"), + content=s.get("content", ""), + reasoning=s.get("reasoning"), + ) if isinstance(s, dict) else s + for s in steps + ] + from app.services.agent_learning_service import save_learning_pattern + save_learning_pattern( + user_input=user_input, + steps=_steps, + success=True, + iterations_used=iterations_used, + tool_calls_made=tool_calls_made, + ) + except Exception: + pass + await extract_global_knowledge_fn(user_input, final_text, steps, review_score) + except Exception: + pass # 后台任务失败不抛异常,仅静默 + + class AgentRuntime: """ 自主 Agent 运行时。 @@ -139,6 +181,7 @@ class AgentRuntime: permission_level=self.config.tools.permission_level, auto_approve_rules=self.config.tools.auto_approve_rules, deny_tools=self.config.tools.deny_tools, + user_id=self.config.user_id or "", ) self.execution_logger = execution_logger self.on_tool_executed = on_tool_executed @@ -711,7 +754,10 @@ class AgentRuntime: logger.info("Agent 执行工具 [%s]: %s", tname, targs) try: - result = await self.tool_manager.execute(tname, targs) + result = await self.tool_manager.execute( + tname, targs, + skip_approval=tname in self.config.tools.require_approval, + ) except Exception as tool_err: logger.error("工具 '%s' 执行异常: %s", tname, tool_err, exc_info=True) result = json.dumps({ @@ -1106,16 +1152,16 @@ class AgentRuntime: "session_id": self.context.session_id, "token_usage": token_usage_final, } - await self.memory.save_context(user_input, final_text, self.context.messages) - # 保存学习模式 - if self.config.memory.learning_enabled: - await self._save_learning_pattern( - user_input, steps, success=True, - iterations_used=self.context.iteration, - tool_calls_made=self.context.tool_calls_made, - ) - # 提取知识到全局知识池(Agent 间知识共享) - await self._extract_global_knowledge(user_input, final_text, steps, review_score) + # W5.2: post-final 处理后台化 — 连接立即关闭,记忆流水线异步执行 + _captured_steps = [s._asdict() if hasattr(s, '_asdict') else s for s in steps] + _captured_msgs = list(self.context.messages) + asyncio.create_task(_post_final_pipeline( + self.memory, user_input, final_text, _captured_msgs, + self.config.memory.learning_enabled, _captured_steps, + self.context.iteration, self.context.tool_calls_made, + review_score, + self._extract_global_knowledge, + )) return # 有工具调用 → 先记录 assistant 消息 @@ -1255,7 +1301,10 @@ class AgentRuntime: logger.info("Agent 执行工具 [%s]: %s", tname, targs) try: - result = await self.tool_manager.execute(tname, targs) + result = await self.tool_manager.execute( + tname, targs, + skip_approval=tname in self.config.tools.require_approval, + ) except Exception as tool_err: logger.error("工具 '%s' 执行异常: %s", tname, tool_err, exc_info=True) result = json.dumps({ @@ -1391,12 +1440,36 @@ class AgentRuntime: break return msgs + async def _inject_rag_knowledge(self, query: str) -> str: + """V2.x: 从挂载的知识库中检索相关片段,返回注入文本。""" + kb_ids = self.config.memory.kb_ids + if not kb_ids or not query: + return "" + from app.core.database import SessionLocal + from app.services.knowledge_service import rag_query + parts: list[str] = [] + db = SessionLocal() + try: + for kb_id in kb_ids: + try: + result = await rag_query(db, kb_id, query, top_k=self.config.memory.kb_top_k) + if result.get("found") and result.get("context"): + parts.append(f"### 知识库: {kb_id}\n{result['context']}") + except Exception as e: + logger.warning("RAG 检索 KB %s 失败: %s", kb_id, e) + finally: + db.close() + return "\n\n".join(parts) + async def _compose_system_prompt(self, query: str = "") -> str: """使用分层装配构建完整系统提示词。 将静态段 + 动态段并行解析后拼接,替代原先的字符串拼接方式。 返回最终的 system_prompt 字符串。 """ + # V2.x: RAG 知识库检索(两种路径共用) + rag_text = await self._inject_rag_knowledge(query) + if not self._prompt_composer: # 降级:使用原有字符串拼接方式 enriched = self.config.system_prompt.rstrip("\n") @@ -1411,6 +1484,9 @@ class AgentRuntime: memdir_text = await self._inject_memdir_context(query) if memdir_text: enriched += "\n\n" + memdir_text + # V2.x: 注入 RAG 检索结果 + if rag_text: + enriched += "\n\n# Relevant Knowledge (RAG)\n\n" + rag_text try: enriched = knowledge_retriever.inject_knowledge(enriched, query) except Exception: @@ -1491,6 +1567,14 @@ class AgentRuntime: except Exception: pass + # V2.x: RAG 知识库检索(embedding 语义匹配) + if rag_text: + self._prompt_composer.add_dynamic(PromptSection( + "rag_knowledge", + lambda rt=rag_text: rt, + cache_break=True, + )) + # 工具列表段(默认关闭,太长) if d_switches.get("tool_list", False): tool_names = self.tool_manager.tool_names() @@ -1916,6 +2000,9 @@ class _LLMClient: kwargs["max_tokens"] = self._config.max_tokens if self._config.extra_body: kwargs["extra_body"] = self._config.extra_body + # V2.x: 结构化输出 — 透传 response_format 给 OpenAI 兼容 API + if self._config.response_format: + kwargs["response_format"] = self._config.response_format if tools: # Normalize tool schemas to OpenAI format: custom tools from the # marketplace may be stored as {"name":..., "parameters":...} diff --git a/backend/app/agent_runtime/memory.py b/backend/app/agent_runtime/memory.py index b16220d..67bf304 100644 --- a/backend/app/agent_runtime/memory.py +++ b/backend/app/agent_runtime/memory.py @@ -114,13 +114,33 @@ class AgentMemory: if db: db.close() - # 2. 向量检索:查找语义相关的历史对话 - if self.vector_memory_enabled and self.scope_kind and self.scope_id: - vector_text = await self._vector_search(query) - if vector_text: - parts.append(vector_text) + # 2-5. 并行检索(embedding 共享 query,减少串行等待) + import asyncio + tasks = [] + task_labels: list = [] - # 3. P7 文件式记忆:从本地 MEMORY.md 加载 + if self.vector_memory_enabled and self.scope_kind and self.scope_id: + tasks.append(self._vector_search(query)) + task_labels.append("vector") + + if True: # 全局知识检索 + tasks.append(self._global_knowledge_search(query)) + task_labels.append("global") + + if self.parent_agent_id and self.scope_id: + tasks.append(self._parent_knowledge_search(query)) + task_labels.append("parent") + + if tasks: + results = await asyncio.gather(*tasks, return_exceptions=True) + for label, result in zip(task_labels, results): + if isinstance(result, Exception): + logger.warning("记忆检索 %s 失败: %s", label, result) + continue + if result: + parts.append(result) + + # 3. P7 文件式记忆:从本地 MEMORY.md 加载(纯本地,不依赖网络) store = self._get_file_store() if store and store.memory_count > 0 and query: file_results = store.search(query, top_k=3) @@ -135,17 +155,6 @@ class AgentMemory: lines[-1] += f" (匹配度: {score:.2f})" parts.append("\n".join(lines)) - # 4. 全局知识检索:从 GlobalKnowledge 表加载相关条目 - global_text = await self._global_knowledge_search(query) - if global_text: - parts.append(global_text) - - # 5. 父 Agent 知识继承:加载父级经验 - if self.parent_agent_id and self.scope_id: - parent_text = await self._parent_knowledge_search(query) - if parent_text: - parts.append(parent_text) - return "\n\n".join(parts) if parts else "" async def _vector_search(self, query: str = "") -> str: diff --git a/backend/app/agent_runtime/permissions.py b/backend/app/agent_runtime/permissions.py index 8b978ea..649d646 100644 --- a/backend/app/agent_runtime/permissions.py +++ b/backend/app/agent_runtime/permissions.py @@ -226,8 +226,11 @@ class PermissionChecker: message=f"确认编辑操作: {tool_name}", ) - # 未知工具默认放行 - return PermissionResult(action=PermissionAction.ALLOW) + # 未知工具默认拒绝(V2.x 安全加固:原为默认 ALLOW) + return PermissionResult( + action=PermissionAction.DENY, + message=f"未知工具 {tool_name} 不在内置白名单中,已被拒绝。如需使用,请联系管理员添加为自定义工具。", + ) def add_auto_approve_rule(self, rule: AutoApproveRule): """添加自动批准规则""" diff --git a/backend/app/agent_runtime/schemas.py b/backend/app/agent_runtime/schemas.py index e151db1..be0e09d 100644 --- a/backend/app/agent_runtime/schemas.py +++ b/backend/app/agent_runtime/schemas.py @@ -59,6 +59,9 @@ class AgentMemoryConfig(BaseModel): memory_dir_enabled: bool = False # 是否启用文件式自动记忆 memory_dir_path: str = "" # 记忆目录路径(空=自动使用项目 .claude/memory) parent_agent_id: Optional[str] = None # 父 Agent ID(继承经验) + # V2.x: RAG 知识库挂载 + kb_ids: List[str] = Field(default_factory=list, description="挂载的知识库 ID 列表,系统提示词阶段嵌入检索结果") + kb_top_k: int = Field(default=5, description="每个知识库检索 Top-K 片段") # 对话自动压缩 (参考 Claude Code src/services/compact/) compaction: Optional[Any] = None # CompactionConfig — 惰性导入避免循环依赖 @@ -74,6 +77,9 @@ class AgentLLMConfig(BaseModel): max_iterations: int = 15 # ReAct 循环最大步数 request_timeout: float = 120.0 extra_body: Optional[Dict[str, Any]] = None + # V2.x: 结构化输出 (OpenAI-compatible response_format) + # 格式: {"type": "json_object"} 或 {"type": "json_schema", "json_schema": {...}} + response_format: Optional[Dict[str, Any]] = None self_review_threshold: float = 0.6 # self-review 通过阈值(0-1) cache_enabled: bool = False # LLM 响应缓存(默认关闭,语义缓存有风险) cache_ttl_ms: int = 300000 # LLM 缓存 TTL,默认 5 分钟 diff --git a/backend/app/agent_runtime/tool_manager.py b/backend/app/agent_runtime/tool_manager.py index a013335..10f977e 100644 --- a/backend/app/agent_runtime/tool_manager.py +++ b/backend/app/agent_runtime/tool_manager.py @@ -41,7 +41,8 @@ class AgentToolManager: cache_ttl_ms: int = 3600000, permission_level: str = "default", auto_approve_rules: Optional[List[Dict[str, Any]]] = None, - deny_tools: Optional[List[str]] = None): + deny_tools: Optional[List[str]] = None, + user_id: str = ""): self._include_tools: set = set(include_tools or []) self._exclude_tools: set = set(exclude_tools or []) self._cache_enabled = cache_enabled @@ -62,6 +63,7 @@ class AgentToolManager: ) for r in (auto_approve_rules or []) ] + self._user_id = user_id self._permission = PermissionChecker( level=_perm_level, auto_approve_rules=_auto_rules, @@ -131,15 +133,17 @@ class AgentToolManager: for s in self.get_tool_schemas() ] - async def execute(self, name: str, args: Dict[str, Any]) -> str: + async def execute(self, name: str, args: Dict[str, Any], skip_approval: bool = False) -> str: """ - 执行工具调用(带缓存)。 + 执行工具调用(带缓存 + 审批)。 优先查找内置工具,其次查找数据库自定义工具(HTTP / Code)。 Args: name: 工具名称 args: 工具参数字典 + skip_approval: 若为 True 且权限检查返回 ASK,不发起审批直接放行 + (由调用方保证已通过其他途径完成审批) Returns: 工具执行结果的字符串表示 @@ -151,12 +155,27 @@ class AgentToolManager: logger.warning("工具 %s 被权限系统拒绝: %s", name, perm.message) return err if perm.action == PermissionAction.ASK: - err = json.dumps({ - "error": f"工具 {name} 需要用户确认: {perm.message}", - "requires_confirmation": True, - }, ensure_ascii=False) - logger.info("工具 %s 需用户确认: %s", name, perm.message) - return err + if skip_approval: + logger.info("工具 %s 跳过 ASK 审批(调用方已预先审批)", name) + else: + from app.services.approval_manager import approval_manager as _am + logger.info("工具 %s 需用户确认(权限系统 ASK),发起审批: %s", name, perm.message) + approval_req = await _am.submit( + tool_name=name, args=args, + user_id=self._user_id, + timeout_ms=60000, + ) + decision = approval_req.decision + if decision == "denied": + msg = f"工具 {name} 需要用户确认但被拒绝: {perm.message}" + logger.info(msg) + return json.dumps({"error": msg, "requires_confirmation": True}, ensure_ascii=False) + elif decision == "skip": + msg = f"工具 {name} 被用户跳过: {perm.message}" + logger.info(msg) + return json.dumps({"error": msg}, ensure_ascii=False) + # decision == "approved" → 继续执行 + logger.info("工具 %s 审批已通过", name) # 缓存检查 if self._is_cacheable(name): diff --git a/backend/app/api/agent_chat.py b/backend/app/api/agent_chat.py index e3aa01e..dd6baec 100644 --- a/backend/app/api/agent_chat.py +++ b/backend/app/api/agent_chat.py @@ -1050,6 +1050,9 @@ def _build_memory_config_from_node(agent_node_cfg: dict) -> AgentMemoryConfig: # 文件式记忆 (MEMORY.md) memory_dir_enabled=bool(agent_node_cfg.get("memory_dir_enabled", False)), memory_dir_path=str(agent_node_cfg.get("memory_dir_path", "")), + # V2.x: RAG 知识库挂载 + kb_ids=agent_node_cfg.get("kb_ids") or [], + kb_top_k=int(agent_node_cfg.get("kb_top_k", 5)), # 对话压缩 compaction=compaction, ) diff --git a/backend/app/api/agent_monitoring.py b/backend/app/api/agent_monitoring.py index 940a71a..05e1fb8 100644 --- a/backend/app/api/agent_monitoring.py +++ b/backend/app/api/agent_monitoring.py @@ -66,9 +66,10 @@ async def get_tool_usage( @router.get("/daily-trend") async def get_daily_trend( days: int = Query(7, ge=1, le=30, description="统计天数"), + agent_id: Optional[str] = Query(None, description="按 Agent ID 过滤"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """每日 LLM 调用趋势。""" + """每日 LLM 调用趋势(可按 agent_id 过滤)。""" user_id = None if current_user.has_permission("monitoring:view_all") else current_user.id - return AgentMonitoringService.get_daily_trend(db, user_id, days) + return AgentMonitoringService.get_daily_trend(db, user_id, days, agent_id=agent_id) diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 001b1be..5a39e73 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -9,7 +9,7 @@ from typing import List, Optional, Dict, Any, Union from datetime import datetime import logging from app.core.database import get_db -from app.models.agent import Agent +from app.models.agent import Agent, AgentVersion from app.api.auth import get_current_user from app.api.deps import require_workspace_admin, WorkspaceContext from app.models.user import User @@ -43,6 +43,7 @@ class AgentCreate(BaseModel): tags: Optional[List[str]] = None is_public: bool = False is_featured: bool = False + avatar: Optional[str] = None class AgentUpdate(BaseModel): @@ -52,6 +53,7 @@ class AgentUpdate(BaseModel): workflow_config: Optional[Dict[str, Any]] = None status: Optional[str] = None budget_config: Optional[Dict[str, Any]] = None + avatar: Optional[str] = None class SceneTemplateItem(BaseModel): @@ -103,6 +105,7 @@ class AgentResponse(BaseModel): parent_agent_id: Optional[str] = None agent_type: Optional[str] = None category: Optional[str] = None + avatar: Optional[str] = None created_at: datetime updated_at: datetime @@ -188,6 +191,10 @@ async def get_agents( "version": agent.version, "status": agent.status, "user_id": agent.user_id if agent.user_id else None, + "parent_agent_id": agent.parent_agent_id, + "agent_type": agent.agent_type, + "category": agent.category, + "avatar": agent.avatar, "created_at": agent.created_at if agent.created_at else datetime.now(), "updated_at": agent.updated_at if agent.updated_at else datetime.now() }) @@ -251,6 +258,7 @@ async def create_agent( tags=agent_data.tags, is_public=1 if agent_data.is_public else 0, is_featured=1 if agent_data.is_featured else 0, + avatar=agent_data.avatar, ) db.add(agent) db.commit() @@ -374,14 +382,133 @@ async def update_agent( if agent_data.budget_config is not None: agent.budget_config = agent_data.budget_config + + if agent_data.avatar is not None: + agent.avatar = agent_data.avatar db.commit() db.refresh(agent) - + + # V2.x: 自动存版本快照 + _save_agent_snapshot(db, agent, change_note="配置更新") + logger.info(f"用户 {current_user.username} 更新了Agent: {agent.name} ({agent.id})") return agent +# ─── V2.x: Agent 版本快照 ─── + + +def _save_agent_snapshot(db: Session, agent: Agent, change_note: str = "") -> AgentVersion: + """将当前 Agent 状态存为版本快照。""" + snapshot_data = { + "name": agent.name, + "description": agent.description, + "workflow_config": agent.workflow_config, + "budget_config": agent.budget_config, + "status": agent.status, + "avatar": agent.avatar, + } + ver = AgentVersion( + agent_id=agent.id, + version=agent.version, + snapshot=snapshot_data, + change_note=change_note, + ) + db.add(ver) + db.commit() + logger.info("Agent 快照已保存: %s v%s", agent.id[:8], agent.version) + return ver + + +def _rollback_agent(db: Session, agent: Agent, version_record: AgentVersion) -> None: + """从快照恢复 Agent 配置(版本号递增,不退回)。""" + snap = version_record.snapshot + if isinstance(snap, dict): + if "name" in snap: + agent.name = snap["name"] + if "description" in snap: + agent.description = snap["description"] + if "workflow_config" in snap: + agent.workflow_config = snap["workflow_config"] + if "budget_config" in snap: + agent.budget_config = snap["budget_config"] + agent.version += 1 + db.commit() + db.refresh(agent) + # 回滚后也存一个快照,标记来源 + _save_agent_snapshot(db, agent, change_note=f"回滚至 v{version_record.version}") + logger.info("Agent %s 已回滚至 v%s", agent.id[:8], version_record.version) + + +def _delete_agent_cascade(db: Session, agent_id: str) -> None: + """V2.x: 集中式级联删除 — 遍历所有含 agent_id 列的表清理关联数据。 + + 使用 SQL 自省而非硬编码表名,新增关联表不会被遗漏留下孤儿数据。 + """ + from sqlalchemy import inspect as sa_inspect + + inspector = sa_inspect(db.get_bind()) + all_tables = inspector.get_table_names() + + # 需要清理的表(直接 DELETE WHERE agent_id) + delete_tables = [] + # 需要置 NULL 的表(UPDATE SET col = NULL) + nullify_tables: list[tuple[str, str]] = [] + + for table_name in all_tables: + if table_name == "agents": + continue # skip self + try: + cols = [c["name"] for c in inspector.get_columns(table_name)] + except Exception: + continue + + if "agent_id" in cols: + # 判断是否外键:是则直接删,否则置 NULL(避免外键约束报错) + try: + fks = inspector.get_foreign_keys(table_name) + has_fk = any( + fk.get("referred_table") == "agents" + for fk in fks + if "agent_id" in fk.get("constrained_columns", []) + ) + except Exception: + has_fk = False + + if has_fk: + delete_tables.append(table_name) + else: + nullify_tables.append((table_name, "agent_id")) + + # 也检查 main_agent_id / assigned_agent_id / parent_agent_id 等常见列 + for col_name in ("main_agent_id", "assigned_agent_id", "parent_agent_id"): + if col_name in cols and col_name != "agent_id": + nullify_tables.append((table_name, col_name)) + + # 执行 DELETE(先删 FK 关联,避免约束冲突) + for table_name in delete_tables: + try: + db.execute(text(f"DELETE FROM {table_name} WHERE agent_id = :aid"), {"aid": agent_id}) + except Exception as e: + logger.warning("级联删除 %s 失败: %s", table_name, e) + + # 执行 SET NULL + for table_name, col_name in nullify_tables: + try: + db.execute(text(f"UPDATE {table_name} SET {col_name} = NULL WHERE {col_name} = :aid"), {"aid": agent_id}) + except Exception as e: + logger.warning("置 NULL %s.%s 失败: %s", table_name, col_name, e) + + # 也清理 agent_versions(版本快照) + try: + db.execute(text("DELETE FROM agent_versions WHERE agent_id = :aid"), {"aid": agent_id}) + except Exception: + pass + + db.flush() + + @router.delete("/{agent_id}", status_code=status.HTTP_200_OK) async def delete_agent( agent_id: str, @@ -403,29 +530,10 @@ async def delete_agent( agent_name = agent.name - # 1. 清理直接关联的记录(级联删除) - related_tables = [ - "agent_llm_logs", - "agent_permissions", - "agent_schedules", - "executions", - "team_members", - ] - for table in related_tables: - db.execute(text(f"DELETE FROM {table} WHERE agent_id = :aid"), {"aid": agent_id}) - logger.debug(f"已清理 {table} 中 agent_id={agent_id} 的记录") + # V2.x: 集中式级联删除 — 用自省发现含 agent_id 列的表,避免硬编码遗漏 + _delete_agent_cascade(db, agent_id) - # 2. 解除分配关系(设为 NULL) - db.execute( - text("UPDATE goals SET main_agent_id = NULL WHERE main_agent_id = :aid"), - {"aid": agent_id}, - ) - db.execute( - text("UPDATE tasks SET assigned_agent_id = NULL WHERE assigned_agent_id = :aid"), - {"aid": agent_id}, - ) - - # 3. 删除 Agent 自身 + # 删除 Agent 自身 db.delete(agent) db.commit() @@ -457,7 +565,10 @@ async def deploy_agent( agent.status = "published" db.commit() db.refresh(agent) - + + # V2.x: 发布时存快照 + _save_agent_snapshot(db, agent, change_note="发布") + logger.info(f"用户 {current_user.username} 部署了Agent: {agent.name} ({agent_id})") return agent @@ -697,7 +808,7 @@ async def execute_agent( if not check_agent_permission(db, current_user, agent, "execute"): raise HTTPException(status_code=403, detail="无权执行此Agent") - from app.agent_runtime import AgentRuntime, AgentConfig, AgentLLMConfig, AgentToolConfig, AgentMemoryConfig + from app.agent_runtime import AgentRuntime, AgentConfig, AgentLLMConfig, AgentToolConfig, AgentMemoryConfig, AgentBudgetConfig from app.agent_runtime.orchestrator import AgentOrchestrator if req.mode in ("sequential", "pipeline", "debate", "graph"): @@ -716,31 +827,42 @@ async def execute_agent( status="completed", ) + # V2.x 修复: 从 workflow_config 节点中读取 tools/memory/permission 配置, + # 不再使用硬编码默认值。与 agent_chat 使用相同的配置提取逻辑。 + from app.api.agent_chat import _find_agent_node_config, _build_memory_config_from_node + wc = agent.workflow_config or {} nodes = wc.get("nodes", []) - system_prompt = agent.description or "" - model = "deepseek-v4-flash" - provider = "deepseek" - temperature = 0.7 - max_iterations = 10 + agent_node_cfg = _find_agent_node_config(nodes) - for n in nodes: - if n.get("type") not in ("agent", "llm", "template"): - continue - cfg = n.get("data", {}) if isinstance(n, dict) else getattr(n, "data", {}) - system_prompt = cfg.get("system_prompt", "") or system_prompt - model = cfg.get("model", model) - provider = cfg.get("provider", provider) - temperature = float(cfg.get("temperature", temperature)) - max_iterations = int(cfg.get("max_iterations", max_iterations)) - break + system_prompt = agent_node_cfg.get("system_prompt") or agent.description or "" + model = agent_node_cfg.get("model", "deepseek-v4-flash") + provider = agent_node_cfg.get("provider", "deepseek") + temperature = float(agent_node_cfg.get("temperature", 0.7)) + max_iterations = int(agent_node_cfg.get("max_iterations", 10)) + + # 执行预算 + budget = AgentBudgetConfig() + if agent.budget_config and isinstance(agent.budget_config, dict): + bc = agent.budget_config + if bc.get("max_llm_invocations"): + budget.max_llm_invocations = max(1, int(bc["max_llm_invocations"])) + if bc.get("max_tool_calls"): + budget.max_tool_calls = max(1, int(bc["max_tool_calls"])) config = AgentConfig( name=agent.name or "agent", system_prompt=system_prompt, llm=AgentLLMConfig(model=model, provider=provider, temperature=temperature, max_iterations=max_iterations), - tools=AgentToolConfig(), - memory=AgentMemoryConfig(), + tools=AgentToolConfig( + include_tools=agent_node_cfg.get("tools", []), + exclude_tools=agent_node_cfg.get("exclude_tools", []), + permission_level=str(agent_node_cfg.get("permission_level", "default")), + deny_tools=agent_node_cfg.get("deny_tools", []), + auto_approve_rules=agent_node_cfg.get("auto_approve_rules", []), + ), + memory=_build_memory_config_from_node(agent_node_cfg), + budget=budget, user_id=current_user.id, memory_scope_id=str(agent.id), ) @@ -849,6 +971,9 @@ def inherit_agent_knowledge( child = db.query(Agent).filter(Agent.id == agent_id).first() if not child: raise NotFoundError("Agent", agent_id) + # V2.x 安全加固: 校验操作者对子 Agent 有编辑权限 + if not check_agent_permission(db, current_user, child, "edit"): + raise HTTPException(status_code=403, detail="无权修改此 Agent") parent = db.query(Agent).filter(Agent.id == parent_agent_id).first() if not parent: raise NotFoundError("父 Agent", parent_agent_id) @@ -889,3 +1014,71 @@ def get_parent_knowledge_preview( "has_parent_knowledge": bool(context), "context_preview": context[:2000] if context else "", } + + +# ─── V2.x: Agent 版本管理 API ─── + + +@router.get("/{agent_id}/versions") +def list_agent_versions( + agent_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取 Agent 的版本历史(按时间倒序)。""" + agent = db.query(Agent).filter(Agent.id == agent_id).first() + if not agent: + raise NotFoundError("Agent", agent_id) + if not check_agent_permission(db, current_user, agent, "read"): + raise HTTPException(status_code=403, detail="无权查看此Agent") + + versions = ( + db.query(AgentVersion) + .filter(AgentVersion.agent_id == agent_id) + .order_by(AgentVersion.version.desc()) + .limit(50) + .all() + ) + return { + "agent_id": agent_id, + "current_version": agent.version, + "versions": [ + { + "id": v.id, + "version": v.version, + "change_note": v.change_note, + "created_at": v.created_at.isoformat() if v.created_at else None, + } + for v in versions + ], + } + + +@router.post("/{agent_id}/versions/{version_id}/rollback") +def rollback_agent_version( + agent_id: str, + version_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """回滚 Agent 到指定版本(版本号递增,不退回)。""" + agent = db.query(Agent).filter(Agent.id == agent_id).first() + if not agent: + raise NotFoundError("Agent", agent_id) + if not check_agent_permission(db, current_user, agent, "write"): + raise HTTPException(status_code=403, detail="无权修改此Agent") + + ver = db.query(AgentVersion).filter( + AgentVersion.id == version_id, + AgentVersion.agent_id == agent_id, + ).first() + if not ver: + raise NotFoundError(f"版本快照不存在: {version_id}") + + _rollback_agent(db, agent, ver) + return { + "success": True, + "agent_id": agent_id, + "current_version": agent.version, + "rolled_back_from": ver.version, + } diff --git a/backend/app/api/approval.py b/backend/app/api/approval.py index 27170e7..76b6f82 100644 --- a/backend/app/api/approval.py +++ b/backend/app/api/approval.py @@ -27,10 +27,15 @@ async def resolve_approval(approval_id: str, req: ApprovalDecisionRequest, curre - **approved**: 批准执行 - **denied**: 拒绝执行(Agent 会收到拒绝提示) - **skip**: 跳过该工具(Agent 会跳过继续) + + 仅审批发起人或管理员可裁决;其他用户收到 403。 """ - ok = approval_manager.resolve(approval_id, req.decision) + caller_id = str(current_user.id) + ok, err = approval_manager.resolve(approval_id, req.decision, user_id=caller_id) if not ok: - raise HTTPException(status_code=404, detail=f"审批请求不存在或已完成: {approval_id}") + if "无权" in err: + raise HTTPException(status_code=403, detail=err) + raise HTTPException(status_code=404, detail=err or f"审批请求不存在或已完成: {approval_id}") return {"success": True, "approval_id": approval_id, "decision": req.decision} @@ -40,6 +45,9 @@ async def get_approval(approval_id: str, current_user: User = Depends(get_curren req = approval_manager.get_pending(approval_id) if not req: raise HTTPException(status_code=404, detail=f"审批请求不存在或已完成: {approval_id}") + # 仅审批发起人或管理员可查看 + if req.user_id and str(current_user.id) != req.user_id and getattr(current_user, "role", "") != "admin": + raise HTTPException(status_code=403, detail="无权查看此审批请求") return { "approval_id": req.approval_id, "tool_name": req.tool_name, diff --git a/backend/app/api/voice.py b/backend/app/api/voice.py index cf53318..9983138 100644 --- a/backend/app/api/voice.py +++ b/backend/app/api/voice.py @@ -74,6 +74,22 @@ _OPENAI_FALLBACK_VOICE = { "teacher1": "nova", "teacher2": "nova", } +# Edge TTS 语音 → Microsoft 情感风格映射 +# xiaoxiao 支持: gentle, cheerful, sad, angry, fearful, excited, hopeful, whisper, +# newscast, customerservice, narration, lyrical, poetry +_VOICE_STYLE_MAP: dict[str, str] = { + "xiaoxiao": "gentle", # 晓晓 · 温柔 → 温柔风格 + "xiaoyi": "cheerful", # 晓怡 · 活泼 → 开心风格 + "xiaochen": "gentle", # 晓辰 · 温和 + "xiaohan": "cheerful", # 晓涵 · 知性 + "teacher1": "narration", # 晓晓老师 · 旁白风格 + "teacher2": "cheerful", # 晓怡老师 + "yunxi": "narration", # 云希 + "yunyang": "newscast", # 云扬 · 专业播报 + "yunjian": "excited", # 云健 · 激情 + "yunxia": "cheerful", # 云夏 · 可爱 +} + class AsrResponse(BaseModel): """语音识别响应""" @@ -259,7 +275,7 @@ async def text_to_voice( if use_edge: edge_voice = _EDGE_VOICE_MAP.get(voice, "zh-CN-XiaoxiaoNeural") try: - await _edge_tts_synthesize(text, edge_voice, str(filepath), speed=adjusted_speed) + await _edge_tts_synthesize(text, edge_voice, str(filepath), voice, speed=adjusted_speed) logger.info("Edge TTS 完成: user=%s text_len=%d edge_voice=%s speed=%.1f", current_user.id, len(req.text), edge_voice, adjusted_speed) return TtsResponse( audio_url=f"/api/v1/voice/audio/{filename}", @@ -271,20 +287,44 @@ async def text_to_voice( raise HTTPException(status_code=502, detail=f"TTS 服务不可用: {exc}") +def _build_ssml(text: str, voice: str, style: str, speed: float) -> str: + """构建 SSML,添加情感风格、语速控制和句间自然停顿。""" + rate_str = f"{int((speed - 1.0) * 100):+d}%" + # 在句末标点后插入停顿,使语音更自然 + for punct in ("。", "!", "?", ";"): + text = text.replace(punct, f"{punct}") + # 逗号和省略号后短停顿 + for punct in (",", "…"): + text = text.replace(punct, f"{punct}") + return ( + f"" + f"" + f"" + f"" + f"{text}" + f"" + f"" + f"" + f"" + ) + + async def _edge_tts_synthesize( - text: str, voice: str, output_path: str, speed: float = 1.0, + text: str, voice: str, output_path: str, voice_key: str = "xiaoxiao", speed: float = 1.0, ) -> None: - """使用 edge-tts Python 库直接合成语音(无需 CLI 子进程)。""" + """使用 edge-tts Python 库合成语音,通过 SSML 添加情感风格和自然停顿。""" import edge_tts - rate_str = f"{int((speed - 1.0) * 100):+d}%" - communicate = edge_tts.Communicate(text, voice, rate=rate_str) + style = _VOICE_STYLE_MAP.get(voice_key, "gentle") + ssml = _build_ssml(text, voice, style, speed) + communicate = edge_tts.Communicate(ssml, voice) await communicate.save(output_path) if not Path(output_path).is_file(): raise RuntimeError("edge-tts 未生成输出文件") - logger.info("edge-tts CLI 成功: %d bytes", Path(output_path).stat().st_size) + logger.info("edge-tts 成功: voice=%s style=%s size=%d", voice_key, style, Path(output_path).stat().st_size) @router.get("/audio/{filename}") @@ -298,3 +338,31 @@ async def get_tts_audio(filename: str): raise HTTPException(status_code=404, detail="音频文件不存在或已过期") return FileResponse(filepath, media_type="audio/mpeg") + + +async def cleanup_tts_files() -> int: + """清理超过 2 小时的 TTS 输出文件。返回清理文件数。""" + if not _TTS_DIR.exists(): + return 0 + cutoff = time.time() - 7200 # 2 hours ago + deleted = 0 + for f in _TTS_DIR.glob("*.mp3"): + try: + if f.stat().st_mtime < cutoff: + f.unlink() + deleted += 1 + except OSError: + pass + if deleted: + logger.info("TTS 文件清理完成: 删除 %d 个过期文件", deleted) + return deleted + + +async def run_tts_cleanup_loop(): + """每隔 6 小时清理一次过期的 TTS 文件。启动时立即执行一次。""" + while True: + try: + await cleanup_tts_files() + except Exception as e: + logger.warning("TTS 文件清理异常: %s", e) + await asyncio.sleep(21600) # 6 hours diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f12d574..f7b70ed 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -108,6 +108,11 @@ class Settings(BaseSettings): # 或每 T 秒强制刷库一次(保证 WS 可见延迟 ≤ ~1.5s;WS 轮询 0.8s) COMPANY_EVENT_FLUSH_INTERVAL_SEC: float = 0.7 + # ── 安全:自定义代码工具开关 ── + # 自定义 code 工具在主进程 exec,存在沙箱逃逸风险。 + # 默认关闭。仅管理员确认环境安全后手动设为 True 开启。 + ALLOW_CUSTOM_CODE_TOOLS: bool = False + # ── 虚拟公司:并发上限(三档-b)── # 同时运行的公司项目数上限;超出的排队(真实队列),空出名额再启动。调高≈无限 MAX_CONCURRENT_COMPANY_PROJECTS: int = 3 diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 6064440..4e1d0db 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -137,6 +137,21 @@ def _run_safe_migrations(): # v1.5: 虚拟公司进阶字段 "ALTER TABLE company_projects ADD COLUMN review_scores JSON NULL", "ALTER TABLE company_projects ADD COLUMN round_count VARCHAR(10) DEFAULT '1'", + # v2.2: 验证闭环 — 机器验证数据采集 + "ALTER TABLE company_projects ADD COLUMN validation_report JSON NULL", + "ALTER TABLE company_projects ADD COLUMN validation_pass_rate DOUBLE NULL", + # v2.4: round_count String → Integer + "ALTER TABLE company_projects MODIFY COLUMN round_count INT DEFAULT 1", + # v2.x: Agent 版本快照 + """CREATE TABLE IF NOT EXISTS agent_versions ( + id CHAR(36) PRIMARY KEY, + agent_id CHAR(36) NOT NULL, + version INT NOT NULL, + snapshot JSON NOT NULL, + change_note VARCHAR(200) NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_av_agent (agent_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", ] for raw_sql in migrations: try: diff --git a/backend/app/main.py b/backend/app/main.py index cd3f171..e18a28d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -369,7 +369,7 @@ async def root(): @app.get("/health") async def health_check(): - """健康检查(含内置工具是否已注册,便于排查「可动手 Agent」)。""" + """健康检查(含内置工具 + V3.0 Redis 多 worker 状态)。""" from app.services.tool_registry import tool_registry names = tool_registry.builtin_tool_names() @@ -379,18 +379,39 @@ async def health_check(): expected_ok = count >= 10 tools_ready = expected_ok and subset_ok + # V3.0: Redis 多 worker 状态 + redis_ok = False + workers = [] + running_tasks = [] + try: + from app.services.redis_task_registry import _redis_available, list_active_workers, list_running_tasks + redis_ok = await _redis_available() + if redis_ok: + workers = await list_active_workers() + running_tasks = await list_running_tasks() + except Exception: + pass + return { "status": "healthy", "checks": { "builtin_tools_ready": tools_ready, "builtin_tools_count_ok": expected_ok, "file_agent_core_ready": subset_ok, + "redis_available": redis_ok, }, "builtin_tools": { "count": count, "names": names, "expected_min_count": 10, }, + "v3_workers": { + "redis_ok": redis_ok, + "active_workers": len(workers), + "running_tasks": len(running_tasks), + "workers": workers, + "tasks": running_tasks, + }, "notes": { "celery": "工作流/Agent 执行通常在 Celery Worker 中跑;Worker 日志中也应出现「内置工具就绪」且 count 应与 API 一致。若仅 API 有工具而 Worker 无,会出现 LLM 无法真正调用 file_write。", }, @@ -527,6 +548,33 @@ async def startup_event(): except Exception as e: logger.error(f"公司定时调度器启动失败: {e}") + # 启动 TTS 文件定期清理(每 6 小时清除超过 2 小时的 mp3) + try: + from app.api.voice import run_tts_cleanup_loop + asyncio.ensure_future(run_tts_cleanup_loop()) + logger.info("TTS 文件清理任务已启动") + except Exception as e: + logger.error(f"TTS 文件清理任务启动失败: {e}") + + # V3.0: 注册本 worker 到 Redis 任务注册表(多 worker 安全) + try: + from app.services.redis_task_registry import register_worker + ok = await register_worker() + if ok: + logger.info("Worker 已注册到 Redis 任务注册表") + # 启动周期心跳(每 50s 续期,TTL=120s) + async def _worker_heartbeat_loop(): + while True: + await asyncio.sleep(50) + try: + from app.services.redis_task_registry import worker_heartbeat + await worker_heartbeat() + except Exception: + pass + asyncio.ensure_future(_worker_heartbeat_loop()) + except Exception as e: + logger.warning("Worker Redis 注册失败 (%s),降级为单 worker 模式", e) + # 注册路由 from app.api import auth, workspaces, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_sessions, agent_branches, agent_monitoring, knowledge_base, knowledge_dashboard, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market, goals, tasks, system_logs, audit_logs, feedback, agent_swarm, push, voice, fcm, scene_contracts, teams, agent_memory, legal, app_update, analytics, billing, users, api_keys, company_presets, companies, company_ws @@ -590,6 +638,13 @@ app.include_router(company_ws.router) @app.on_event("shutdown") async def shutdown_event(): """应用关闭事件""" + # V3.0: 注销本 worker + try: + from app.services.redis_task_registry import unregister_worker + await unregister_worker() + logger.info("Worker 已从 Redis 注销") + except Exception as e: + logger.warning("Worker 注销失败: %s", e) try: from app.services.scheduler import shutdown_scheduler shutdown_scheduler() diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py index d653f70..b41c185 100644 --- a/backend/app/models/agent.py +++ b/backend/app/models/agent.py @@ -33,6 +33,7 @@ class Agent(Base): category = Column(String(50), nullable=True, comment="分类: llm/data_processing/automation/integration/other") tags = Column(JSON, nullable=True, comment="标签列表") thumbnail = Column(Text, nullable=True, comment="缩略图URL") + avatar = Column(Text, nullable=True, comment="头像(emoji 或图片URL)") is_public = Column(Integer, default=0, comment="是否公开到市场: 0=私有 1=公开") is_featured = Column(Integer, default=0, comment="是否精选: 0=否 1=是") rating_avg = Column(String(10), default="0.0", comment="平均评分") @@ -52,6 +53,18 @@ class Agent(Base): return f"" +class AgentVersion(Base): + """Agent 版本快照 — 保存/发布时自动存快照,支持回滚""" + __tablename__ = "agent_versions" + + id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="版本ID") + agent_id = Column(CHAR(36), ForeignKey("agents.id", ondelete="CASCADE"), nullable=False, index=True, comment="所属Agent") + version = Column(Integer, nullable=False, comment="版本号") + snapshot = Column(JSON, nullable=False, comment="完整快照: {name, description, workflow_config, budget_config, ...}") + change_note = Column(String(200), nullable=True, comment="变更说明") + created_at = Column(DateTime, default=func.now(), comment="快照时间") + + class AgentExtension(Base): """Agent 自主扩展记录表 — 用于 extension_log 工具""" __tablename__ = "agent_extensions" diff --git a/backend/app/models/company.py b/backend/app/models/company.py index 68da224..4fa3788 100644 --- a/backend/app/models/company.py +++ b/backend/app/models/company.py @@ -3,7 +3,7 @@ 公司 = 多个部门(Team) + CEO Agent + 战略规划能力 """ -from sqlalchemy import Column, String, Text, Integer, DateTime, JSON, ForeignKey, Boolean, Index, func +from sqlalchemy import Column, String, Text, Integer, DateTime, JSON, Float, ForeignKey, Boolean, Index, func from sqlalchemy.dialects.mysql import CHAR from sqlalchemy.orm import relationship from app.core.database import Base @@ -68,7 +68,9 @@ class CompanyProject(Base): description = Column(Text, comment="项目描述(CEO级目标)") ceo_plan = Column(JSON, nullable=True, comment="CEO 规划结果(DepartmentPlan[])") review_scores = Column(JSON, nullable=True, comment="CEO审查打分结果") - round_count = Column(String(10), nullable=True, default="1", comment="执行迭代轮次数") + validation_report = Column(JSON, nullable=True, comment="机器验证完整报告") + validation_pass_rate = Column(Float, nullable=True, comment="首轮验证通过率 0.0-1.0") + round_count = Column(Integer, nullable=True, default=1, comment="执行迭代轮次数") status = Column(String(30), default="created", comment="状态: created/planning/in_progress/completed/failed") config = Column(JSON, nullable=True, comment="项目配置(含 is_public 标记)") created_at = Column(DateTime, default=func.now(), comment="创建时间") @@ -92,6 +94,8 @@ class CompanyProject(Base): "description": self.description, "ceo_plan": self.ceo_plan, "review_scores": self.review_scores, + "validation_report": self.validation_report, + "validation_pass_rate": self.validation_pass_rate, "round_count": self.round_count, "status": self.status, "config": self.config, diff --git a/backend/app/services/agent_monitoring_service.py b/backend/app/services/agent_monitoring_service.py index da3a3b7..51a0687 100644 --- a/backend/app/services/agent_monitoring_service.py +++ b/backend/app/services/agent_monitoring_service.py @@ -202,14 +202,17 @@ class AgentMonitoringService: db: Session, user_id: Optional[str] = None, days: int = 7, + agent_id: Optional[str] = None, ) -> List[Dict[str, Any]]: - """获取每日 LLM 调用趋势。""" + """获取每日 LLM 调用趋势,可按 agent_id 过滤。""" end_time = datetime.utcnow() start_time = end_time - timedelta(days=days) filters = [AgentLLMLog.created_at >= start_time] if user_id: filters.append(AgentLLMLog.user_id == user_id) + if agent_id: + filters.append(AgentLLMLog.agent_id == agent_id) results = [] for i in range(days): diff --git a/backend/app/services/agent_profiler.py b/backend/app/services/agent_profiler.py new file mode 100644 index 0000000..af36e94 --- /dev/null +++ b/backend/app/services/agent_profiler.py @@ -0,0 +1,242 @@ +""" +Agent 能力画像 — 基于历史 review_scores 构建部门/Agent 能力画像。 + +数据来源: company_projects.review_scores JSON +用途: 注入 CEO 规划 prompt (任务分配优化) 和评审 prompt (上下文) +""" +from __future__ import annotations + +import logging +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy.orm import Session + +from app.models.company import CompanyProject + +logger = logging.getLogger(__name__) + + +@dataclass +class DeptProfile: + """部门能力画像""" + department_name: str + total_projects: int = 0 + avg_score: float = 0.0 + pass_rate: float = 0.0 + # 按任务类型细分 + by_task_type: Dict[str, Dict[str, Any]] = field(default_factory=dict) + # 近期趋势 (最近 5 个项目) + recent_scores: List[float] = field(default_factory=list) + recent_trend: str = "" # improving / declining / stable + + +@dataclass +class AgentProfile: + """综合能力画像""" + departments: Dict[str, DeptProfile] = field(default_factory=dict) + generated_at: str = "" + total_projects_analyzed: int = 0 + + +class AgentProfiler: + """基于历史 review_scores 构建 Agent/部门能力画像。 + + 只读 company_projects 表,全统计无外部依赖。 + """ + + @staticmethod + def get_profile( + db: Session, + user_id: str, + workspace_id: Optional[str] = None, + lookback_days: int = 90, + ) -> AgentProfile: + """获取用户下所有部门的历史能力画像。 + + Args: + db: 数据库会话 + user_id: 用户 ID (通过 company.user_id 过滤) + workspace_id: 可选的工作区过滤 + lookback_days: 回溯天数,默认 90 + + Returns: + AgentProfile 含各部门画像 + """ + cutoff = datetime.now() - timedelta(days=lookback_days) + + query = db.query(CompanyProject).join( + CompanyProject.company + ).filter( + CompanyProject.review_scores.isnot(None), + CompanyProject.created_at >= cutoff, + ) + + # company.user_id 过滤 + from app.models.company import Company + query = query.filter(Company.user_id == user_id) + + if workspace_id: + query = query.filter(Company.workspace_id == workspace_id) + + projects = query.all() + + # 聚合统计 + dept_scores: Dict[str, List[Dict]] = defaultdict(list) + for p in projects: + scores = p.review_scores or [] + if isinstance(scores, dict): + scores = [scores] + for s in scores: + name = s.get("name", "") + if name: + dept_scores[name].append({ + "score": s.get("score", 0), + "passed": s.get("pass", False), + "date": p.created_at, + "project_name": p.name, + # 从 ceo_plan 或 config 中推断任务类型 + "task_type": AgentProfiler._infer_task_type(p), + }) + + profile = AgentProfile( + generated_at=datetime.now().isoformat(), + total_projects_analyzed=len(projects), + ) + + for dept_name, records in dept_scores.items(): + if not records: + continue + + scores_list = [r["score"] for r in records] + passed_count = sum(1 for r in records if r["passed"]) + + dp = DeptProfile( + department_name=dept_name, + total_projects=len(records), + avg_score=round(sum(scores_list) / len(scores_list), 1) if scores_list else 0.0, + pass_rate=round(passed_count / len(records), 2) if records else 0.0, + recent_scores=sorted(scores_list, key=lambda x: 0)[-5:], + ) + + # 按任务类型统计 + type_scores: Dict[str, List[float]] = defaultdict(list) + type_passes: Dict[str, List[bool]] = defaultdict(list) + for r in records: + tt = r.get("task_type", "general") + type_scores[tt].append(r["score"]) + type_passes[tt].append(r["passed"]) + + for tt, t_scores in type_scores.items(): + t_passes = type_passes.get(tt, []) + dp.by_task_type[tt] = { + "count": len(t_scores), + "avg_score": round(sum(t_scores) / len(t_scores), 1), + "pass_rate": round(sum(1 for p in t_passes if p) / len(t_passes), 2) if t_passes else 0.0, + } + + # 近期趋势:比较前 50% 和后 50% 的均分 + sorted_records = sorted(records, key=lambda r: r["date"] if r["date"] else datetime.min) + half = max(1, len(sorted_records) // 2) + first_half = [r["score"] for r in sorted_records[:half]] + second_half = [r["score"] for r in sorted_records[half:]] + first_avg = sum(first_half) / len(first_half) if first_half else 0 + second_avg = sum(second_half) / len(second_half) if second_half else 0 + diff = second_avg - first_avg + if diff > 0.5: + dp.recent_trend = "improving" + elif diff < -0.5: + dp.recent_trend = "declining" + else: + dp.recent_trend = "stable" + + profile.departments[dept_name] = dp + + return profile + + @staticmethod + def _infer_task_type(project: CompanyProject) -> str: + """从项目名/描述推断任务类型 (启发式)。""" + name = (project.name or "").lower() + desc = (project.description or "").lower() + combined = f"{name} {desc}" + + if any(kw in combined for kw in ["网页", "html", "页面", "落地页", "landing", "网站", "web"]): + return "web_page" + if any(kw in combined for kw in ["文档", "报告", "分析", "document", "report", "analysis"]): + return "document" + if any(kw in combined for kw in ["产品", "product", "app", "应用", "软件"]): + return "product" + if any(kw in combined for kw in ["营销", "market", "推广", "广告", "campaign"]): + return "marketing" + if any(kw in combined for kw in ["数据", "data", "api", "接口"]): + return "data_api" + return "general" + + @staticmethod + def format_for_ceo_prompt(profile: AgentProfile, max_depts: int = 6) -> str: + """将画像格式化为可注入 CEO 规划 prompt 的文本摘要。""" + if not profile.departments: + return "" + + dept_entries = sorted( + profile.departments.items(), + key=lambda kv: kv[1].total_projects, + reverse=True, + )[:max_depts] + + lines = ["## Agent Historical Capability Profiles"] + lines.append(f"(Based on {profile.total_projects_analyzed} projects in the lookback period)\n") + + for dept_name, dp in dept_entries: + trend_icon = {"improving": "↑", "declining": "↓", "stable": "→"}.get(dp.recent_trend, "") + lines.append(f"### {dept_name}") + lines.append(f"- Overall: {dp.avg_score}/10 avg score, {dp.pass_rate:.0%} pass rate, {dp.total_projects} projects {trend_icon}") + + # 按任务类型细分 + if dp.by_task_type: + strengths = [] + weaknesses = [] + for tt, stats in sorted(dp.by_task_type.items(), key=lambda kv: -kv[1]["avg_score"]): + label = { + "web_page": "web page", "document": "document/report", + "product": "product", "marketing": "marketing", + "data_api": "data/API", "general": "general", + }.get(tt, tt) + line = f"{label}: {stats['avg_score']}/10 ({stats['count']}x)" + if stats["avg_score"] >= 7.0: + strengths.append(line) + elif stats["avg_score"] <= 5.0: + weaknesses.append(line) + + if strengths: + lines.append(f"- Strengths: {'; '.join(strengths)}") + if weaknesses: + lines.append(f"- Weaknesses: {'; '.join(weaknesses)}") + + lines.append("") + + lines.append("Use these profiles to: assign tasks matching department strengths; consider stronger models for weakness areas; flag departments with declining trends for extra review.\n") + return "\n".join(lines) + + @staticmethod + def format_for_review_prompt(profile: AgentProfile, dept_name: str) -> str: + """格式化单个部门的画像,注入评审 prompt。""" + dp = profile.departments.get(dept_name) + if not dp: + return "" + + lines = [f"Historical profile for {dept_name}:"] + lines.append(f"- All-time avg: {dp.avg_score}/10 ({dp.total_projects} projects), pass rate: {dp.pass_rate:.0%}") + lines.append(f"- Recent trend: {dp.recent_trend}") + + if dp.by_task_type: + type_summary = ", ".join( + f"{tt}: {s['avg_score']}/10" + for tt, s in sorted(dp.by_task_type.items(), key=lambda kv: -kv[1]["count"])[:3] + ) + lines.append(f"- By task type: {type_summary}") + + return " | ".join(lines) diff --git a/backend/app/services/approval_manager.py b/backend/app/services/approval_manager.py index d2b867a..c709fbc 100644 --- a/backend/app/services/approval_manager.py +++ b/backend/app/services/approval_manager.py @@ -1,21 +1,34 @@ -"""工具级人工审批管理器 — asyncio.Event 驱动的异步审批等待/唤醒""" +"""工具级人工审批管理器 — asyncio.Event 驱动的异步审批等待/唤醒。 + +V2.x 安全加固: +- approval_id 改用完整 UUID4(原 8 位可枚举) +- ApprovalRequest 记录 user_id,resolve 时校验归属 +- _pending 同步持久化到 Redis(可选),重启/多副本可恢复 +""" from __future__ import annotations import asyncio +import json import logging +import time import uuid from dataclasses import dataclass, field from typing import Any, Dict, Optional logger = logging.getLogger(__name__) +# Redis key 前缀 +_REDIS_PENDING_PREFIX = "approval:pending:" +_REDIS_TTL = 3600 # 审批请求 1 小时过期 + @dataclass class ApprovalRequest: approval_id: str tool_name: str args: Dict[str, Any] + user_id: str = "" event: asyncio.Event = field(default_factory=asyncio.Event) decision: str = "deny" # approved | denied | skip @@ -25,19 +38,54 @@ class ApprovalManager: _pending: Dict[str, ApprovalRequest] = {} - def create(self, tool_name: str, args: Dict[str, Any]) -> ApprovalRequest: + # ─── Redis 持久化辅助 ─── + + async def _redis_save(self, req: ApprovalRequest) -> None: + """将审批请求持久化到 Redis(可选,不可用时静默跳过)。""" + try: + from app.services.redis_task_registry import _get_redis + r = await _get_redis() + await r.set( + _REDIS_PENDING_PREFIX + req.approval_id, + json.dumps({ + "approval_id": req.approval_id, + "tool_name": req.tool_name, + "args": req.args, + "user_id": req.user_id, + "created_at": time.time(), + }), + ex=_REDIS_TTL, + ) + except Exception: + pass + + async def _redis_delete(self, approval_id: str) -> None: + try: + from app.services.redis_task_registry import _get_redis + r = await _get_redis() + await r.delete(_REDIS_PENDING_PREFIX + approval_id) + except Exception: + pass + + def create(self, tool_name: str, args: Dict[str, Any], user_id: str = "") -> ApprovalRequest: """创建审批请求(不等待),返回 ApprovalRequest(含 approval_id)。 用于流式场景:先 create → yield SSE 事件(带 approval_id)→ wait_for_decision。 """ - approval_id = str(uuid.uuid4())[:8] + approval_id = str(uuid.uuid4()) # V2.x: 全长 UUID4,不可枚举 req = ApprovalRequest( approval_id=approval_id, tool_name=tool_name, args=args, + user_id=user_id, ) self._pending[approval_id] = req - logger.info("审批请求已创建: id=%s tool=%s", approval_id, tool_name) + # 异步持久化到 Redis(fire-and-forget) + try: + asyncio.get_event_loop().create_task(self._redis_save(req)) + except Exception: + pass + logger.info("审批请求已创建: id=%s tool=%s user=%s", approval_id[:8], tool_name, user_id) return req async def wait_for_decision(self, approval_id: str, timeout_ms: int = 60000) -> str: @@ -48,42 +96,51 @@ class ApprovalManager: try: await asyncio.wait_for(req.event.wait(), timeout=timeout_ms / 1000.0) except asyncio.TimeoutError: - logger.warning("审批请求超时: id=%s", approval_id) + logger.warning("审批请求超时: id=%s", approval_id[:8]) req.decision = "deny" self._pending.pop(approval_id, None) + await self._redis_delete(approval_id) return req.decision - async def submit(self, tool_name: str, args: Dict[str, Any], timeout_ms: int = 60000) -> ApprovalRequest: + async def submit(self, tool_name: str, args: Dict[str, Any], user_id: str = "", timeout_ms: int = 60000) -> ApprovalRequest: """提交审批请求并等待决策(带超时)— 非流式场景一步完成。 Returns: ApprovalRequest(含 decision 字段,调用方读取即可) """ - req = self.create(tool_name, args) + req = self.create(tool_name, args, user_id=user_id) try: await asyncio.wait_for(req.event.wait(), timeout=timeout_ms / 1000.0) except asyncio.TimeoutError: - logger.warning("审批请求超时: id=%s tool=%s", req.approval_id, tool_name) + logger.warning("审批请求超时: id=%s tool=%s", req.approval_id[:8], tool_name) req.decision = "deny" self._pending.pop(req.approval_id, None) + await self._redis_delete(req.approval_id) return req - def resolve(self, approval_id: str, decision: str) -> bool: + def resolve(self, approval_id: str, decision: str, user_id: str = "") -> tuple[bool, str]: """外部(API)调用,写入审批决定并唤醒等待方。 + V2.x: 校验 user_id 归属——只有审批发起人可裁决自己的审批。 + 管理员可裁决任意审批(user_id="" 跳过校验)。 + Returns: - True 表示成功唤醒,False 表示审批 ID 无效或已完成。 + (是否成功, 错误消息) """ req = self._pending.get(approval_id) if not req: - logger.warning("审批 ID 无效或已完成: %s", approval_id) - return False + logger.warning("审批 ID 无效或已完成: %s", approval_id[:8]) + return False, "审批请求不存在或已完成" + # 归属校验:非管理员只能裁决自己的审批 + if user_id and req.user_id and req.user_id != user_id: + logger.warning("审批归属不匹配: id=%s owner=%s caller=%s", approval_id[:8], req.user_id, user_id) + return False, "无权裁决此审批请求" if decision not in ("approved", "denied", "skip"): decision = "deny" req.decision = decision req.event.set() - logger.info("审批已解决: id=%s decision=%s", approval_id, decision) - return True + logger.info("审批已解决: id=%s decision=%s", approval_id[:8], decision) + return True, "" def get_pending(self, approval_id: str) -> Optional[ApprovalRequest]: """查询待审批请求(用于前端展示详情)。""" diff --git a/backend/app/services/company_orchestrator.py b/backend/app/services/company_orchestrator.py index 33528e2..4454ef1 100644 --- a/backend/app/services/company_orchestrator.py +++ b/backend/app/services/company_orchestrator.py @@ -12,6 +12,7 @@ from __future__ import annotations import asyncio import json import logging +import time as _time import re import difflib from datetime import datetime @@ -67,7 +68,16 @@ Output format — respond with ONLY the JSON object below, no markdown, no expla "department_name": "Department name (exact match from the list above)", "department_type": "executive/product/engineering/marketing/operations/sales/finance/hr", "goal": "Specific goal for this department — MUST be achievable by its available_roles", - "deliverables": ["deliverable 1", "deliverable 2"], + "deliverables": [ + {{ + "name": "Specific deliverable name", + "type": "document|html|code|other", + "acceptance_criteria": [ + "Criterion 1 — MUST be verifiable (not vague like 'do well')", + "Criterion 2 — e.g. 'JS 无语法错误', '含至少 3 个价格方案', 'body 文本 >= 300 字'" + ] + }} + ], "priority": 1, "dependencies": ["other_department_name or empty"], "key_questions": ["question this department should answer"] @@ -75,12 +85,20 @@ Output format — respond with ONLY the JSON object below, no markdown, no expla ] }} +ACCEPTANCE CRITERIA RULES: +- Each deliverable MUST have at least 2 acceptance criteria +- Criteria MUST be verifiable — avoid vague terms like "good quality" or "complete" +- Machine-verifiable examples: "JS 无语法错误", "HTML body 非空", "文档 >= 300 字" +- LLM-verifiable examples: "首屏含产品名称", "至少 3 个价格方案", "含目标用户画像" +- type MUST be one of: document, html, code, other + CRITICAL RULES: - Output ONLY the JSON object. No prefix text, no suffix text, no markdown code fences. - Only include departments that are actually needed for this objective - department_name MUST exactly match the department name from the list above - Assign priority 1 to the most critical/blocking departments - Be specific about deliverables — vague goals lead to poor execution +- P1-24: Each concrete deliverable (HTML prototype, design, code module) MUST be assigned to ONE department only. The downstream department's role is to QA/integrate/extend, NOT to independently rebuild the same artifact. Use dependencies to ensure downstream reuses upstream file paths. - Dependencies should be department names (not agent names) - DO NOT plan tasks that require roles not in the department's available_roles list""" @@ -128,7 +146,10 @@ def _parse_ceo_json(raw_output: str) -> Dict[str, Any]: break logger.warning("Failed to parse CEO JSON plan from output: %.500s", text) - return {} + raise ValueError( + f"CEO 规划解析失败:无法从 LLM 输出中提取有效的 JSON 计划。" + f"原始输出前 500 字符: {text[:500]}" + ) # ─── 四档-②:历史知识相关性筛选(学习闭环)─── @@ -149,36 +170,87 @@ _MEMORY_CATEGORY_META = { } -def _select_relevant_knowledge( +async def _select_relevant_knowledge( entries: List[Dict[str, Any]], project_desc: str, max_entries: int, max_chars: int, ) -> str: - """从本公司历史知识里挑与新项目相关的,格式化成注入 CEO prompt 的「历史项目经验」块。 + """V2.25.6: 语义检索升级 — embedding 相似度 + 关键词匹配加权融合。 - 纯函数(不碰 DB),便于隔离测试。相关性 = 领域词在知识正文命中 + tag 命中新项目描述; - 再叠加 category 权重(review/insight 优先)。**topical<=0 的条目不注入**(只喂真相关的,避免噪声)。 + 从本公司历史知识里挑与新项目语义相关的,格式化成注入 CEO prompt 的「历史项目经验」块。 + 优先使用 embedding 语义相似度(权重 0.6),辅以关键词命中(权重 0.4); + embedding API 不可用时自动降级为纯关键词匹配。 无相关条目 → 返回 ""(回归旧行为,不改 CEO prompt)。 """ if not entries or max_entries <= 0 or max_chars <= 0: return "" - desc = project_desc or "" + desc = project_desc or "" if project_desc else "" + + # ─── 关键词分数(始终可用) ─── proj_terms = [kw for kw in _MEMORY_DOMAIN_KEYWORDS if kw in desc] - scored = [] - for e in entries: + def _keyword_score(e: Dict[str, Any]) -> float: + title = (e.get("title") or "").strip() + content = (e.get("content") or "").strip() + tags = e.get("tags") or [] + hay = f"{title}\n{content}" + keyword_hits = sum(1 for t in proj_terms if t in hay) + tag_hits = sum(1 for tg in tags if tg and str(tg) in desc) + return keyword_hits * 1.0 + tag_hits * 2.0 + + # ─── Embedding 语义相似度(尝试) ─── + query_emb: Optional[List[float]] = None + emb_scores: Dict[int, float] = {} + try: + from app.services.embedding_service import embedding_service + query_emb = await embedding_service.generate_embedding(desc[:2000]) + except Exception: + pass + + if query_emb: + # 为每个条目生成 embedding 并计算余弦相似度(上限 20 个,控制 API 调用次数) + import math + sample_entries = entries[: min(len(entries), 20)] + for i, e in enumerate(sample_entries): + try: + title = (e.get("title") or "").strip() + content = (e.get("content") or "").strip() + hay = f"{title}\n{content}"[:2000] + if not hay.strip(): + continue + entry_emb = await embedding_service.generate_embedding(hay) + if not entry_emb: + continue + # 余弦相似度 + dot = sum(a * b for a, b in zip(query_emb, entry_emb)) + norm_q = math.sqrt(sum(a * a for a in query_emb)) + norm_e = math.sqrt(sum(b * b for b in entry_emb)) + if norm_q > 0 and norm_e > 0: + emb_scores[i] = dot / (norm_q * norm_e) + except Exception: + continue + + # ─── 融合打分 ─── + scored: List[tuple] = [] + for i, e in enumerate(entries): title = (e.get("title") or "").strip() content = (e.get("content") or "").strip() category = e.get("category") or "general" - tags = e.get("tags") or [] hay = f"{title}\n{content}" - # 主题命中:领域词出现在知识正文 + 该知识 tag 出现在新项目描述 - keyword_hits = sum(1 for t in proj_terms if t in hay) - tag_hits = sum(1 for tg in tags if tg and str(tg) in desc) - topical = keyword_hits * 1.0 + tag_hits * 2.0 - if topical <= 0: - continue + kw = _keyword_score(e) + emb = emb_scores.get(i, 0.0) + # 加权融合:embedding 有值时用 6:4 混合,否则纯关键词 + if query_emb is not None and emb_scores: + topical = 0.6 * emb + 0.4 * (kw / max(1.0, kw + 2.0)) # 归一化关键词分 + # 至少关键词命中 1 次才考虑(防噪声) + if kw <= 0 and emb < 0.3: + continue + else: + # 降级:纯关键词 + topical = kw + if topical <= 0: + continue _, cat_w = _MEMORY_CATEGORY_META.get(category, ("经验", 0.5)) final = topical + cat_w scored.append((final, e.get("_recency", 0), e)) @@ -279,7 +351,7 @@ class CompanyOrchestrator: pass logger.warning("自动沉淀知识失败(忽略): project=%s err=%s", getattr(project, "id", "?"), e) - def _load_ceo_memory(self, project_desc: str) -> str: + async def _load_ceo_memory(self, project_desc: str) -> str: """四档-②:检索本公司历史知识,返回注入 CEO prompt 的「历史项目经验」块(或 "")。 受 settings 门控与上限约束;DB/检索异常一律降级为空串(学习闭环失败不阻断规划)。 @@ -308,7 +380,7 @@ class CompanyOrchestrator: } for r in rows ] - return _select_relevant_knowledge( + return await _select_relevant_knowledge( entries, project_desc, int(settings.COMPANY_CEO_MEMORY_MAX_ENTRIES), @@ -353,11 +425,22 @@ class CompanyOrchestrator: ceo_prompt = CEO_SYSTEM_PROMPT.replace("{departments_desc}", dept_desc_str) # 四档-②:学习闭环——把本公司历史项目知识按相关性注入 CEO prompt - memory_block = self._load_ceo_memory(project_desc) + memory_block = await self._load_ceo_memory(project_desc) if memory_block: ceo_prompt = f"{ceo_prompt}\n\n{memory_block}" logger.info("CEO 规划注入历史知识: company=%s chars=%d", self.company_id, len(memory_block)) + # V2.25.3: Agent 能力画像 — 注入历史部门表现 + try: + from app.services.agent_profiler import AgentProfiler + agent_profile = AgentProfiler.get_profile(self.db, self.user_id) + profile_block = AgentProfiler.format_for_ceo_prompt(agent_profile) + if profile_block: + ceo_prompt = f"{ceo_prompt}\n\n{profile_block}" + logger.info("CEO 规划注入能力画像: company=%s depts=%d", self.company_id, len(agent_profile.departments)) + except Exception: + logger.warning("Agent profile injection failed, continuing without", exc_info=True) + # Create runtime config wf = ceo_agent.workflow_config or {} nodes = wf.get("nodes", []) @@ -368,15 +451,21 @@ class CompanyOrchestrator: break llm_data = llm_node.get("data", {}) if llm_node else {} + provider = llm_data.get("provider", "deepseek") + temperature = float(llm_data.get("temperature", 0.4)) + # Kimi K3 等 moonshot 推理模型强制要求 temperature=1 + if provider == "moonshot": + temperature = 1.0 + config = AgentConfig( name=ceo_agent.name, system_prompt=ceo_prompt, user_id=self.user_id, memory_scope_id=f"company_ceo_{self.company_id}", llm=AgentLLMConfig( - provider=llm_data.get("provider", "deepseek"), + provider=provider, model=llm_data.get("model", "deepseek-v4-pro"), - temperature=float(llm_data.get("temperature", 0.4)), + temperature=temperature, max_tokens=4096, # Ensure enough output for full department plan max_iterations=3, # CEO planning should be concise ), @@ -388,9 +477,146 @@ class CompanyOrchestrator: result = await runtime.run(project_desc) plan = _parse_ceo_json(result.content) + # V2.25.4: 规划自我批判 — 批评者审视 plan v1 → CEO 修订出 v2 + try: + plan = await self._critique_and_revise_plan(plan, project_desc, ceo_prompt, llm_data) + except Exception: + logger.warning("Plan critique failed, using v1 as-is", exc_info=True) + logger.info("CEO 规划完成: company=%s departments=%d", self.company_id, len(plan.get("departments", []))) return plan + async def _critique_and_revise_plan( + self, + plan_v1: Dict[str, Any], + project_desc: str, + ceo_prompt: str, + llm_data: Dict[str, Any], + ) -> Dict[str, Any]: + """V2.25.4: 批评者审视 plan v1 → CEO 修订出 v2。 + + 一次额外 LLM 调用做"挑刺",检查:依赖环、漏环节、不可验证标准、粒度问题。 + 有实质问题 → 注入批评让 CEO 重出 v2;没问题 → 直接用 v1。 + """ + from app.agent_runtime.core import _LLMClient + + dept_count = len(plan_v1.get("departments", [])) + deliverables_total = sum( + len(d.get("deliverables", [])) + for d in plan_v1.get("departments", []) + ) + + critic_prompt = f"""You are a rigorous project planning critic. Review the following CEO plan for a project: "{project_desc}" + +Plan v1 (JSON): +{json.dumps(plan_v1, ensure_ascii=False, indent=2)[:2000]} + +Check for these issues: + +1. **Dependency cycles or missing prerequisites**: Does any department depend on another that hasn't produced the needed output yet? Is there a circular dependency? + +2. **Missing steps**: Are there obvious gaps in the workflow? E.g., "design" without "review", "build" without "test"? + +3. **Unverifiable acceptance criteria**: Are any criteria too vague to verify? E.g., "good quality", "complete work", "everything done"? + +4. **Granularity problems**: Are deliverables too coarse (e.g., one deliverable = "finish everything") or too fine (10+ tiny files that could be one)? + +5. **Handoff gaps**: Between sequential departments, is it clear what exactly the downstream needs from the upstream? + +Output ONLY a JSON object (no markdown, no explanation): +{{ + "has_issues": , + "severity": "", + "issues": [ + {{ + "department": "", + "type": "", + "description": "", + "suggestion": "" + }} + ] +}} + +Be strict but fair — only flag real problems. A well-structured plan should get has_issues=false.""" + + provider = llm_data.get("provider", "deepseek") + temperature = 0.3 # 批评应保持低温度,追求一致 + if provider == "moonshot": + temperature = 1.0 + + critic_llm = AgentLLMConfig( + provider=provider, + model=llm_data.get("model", "deepseek-v4-pro"), + temperature=temperature, + max_tokens=1000, + request_timeout=60.0, + ) + client = _LLMClient(critic_llm) + critic_messages = [ + {"role": "system", "content": "You are a project planning critic. Output ONLY valid JSON — no markdown, no explanation."}, + {"role": "user", "content": critic_prompt}, + ] + + try: + resp = await client.chat(messages=critic_messages, tools=None, iteration=0) + critic_raw = getattr(resp, "content", "") or (resp.get("content", "") if isinstance(resp, dict) else "") + critic_result = _parse_ceo_json(critic_raw) + except Exception as e: + logger.warning("Plan critic LLM call failed: %s", e) + return plan_v1 + + if not critic_result.get("has_issues"): + logger.info("Plan critique: no issues found, using v1 (%d depts, %d deliverables)", + dept_count, deliverables_total) + return plan_v1 + + issues = critic_result.get("issues", []) + severity = critic_result.get("severity", "minor") + logger.info("Plan critique: %d issues found (severity=%s), revising", len(issues), severity) + + # 构建修订版 prompt:原 prompt + 批评指出的具体问题 + issue_text = "\n".join( + f"- [{i.get('type', '?')}] {i.get('department', 'global')}: {i.get('description', '')} → {i.get('suggestion', '')}" + for i in issues + ) + + revision_prompt = ( + f"{ceo_prompt}\n\n" + f"## PLAN CRITIQUE — Your previous plan v1 was reviewed and these issues were found:\n" + f"{issue_text}\n\n" + f"Please revise your plan to address these issues. Output the COMPLETE revised plan as a strict JSON object (same format as before), " + f"incorporating the suggested fixes. Be specific about the changes you make.\n\n" + f"Original project: {project_desc}" + ) + + # 复用相同 LLM 配置(但 temperature 稍高以允许实质修改) + revision_config = config = AgentConfig( + name="CEO", + system_prompt=ceo_prompt, + user_id=self.user_id, + memory_scope_id=f"company_ceo_rev_{self.company_id}", + llm=AgentLLMConfig( + provider=provider, + model=llm_data.get("model", "deepseek-v4-pro"), + temperature=0.5 if provider != "moonshot" else 1.0, + max_tokens=4096, + max_iterations=2, + ), + tools=AgentToolConfig(include_tools=["web_search", "text_analyze", "task_plan"]), + memory=AgentMemoryConfig(enabled=False), + ) + + try: + revision_runtime = AgentRuntime(revision_config) + rev_result = await revision_runtime.run(revision_prompt) + plan_v2 = _parse_ceo_json(rev_result.content) + logger.info("Plan critique: revised to v2 (%d depts, addressed %d issues)", + len(plan_v2.get("departments", [])), len(issues)) + return plan_v2 + except Exception as e: + logger.warning("Plan revision failed: %s, using v1", e) + return plan_v1 + # 四档-①:部门间交接产物——把已完成上游依赖部门的交付物注入下游部门的 context _HANDOFF_PER_DEPT_CHARS = 2000 # 每个上游交付物注入上限 _HANDOFF_TOTAL_CHARS = 6000 # 全部上游交付物合计上限(防 prompt 膨胀) @@ -399,12 +625,12 @@ class CompanyOrchestrator: self, dept_plan: Dict[str, Any], ceo_analysis: str, - completed_deliverables: Dict[str, str], + completed_deliverables: Dict[str, Dict[str, Any]], ) -> str: - """构造某部门的执行 context = CEO 总体分析 + 其依赖的上游部门交付物摘要。 + """V2.25.5: 结构化交接 — 下游部门拿到的不再是上游交付物的原始文本截断, + 而是包含契约摘要、文件清单、关键决策、给下游注意事项的结构化交接包。 只按该部门声明的 `dependencies` 精准注入(无依赖 → 仅 CEO 分析,回归旧行为)。 - 逐项与合计各有字数上限,避免把下游 prompt 撑爆。 """ base = ceo_analysis or "" deps = dept_plan.get("dependencies", []) @@ -413,27 +639,77 @@ class CompanyOrchestrator: if not deps: return base + # V2.4: 循环依赖/上游未完成警告 + unmet_deps = [d for d in deps if d not in completed_deliverables] + if unmet_deps: + dept_name = dept_plan.get("department_name", "") + logger.warning("部门 %s 依赖的上游未完成: %s (可能循环依赖)", dept_name, unmet_deps) + base = ( + f"{base}\n\n" + f"【警告】你的上游部门 {', '.join(unmet_deps)} 尚未产出交付物(可能是循环依赖)。" + "请基于假设数据或行业常识开展工作,后续对接时再对齐接口。" + ) + sections: List[str] = [] used = 0 for dep_name in deps: - deliverable = (completed_deliverables.get(dep_name) or "").strip() - if not deliverable: + info = completed_deliverables.get(dep_name) + if not info: continue - snippet = deliverable[: self._HANDOFF_PER_DEPT_CHARS] - if used + len(snippet) > self._HANDOFF_TOTAL_CHARS: - snippet = snippet[: max(0, self._HANDOFF_TOTAL_CHARS - used)] - if not snippet: + deliverable_text = str(info.get("deliverable", "")).strip() + if not deliverable_text: + continue + + files = info.get("files") or [] + upstream_project_path = info.get("project_path") or "" + + # 结构化交接包 + handoff_parts: List[str] = [] + handoff_parts.append(f"## Handoff from 「{dep_name}」\n") + + # 1. 上游工作区路径 + 文件清单(P1-24: 下游部门可直接读取上游文件) + if upstream_project_path: + handoff_parts.append(f"### Workspace Path\n`{upstream_project_path}`\n") + if files: + shown = files[:10] + file_lines = [f"- `{upstream_project_path}/{fn}`" if upstream_project_path else f"- `{fn}`" for fn in shown] + if len(files) > 10: + file_lines.append(f"- ... 还有 {len(files) - 10} 个文件未列出") + handoff_parts.append(f"### Output Files ({len(files)} files)") + handoff_parts.extend(file_lines) + handoff_parts.append("") + + # 2. 交付物摘要(截取合适长度) + dlv_quota = max(400, self._HANDOFF_PER_DEPT_CHARS - 200) # 留给结构开销 + dlv_snippet = deliverable_text[:dlv_quota] + handoff_parts.append("### Deliverable Summary") + handoff_parts.append(dlv_snippet) + handoff_parts.append("") + + # 3. 给下游的衔接提示 + handoff_parts.append("### Notes for Downstream") + handoff_parts.append( + "Build ON TOP of the above deliverable. Do NOT redo their work. " + "READ their files from the workspace path listed above, then extend/integrate/QA them. " + "Use file_read tool to inspect upstream files before writing your own." + + (f" The upstream workspace is at: {upstream_project_path}" if upstream_project_path else "") + ) + + handoff_block = "\n".join(handoff_parts) + if used + len(handoff_block) > self._HANDOFF_TOTAL_CHARS: + handoff_block = handoff_block[: max(0, self._HANDOFF_TOTAL_CHARS - used)] + if not handoff_block: break - sections.append(f"— 上游部门「{dep_name}」的交付物:\n{snippet}") - used += len(snippet) + sections.append(handoff_block) + used += len(handoff_block) if used >= self._HANDOFF_TOTAL_CHARS: break if not sections: return base handoff = ( - "【上游部门已完成的交付物——请在此基础上衔接,勿重复其工作】\n" - + "\n\n".join(sections) + "【上游部门已完成的交付物——请在此基础上衔接,勿重复其工作】\n\n" + + "\n---\n".join(sections) ) return f"{base}\n\n{handoff}" if base else handoff @@ -444,7 +720,7 @@ class CompanyOrchestrator: context: str, project_path: Path, ) -> Dict[str, Any]: - """Phase 2: 运行单个部门(复用 TeamOrchestrator,非流式)。""" + """[DEPRECATED] Phase 2: 运行单个部门(非流式)。请使用 _run_department_stream。""" from app.services.team_orchestrator import TeamOrchestrator goal = dept_plan.get("goal", context) @@ -504,7 +780,21 @@ class CompanyOrchestrator: goal = dept_plan.get("goal", context) deliverables = dept_plan.get("deliverables", []) - dept_task = f"{goal}\n\nExpected deliverables:\n" + "\n".join(f"- {d}" for d in deliverables) + # V2.25.2: deliverables 支持新格式 (object with name/type/acceptance_criteria) 和旧格式 (plain string) + dlv_lines = [] + for d in deliverables: + if isinstance(d, dict): + name = d.get("name", "") + dlv_type = d.get("type", "other") + criteria = d.get("acceptance_criteria") or [] + dlv_lines.append(f"- {name} (type: {dlv_type})") + if criteria: + dlv_lines.append(" 验收标准:") + for ac in criteria: + dlv_lines.append(f" ✓ {ac}") + else: + dlv_lines.append(f"- {d}") + dept_task = f"{goal}\n\nExpected deliverables:\n" + "\n".join(dlv_lines) if context: dept_task = f"Company context: {context}\n\nDepartment task: {dept_task}" # M2-b 返工注入:本轮为返工时,把 CEO 评审反馈 + 人工意见拼进任务,驱动针对性改进 @@ -537,71 +827,119 @@ class CompanyOrchestrator: try: await sem.acquire() acquired = True - orchestrator = TeamOrchestrator( - db=dept_db, - team_id=department.id, - user_id=self.user_id, - auto_approve_files=self.auto_approve_files, - ) - async for team_event in orchestrator.execute_stream(dept_task, auto_approve_files=self.auto_approve_files): - t_type = team_event.get("type", "unknown") + # V2.25.1: Pre-delivery self-validation gate — 部门交付前自验 + # 验证失败 → 注入修复 feedback 重跑部门(最多 2 次内部自修) + self_fix_attempts = 0 + max_self_fix = 2 + current_dept_task = dept_task - # ─── Extract live state for monitoring panels ─── - if t_type == "agent_event": - inner = team_event.get("data", team_event) - inner_type = inner.get("type", "") - if inner_type == "think": - last_thought = inner.get("content", "")[:2000] - elif inner_type == "final": - tu = inner.get("token_usage") or {} - try: - dept_real_tokens += int(tu.get("total_tokens") or 0) - except (TypeError, ValueError): - pass - try: - dept_cost_yuan += estimate_cost_yuan( - tu.get("model") or "", - int(tu.get("prompt_tokens") or 0), - int(tu.get("completion_tokens") or 0), - ) - except (TypeError, ValueError): - pass - elif t_type == "phase_start": - current_phase = team_event.get("phase", 0) - total_phases = team_event.get("total_phases", total_phases or 1) - current_phase_name = team_event.get("name", "") - elif t_type == "phase_done": - current_phase_name = "" + while True: + orchestrator = TeamOrchestrator( + db=dept_db, + team_id=department.id, + user_id=self.user_id, + auto_approve_files=self.auto_approve_files, + ) + dept_result["success"] = False + dept_result["result"] = None + dept_result.pop("error", None) - # Forward as dept-level event - company_event = { - "type": "dept_agent_event", - "department_name": department.name, - "department_type": department.department_type, - "sub_type": t_type, - "data": team_event, - "last_thought": last_thought, - "phase_progress": f"{current_phase}/{total_phases}" if total_phases > 0 else "", - "phase_name": current_phase_name, - "_ts": __import__("time").time(), - } - await event_queue.put(company_event) + async for team_event in orchestrator.execute_stream(current_dept_task, auto_approve_files=self.auto_approve_files): + t_type = team_event.get("type", "unknown") - # Capture final result from team events - if team_event.get("type") == "final": - dept_result["success"] = True - dept_result["result"] = { - "deliverable": team_event.get("deliverable", ""), - "files": team_event.get("files", []), - "phase_count": team_event.get("phase_count", 0), + # ─── Extract live state for monitoring panels ─── + if t_type == "agent_event": + inner = team_event.get("data", team_event) + inner_type = inner.get("type", "") + if inner_type == "think": + last_thought = inner.get("content", "")[:2000] + elif inner_type == "final": + tu = inner.get("token_usage") or {} + try: + dept_real_tokens += int(tu.get("total_tokens") or 0) + except (TypeError, ValueError): + pass + try: + dept_cost_yuan += estimate_cost_yuan( + tu.get("model") or "", + int(tu.get("prompt_tokens") or 0), + int(tu.get("completion_tokens") or 0), + ) + except (TypeError, ValueError): + pass + elif t_type == "phase_start": + current_phase = team_event.get("phase", 0) + total_phases = team_event.get("total_phases", total_phases or 1) + current_phase_name = team_event.get("name", "") + elif t_type == "phase_done": + current_phase_name = "" + + # Forward as dept-level event + company_event = { + "type": "dept_agent_event", + "department_name": department.name, + "department_type": department.department_type, + "sub_type": t_type, + "data": team_event, + "last_thought": last_thought, + "phase_progress": f"{current_phase}/{total_phases}" if total_phases > 0 else "", + "phase_name": current_phase_name, + "_ts": _time.time(), } - elif team_event.get("type") == "error": - dept_result["success"] = False - dept_result["error"] = team_event.get("content", str(team_event)) + await event_queue.put(company_event) - # If no final event, mark as success anyway - if dept_result["result"] is None: - dept_result["success"] = True + # Capture final result from team events + if team_event.get("type") == "final": + dept_result["success"] = True + dept_result["result"] = { + "deliverable": team_event.get("deliverable", ""), + "files": team_event.get("files", []), + "phase_count": team_event.get("phase_count", 0), + "project_path": team_event.get("project_path", ""), # P1-24: 跨部门文件交接 + } + elif team_event.get("type") == "error": + dept_result["success"] = False + dept_result["error"] = team_event.get("content", str(team_event)) + + # If no final event and no error, mark as success anyway + if dept_result["result"] is None and not dept_result.get("error"): + dept_result["success"] = True + + # ─── Pre-delivery gate: only run if dept succeeded and has output ─── + if not dept_result["success"] or not dept_result.get("result"): + break + if self_fix_attempts >= max_self_fix: + break + + try: + from app.services.deliverable_validator import DeliverableValidator + vr = DeliverableValidator.validate(project_path, [dept_result]) + all_passed = all( + c.passed + for dv in vr.departments.values() + for c in dv.checks + ) + if all_passed: + dept_result["self_fix_rounds"] = self_fix_attempts + break + + self_fix_attempts += 1 + failed_items = [] + for dv in vr.departments.values(): + for c in dv.checks: + if not c.passed: + failed_items.append(f"[FAIL] {c.check_name}: {c.detail}") + fix_fb = ( + "机器自验未通过,请只修复以下具体问题(不要重做整个交付物):\n" + + "\n".join(failed_items) + ) + current_dept_task = dept_task + "\n\n【自验修正——请针对性修复以下问题,保持已有成果不变】\n" + fix_fb + logger.info("Department %s self-fix round %d: %d failures", + department.name, self_fix_attempts, len(failed_items)) + except Exception: + logger.warning("Self-validation gate error for %s, delivering as-is", + department.name, exc_info=True) + break logger.info("Department (stream) %s completed: success=%s", department.name, dept_result["success"]) except asyncio.CancelledError: @@ -619,7 +957,7 @@ class CompanyOrchestrator: "last_thought": last_thought, "phase_progress": f"{current_phase}/{total_phases}" if total_phases > 0 else "", "phase_name": current_phase_name, - "_ts": __import__("time").time(), + "_ts": _time.time(), }) except Exception as e: logger.error("Department (stream) %s failed: %s", department.name, e) @@ -633,7 +971,7 @@ class CompanyOrchestrator: "last_thought": last_thought, "phase_progress": f"{current_phase}/{total_phases}" if total_phases > 0 else "", "phase_name": current_phase_name, - "_ts": __import__("time").time(), + "_ts": _time.time(), }) finally: try: @@ -653,7 +991,8 @@ class CompanyOrchestrator: "cancelled": dept_result.get("cancelled", False), "real_tokens": dept_real_tokens, "cost_yuan": round(dept_cost_yuan, 4), - "_ts": __import__("time").time(), + "self_fix_rounds": dept_result.get("self_fix_rounds", 0), + "_ts": _time.time(), }) async def rework_single_department(self, project_id: str, dept_name: str): @@ -662,7 +1001,6 @@ class CompanyOrchestrator: 不走 execute_stream 全流程;只驱动单个 _run_department_stream + 单部门重打分, 再把该部门的 review_scores 条目替换、状态翻回 completed/failed。 """ - import time as _time project = self.db.query(CompanyProject).filter(CompanyProject.id == project_id).first() if project is None: yield {"type": "error", "message": "项目不存在", "_ts": _time.time()} @@ -729,7 +1067,7 @@ class CompanyOrchestrator: # 单部门重打分,替换 review_scores 中该条目(知识/洞察读它) try: - review = await self._ceo_review_with_scoring(company, ceo_plan, [one_result], project.description or "") + review = await self._ceo_review_with_scoring(company, ceo_plan, [one_result], project.description or "", project_path) new_scores = review.get("departments", []) existing = list(project.review_scores or []) for ns in new_scores: @@ -876,7 +1214,7 @@ class CompanyOrchestrator: # Sort by dependency: no-deps first, then dependent dept_results: List[Dict] = [] completed_names: set = set() - completed_deliverables: Dict[str, str] = {} # 四档-①:name→交付物,供下游交接 + completed_deliverables: Dict[str, Dict[str, Any]] = {} # 四档-①:name→{deliverable, files},供下游交接 # Execute in waves based on dependencies while matched_tasks: @@ -919,16 +1257,37 @@ class CompanyOrchestrator: if isinstance(_res, dict): _dlv = _res.get("deliverable") or _res.get("final_output") or "" if _dlv: - completed_deliverables[r["department_name"]] = _dlv + completed_deliverables[r["department_name"]] = { + "deliverable": _dlv, + "files": _res.get("files") or [], + "project_path": _res.get("project_path") or "", # P1-24: 跨部门文件交接 + "department_name": r["department_name"], + } matched_tasks = remaining # Phase 3: CEO Review logger.info("公司编排 [%s]: Phase 3 — CEO 审查汇总", self.company_id) - review = await self._ceo_review_with_scoring(company, ceo_plan, dept_results, project_description) + review = await self._ceo_review_with_scoring(company, ceo_plan, dept_results, project_description, project_path) summary = review.get("summary", "") project.review_scores = review.get("departments", []) - project.round_count = "1" + project.round_count = 1 + + # V2.2: 写入机器验证报告 + if review.get("_validation_report"): + vr = review["_validation_report"] + project.validation_report = { + "departments": { + dn: { + "deliverable_type": dv.deliverable_type, + "checks": [{"check_name": c.check_name, "passed": c.passed, "detail": c.detail} for c in dv.checks], + "pass_rate": dv.pass_rate, + } + for dn, dv in vr.departments.items() + }, + "overall_pass_rate": vr.overall_pass_rate, + } + project.validation_pass_rate = vr.overall_pass_rate # Update project all_success = all(d["success"] for d in dept_results) @@ -954,7 +1313,6 @@ class CompanyOrchestrator: except 分支把状态覆盖成 failed)。runner 视编排器正常结束为成功、不改状态, 故这里先把 status 落成 stopped。 """ - import time as _time try: project.status = "stopped" project.completed_at = datetime.now() @@ -992,7 +1350,6 @@ class CompanyOrchestrator: {type: "company_done", success, summary, departments, project_id, total_duration_ms, round_count, _ts} {type: "error", message} """ - import time as _time t0 = _time.time() self.auto_approve_files = auto_approve_files # ─── M2-b 运行限额(人在环审批 + 预算/超时熔断);默认全关,回归安全 ─── @@ -1051,6 +1408,9 @@ class CompanyOrchestrator: self.db.add(project) self.db.commit() + # 尽早告知调用方项目行 ID(前端精确停止/控制室跳转用;幂等信息事件) + yield {"type": "project_created", "project_id": project.id, "_ts": _time.time()} + # ─── M2-a 运行时控制(进程内注册表:暂停/继续/跳过)─── from app.services import company_runner # 惰性导入,避免与 runner 循环依赖 control = company_runner.get_control(project_id) if project_id else company_runner.ProjectControl() @@ -1124,6 +1484,7 @@ class CompanyOrchestrator: # ─── Multi-round iteration loop ─── all_dept_results: List[Dict] = [] previous_scores: List[Dict] = [] + previous_validation_report = None # V2.2: 机器验证报告(跨轮次) current_plans = dept_plans round_index = 0 all_pass = False @@ -1172,6 +1533,12 @@ class CompanyOrchestrator: for p in filtered_plans: entry = failed_map.get(p.get("department_name")) or failed_map.get(p.get("department_type")) parts = [] + # V2.2: 先注入机器验证证据,让返工部门看到具体问题 + if previous_validation_report: + dept_name = p.get("department_name") or p.get("department_type") + val_failures = previous_validation_report.get_dept_failures(dept_name) + if val_failures and "全部验证项通过" not in val_failures: + parts.append("机器验证结果:\n" + val_failures) if entry: if entry.get("feedback"): parts.append("CEO 评审反馈:" + str(entry["feedback"])) @@ -1207,7 +1574,7 @@ class CompanyOrchestrator: } dept_results: List[Dict] = [] completed_names: set = set() - completed_deliverables: Dict[str, str] = {} # 四档-①:name→交付物,供下游交接 + completed_deliverables: Dict[str, Dict[str, Any]] = {} # 四档-①:name→{deliverable, files},供下游交接 dept_timings: Dict[str, float] = {} while matched: @@ -1272,7 +1639,7 @@ class CompanyOrchestrator: dept_event_queue: asyncio.Queue = asyncio.Queue() # 计时起点:部门被派发时(而非首个事件到达时),避免 duration≈0 for dept, plan in ready: - dept_timings.setdefault(dept.name, __import__("time").time()) + dept_timings.setdefault(dept.name, _time.time()) tasks = { asyncio.create_task( self._run_department_stream( @@ -1307,12 +1674,18 @@ class CompanyOrchestrator: done_count += 1 r = evt completed_names.add(r["department_name"]) - # 四档-①:记录该部门交付物,供同轮下游部门交接 - _dlv = (r.get("result") or {}).get("deliverable", "") if isinstance(r.get("result"), dict) else "" + # 四档-①:记录该部门交付物(含文件清单),供同轮下游部门交接 + _res = r.get("result") or {} + _dlv = _res.get("deliverable", "") if isinstance(_res, dict) else "" if _dlv: - completed_deliverables[r["department_name"]] = _dlv + completed_deliverables[r["department_name"]] = { + "deliverable": _dlv, + "files": _res.get("files") or [], + "project_path": _res.get("project_path") or "", # P1-24: 跨部门文件交接 + "department_name": r["department_name"], + } control.dept_tasks.pop(r["department_name"], None) # M2-c 注销句柄 - dur = int((__import__("time").time() - dept_timings.get(r["department_name"], __import__("time").time())) * 1000) + dur = int((_time.time() - dept_timings.get(r["department_name"], _time.time())) * 1000) approx_used = dept_token_usage.get(r["department_name"], 0) real_used = int(r.get("real_tokens") or 0) used_display = real_used if real_used > 0 else approx_used @@ -1341,7 +1714,7 @@ class CompanyOrchestrator: "used": used, "remaining": max(0, limit - used), "percentage": round(used / limit * 100, 1) if limit > 0 else 0, - "_ts": __import__("time").time(), + "_ts": _time.time(), } yield { "type": "dept_done", @@ -1352,7 +1725,7 @@ class CompanyOrchestrator: "tokens_used": used_display, "tokens_used_approx": approx_used, "cost_yuan": round(dept_cost, 4), - "_ts": __import__("time").time(), + "_ts": _time.time(), } # M2-b 预算熔断(部门结束粒度,最灵敏):累计成本超阈 → 优雅终止 if max_cost and company_cost_yuan >= max_cost: @@ -1363,7 +1736,7 @@ class CompanyOrchestrator: elif evt.get("type") == "dept_agent_event": dept_name = evt.get("department_name", "") if dept_name and dept_name not in dept_timings: - dept_timings[dept_name] = __import__("time").time() + dept_timings[dept_name] = _time.time() matched = remaining @@ -1389,7 +1762,7 @@ class CompanyOrchestrator: } t_review_start = _time.time() try: - review = await self._ceo_review_with_scoring(company, ceo_plan, dept_results, project_description) + review = await self._ceo_review_with_scoring(company, ceo_plan, dept_results, project_description, project_path) except Exception as e: review = {"summary": f"Review failed: {e}", "overall_score": 0, "departments": [], "next_steps": ""} @@ -1410,6 +1783,7 @@ class CompanyOrchestrator: } previous_scores = scores + previous_validation_report = review.get("_validation_report") # V2.2 all_dept_results = dept_results all_pass = all(d.get("pass", True) for d in scores) @@ -1451,9 +1825,26 @@ class CompanyOrchestrator: # ─── Finalize ─── all_success = all(d["success"] for d in all_dept_results) project.review_scores = previous_scores - project.round_count = str(round_index) + project.round_count = round_index project.status = "completed" if all_success else "failed" project.completed_at = datetime.now() + + # V2.2: 写入机器验证报告 + if previous_validation_report: + vr = previous_validation_report + project.validation_report = { + "departments": { + dn: { + "deliverable_type": dv.deliverable_type, + "checks": [{"check_name": c.check_name, "passed": c.passed, "detail": c.detail} for c in dv.checks], + "pass_rate": dv.pass_rate, + } + for dn, dv in vr.departments.items() + }, + "overall_pass_rate": vr.overall_pass_rate, + } + project.validation_pass_rate = vr.overall_pass_rate + self.db.commit() # 四档-②:终态自动沉淀知识,供后续项目的 CEO 规划回喂(闭合学习闭环) @@ -1480,11 +1871,12 @@ class CompanyOrchestrator: ceo_plan: Dict, dept_results: List[Dict], project_desc: str, + project_path: Optional[Path] = None, ) -> Dict[str, Any]: """Phase 3: CEO 审查所有部门产出 + 打分反馈。 Returns: - {summary, overall_score, departments: [{name, score, feedback, pass}], next_steps} + {summary, overall_score, departments: [{name, score, feedback, pass}], next_steps, _validation_report?} """ ceo_agent = None if company.ceo_agent_id: @@ -1497,6 +1889,26 @@ class CompanyOrchestrator: if not ceo_agent: return {"summary": "CEO Agent not available for review", "overall_score": 0, "departments": [], "next_steps": ""} + # V2.2: 机器验证 — 在 CEO 评审前检测交付物质量,产出结构化证据 + validation_report = None + validation_evidence_lines: List[str] = [] + if project_path: + try: + from app.services.deliverable_validator import DeliverableValidator + validation_report = DeliverableValidator.validate(project_path, dept_results) + evidence_lines = ["## Machine Validation Evidence\n"] + for dept_name, dv in validation_report.departments.items(): + evidence_lines.append(f"### Department: {dept_name}") + evidence_lines.append(f"Deliverable type: {dv.deliverable_type}") + for c in dv.checks: + icon = "[PASS]" if c.passed else "[FAIL]" + evidence_lines.append(f"{icon} {c.check_name}: {c.detail}") + evidence_lines.append(f"Pass rate: {dv.pass_rate:.0%}\n") + evidence_lines.append(f"Overall machine validation pass rate: {validation_report.overall_pass_rate:.0%}") + validation_evidence_lines = evidence_lines + except Exception: + logger.warning("Machine validation failed, continuing without evidence", exc_info=True) + # Build review prompt with scoring requirement dept_summaries = [] for dr in dept_results: @@ -1517,13 +1929,59 @@ class CompanyOrchestrator: f"Output: {deliverable or error or 'No output'}" ) + machine_evidence_block = "\n".join(validation_evidence_lines) if validation_evidence_lines else "" + + # V2.25.2: 验收标准矩阵 — 从 ceo_plan 提取验收标准,机器可验的预填,其余标记 LLM 判定 + acceptance_blocks: List[str] = [] + plan_depts = ceo_plan.get("departments", []) + for pd in plan_depts: + pd_deliverables = pd.get("deliverables", []) + if not pd_deliverables: + continue + dept_name = pd.get("department_name", "") + rows: List[str] = [] + for dlv in (pd_deliverables if isinstance(pd_deliverables[0], dict) else []): + dlv_name = dlv.get("name", "") + dlv_type = dlv.get("type", "other") + criteria = dlv.get("acceptance_criteria") or [] + for ac in criteria: + # 判定机器是否可验 + ac_lower = str(ac).lower() + machine_check = "" + if any(kw in ac_lower for kw in ["语法错误", "syntax", "括号", "parse", "ast"]): + machine_check = "[MACHINE] code syntax" + elif any(kw in ac_lower for kw in ["body", "html", "页面内容", "页面", "200", "http"]): + machine_check = "[MACHINE] html check" + elif any(kw in ac_lower for kw in ["字", "word", "章节", "section", "关键字"]): + machine_check = "[MACHINE] document completeness" + elif any(kw in ac_lower for kw in ["文件存在", "file exists", "非空", "not empty", "size"]): + machine_check = "[MACHINE] file existence" + else: + machine_check = "[LLM JUDGMENT REQUIRED]" + rows.append(f"| {dlv_name} | {ac} | {machine_check} |") + if rows: + header = (f"### Acceptance Criteria: {dept_name}\n" + f"| Deliverable | Criterion | Verifiability |\n" + f"|---|---|---|") + acceptance_blocks.append(header + "\n" + "\n".join(rows)) + + acceptance_block = "" + if acceptance_blocks: + acceptance_block = ("## Acceptance Criteria Matrix\n" + "Please judge each [LLM JUDGMENT REQUIRED] criterion against the department's actual deliverable above.\n\n" + + "\n\n".join(acceptance_blocks)) + review_prompt = f"""You are the CEO reviewing your company's execution of the project: "{project_desc}" Your original plan: {json.dumps(ceo_plan, ensure_ascii=False, indent=2)[:1500]} -Department Results: +{machine_evidence_block} + +{acceptance_block} + +## Department Deliverables {chr(10).join(dept_summaries)[:3000]} You MUST output a strict JSON object (no markdown, no extra text): @@ -1549,6 +2007,16 @@ Scoring criteria: - 4-5: Below standard, needs significant revision - 1-3: Poor quality, fundamental issues +Machine validation evidence rules (IMPORTANT): +- If all machine validation checks passed for a department → "pass" should default to true unless the deliverable content is clearly insufficient +- If some machine validation checks failed → "pass" CAN still be true, but you MUST explain in feedback why the failures don't block acceptance +- Score should correlate with validation pass rate but does not need to be strictly proportional — use your judgment on content quality + +Acceptance criteria rules: +- For each [LLM JUDGMENT REQUIRED] criterion in the Acceptance Criteria Matrix, check against the department's actual deliverable and include your judgment in the feedback +- A deliverable that fails multiple acceptance criteria should have a lower score +- If ALL acceptance criteria pass (both machine and LLM-judged), the department should generally pass + A department passes if score >= 6. Be honest but fair.""" wf = ceo_agent.workflow_config or {} @@ -1566,10 +2034,16 @@ A department passes if score >= 6. Be honest but fair.""" # 解析失败 → 旧逻辑静默 auto-pass(score=5) 污染打分/返工判断。 from app.agent_runtime.core import _LLMClient + provider = llm_data.get("provider", "deepseek") + temperature = 0.2 + # Kimi K3 等 moonshot 推理模型强制要求 temperature=1 + if provider == "moonshot": + temperature = 1.0 + review_llm = AgentLLMConfig( - provider=llm_data.get("provider", "deepseek"), + provider=provider, model=llm_data.get("model", "deepseek-v4-pro"), - temperature=0.2, + temperature=temperature, max_tokens=1500, request_timeout=90.0, ) @@ -1588,7 +2062,10 @@ A department passes if score >= 6. Be honest but fair.""" logger.warning("CEO review LLM call failed (attempt %d): %s", attempt + 1, e) break raw = getattr(resp, "content", "") or (resp.get("content", "") if isinstance(resp, dict) else "") - review = _parse_ceo_json(raw) + try: + review = _parse_ceo_json(raw) + except ValueError: + review = {} # parse failed → retry or fallback below if review: break # 一次纠正性重试:明确告知上一轮不是合法 JSON @@ -1617,12 +2094,32 @@ A department passes if score >= 6. Be honest but fair.""" "next_steps": "", "parse_failed": True, } + if validation_report: + review["_validation_report"] = validation_report return review +# Windows 保留文件名(大小写不敏感) +_WINDOWS_RESERVED = { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +} +_MAX_SAFE_NAME_LEN = 60 + + def _safe_name(text: str) -> str: - """Generate a safe directory name from project description.""" + """Generate a safe directory name from project description. + + - Windows reserved name → prefix with underscore + - Length capped at 60 chars + - Special chars stripped, whitespace → underscore + """ import re - safe = re.sub(r'[^\w\s-]', '', text[:40]) + safe = re.sub(r'[^\w\s-]', '', text[:80]) safe = re.sub(r'\s+', '_', safe) + safe = safe.strip("_-. ")[:_MAX_SAFE_NAME_LEN] + # Windows 保留名 → 前导下划线 + if safe.upper() in _WINDOWS_RESERVED: + safe = f"_{safe}" return safe or "untitled" diff --git a/backend/app/services/company_runner.py b/backend/app/services/company_runner.py index cd0948d..f24392f 100644 --- a/backend/app/services/company_runner.py +++ b/backend/app/services/company_runner.py @@ -1,12 +1,10 @@ """ -公司项目后台执行器 — 白盒控制室的执行解耦层 +公司项目后台执行器 — V3.0 多 worker 支持 -把公司级流式执行从「请求协程内跑完」解耦为「API 进程内的后台 asyncio 任务」: -- 执行不再随 SSE/WS 断开而中止(可恢复) -- 每条编排事件落库 company_project_events(供 WS/HTTP 按 seq 回放) -- 周期性刷新 project.updated_at(心跳),避免僵尸监管误杀长任务 - -前提:API 为单 uvicorn worker,后台任务与 WS 端点同进程共享 DB/表。 +- Redis 任务锁 (SETNX) 确保同一 project 不会两个 worker 同时执行 +- Redis pub/sub 控制频道跨 worker 广播 pause/cancel/approve +- 进程内 _RUNNING dict 管理本地协程(不能序列化到 Redis) +- Redis 不可用时自动降级为单 worker 模式(向后兼容) """ from __future__ import annotations @@ -32,6 +30,9 @@ _RUNNING: Dict[str, asyncio.Task] = {} # 三档-b:全局项目并发信号量(lazy,绑定到运行时事件循环)。单 worker 单 loop 单实例。 _PROJECT_SEM: Optional[asyncio.Semaphore] = None +# V3.0: Redis 控制频道监听 task(按 project_id),用于接收跨 worker 的 pause/cancel/approve +_REDIS_CONTROL_TASKS: Dict[str, asyncio.Task] = {} + def _get_project_sem() -> asyncio.Semaphore: """取(缺则建)全局项目并发信号量。lazy 构造避免 import 期绑错事件循环。""" @@ -97,12 +98,20 @@ def stop(project_id: str) -> bool: """请求停止(硬取消)运行中的项目任务。返回是否有任务被取消。 复用 run_company_project 的 CancelledError→stopped 雏形(落 company_terminated)。 + V3.0: 同时通过 Redis pub/sub 广播 cancel,使跨 worker 停项目生效。 """ task = _RUNNING.get(project_id) + cancelled = False if task is not None and not task.done(): task.cancel() - return True - return False + cancelled = True + # V3.0: 跨 worker 广播 cancel(fire-and-forget,Redis 不可用时退化) + try: + loop = asyncio.get_event_loop() + loop.create_task(publish_redis_control(project_id, "cancel")) + except Exception: + pass + return cancelled def cancel_department(project_id: str, name: str) -> bool: @@ -111,14 +120,22 @@ def cancel_department(project_id: str, name: str) -> bool: 编排器在建部门 task 时把句柄登记进 ctrl.dept_tasks;此处 cancel 触发 `_run_department_stream` 的 CancelledError 分支,仍发 dept_done 让波次推进、 下游依赖照常解锁。 + V3.0: 同时通过 Redis pub/sub 广播 cancel_department,使跨 worker 取消部门生效。 """ ctrl = get_control(project_id) task = ctrl.dept_tasks.get(name) + cancelled = False if task is not None and not task.done(): ctrl.cancelled_depts.add(name) task.cancel() - return True - return False + cancelled = True + # V3.0: 跨 worker 广播(fire-and-forget) + try: + loop = asyncio.get_event_loop() + loop.create_task(publish_redis_control(project_id, "cancel_department", department_name=name)) + except Exception: + pass + return cancelled def _max_seq(db, project_id: str) -> int: @@ -248,20 +265,72 @@ def is_running(project_id: str) -> bool: def spawn(project_id: str, company_id: str, user_id: str, description: str, max_rounds: int = 3, ceo_plan: Optional[Dict[str, Any]] = None, limits: Optional[Dict[str, Any]] = None) -> asyncio.Task: - """在当前事件循环内派发后台执行任务,并登记句柄防 GC。""" + """在当前事件循环内派发后台执行任务,并登记句柄防 GC。 + + V3.0: 通过 Redis SETNX 抢锁,确保多 worker 下同一 project 不会被两个 worker 同时执行。 + Redis 不可用时降级为始终放行(回归单 worker 行为)。 + """ task = asyncio.create_task( run_company_project(project_id, company_id, user_id, description, max_rounds, ceo_plan, limits) ) _RUNNING[project_id] = task + async def _acquire_lock(): + """延迟抢锁:等任务真正获得信号量后再注册 Redis,避免排队期占用 TTL。""" + await asyncio.sleep(0.1) # 让出给事件循环,确保 task 开始跑 + if project_id in _RUNNING: + try: + from app.services.redis_task_registry import try_acquire + await try_acquire(project_id) + except Exception: + pass # Redis 不可用,降级 + def _on_done(t: asyncio.Task) -> None: _RUNNING.pop(project_id, None) - clear_control(project_id) # 清理控制态,避免注册表泄漏 + clear_control(project_id) + # V3.0: 清理 Redis 锁和控制监听 + try: + ctrl_task = _REDIS_CONTROL_TASKS.pop(project_id, None) + if ctrl_task and not ctrl_task.done(): + ctrl_task.cancel() + except Exception: + pass + # 延迟释放 Redis 锁(fire-and-forget) + try: + loop = asyncio.get_event_loop() + loop.create_task(_release_lock(project_id)) + except Exception: + pass task.add_done_callback(_on_done) + + # 注册 Redis 锁 + loop = asyncio.get_event_loop() + loop.create_task(_acquire_lock()) + return task +async def _release_lock(project_id: str) -> None: + """释放 Redis 任务锁(fire-and-forget,失败不抛)。""" + try: + from app.services.redis_task_registry import release_task + await release_task(project_id) + except Exception: + pass + + +async def publish_redis_control(project_id: str, action: str, **kwargs) -> None: + """通过 Redis pub/sub 广播控制指令(fire-and-forget,失败不抛)。""" + try: + from app.services.redis_task_registry import publish_control + cmd = {"action": action} + cmd.update(kwargs) + await publish_control(project_id, cmd) + except Exception: + pass + + async def run_company_project( project_id: str, company_id: str, @@ -295,6 +364,49 @@ async def run_company_project( db_orch = SessionLocal() # 编排器专用(会被内部并行部门协程共享,沿用既有行为) db_evt = SessionLocal() # 事件落库 + 心跳专用,隔离编排器 session 状态 buf = _EventBuffer(project_id) + + # V3.0: 订阅 Redis 控制频道,接收跨 worker 的 pause/cancel/approve 信号 + ctrl = get_control(project_id) + + async def _on_redis_control(cmd: dict) -> None: + action = cmd.get("action", "") + if action == "pause": + ctrl.pause_event.clear() + logger.info("Redis 控制: %s → pause", project_id) + elif action == "resume": + ctrl.pause_event.set() + logger.info("Redis 控制: %s → resume", project_id) + elif action == "cancel": + task = _RUNNING.get(project_id) + if task and not task.done(): + task.cancel() + logger.info("Redis 控制: %s → cancel", project_id) + elif action == "approve": + decision = cmd.get("decision", "") + feedback = cmd.get("feedback", "") + ctrl.resolve_gate(decision, feedback) + logger.info("Redis 控制: %s → approve (%s)", project_id, decision) + elif action == "skip_department": + dept = cmd.get("department_name", "") + if dept: + ctrl.skip_depts.add(dept) + logger.info("Redis 控制: %s → skip_dept %s", project_id, dept) + elif action == "cancel_department": + dept = cmd.get("department_name", "") + if dept: + dt = ctrl.dept_tasks.get(dept) + if dt and not dt.done(): + ctrl.cancelled_depts.add(dept) + dt.cancel() + logger.info("Redis 控制: %s → cancel_dept %s", project_id, dept) + + try: + from app.services.redis_task_registry import subscribe_control + redis_ctrl_task = await subscribe_control(project_id, _on_redis_control) + _REDIS_CONTROL_TASKS[project_id] = redis_ctrl_task + except Exception: + pass # Redis 不可用,降级为仅进程内控制 + orch = CompanyOrchestrator(db_orch, company_id, user_id) async for evt in orch.execute_stream( description, max_rounds=max_rounds, ceo_plan=ceo_plan, project_id=project_id, @@ -308,6 +420,14 @@ async def run_company_project( now = time.time() if buf.should_flush(now) or (now - buf.last_hb) >= buf.hb_interval: buf.flush(db_evt) + # V3.0: 周期性刷新 Redis TTL(与 DB 心跳同频,保任务锁不因执行久而过期) + if now - getattr(buf, '_last_redis_hb', 0) >= buf.hb_interval: + try: + from app.services.redis_task_registry import refresh_task_ttl + asyncio.create_task(refresh_task_ttl(project_id)) + except Exception: + pass + buf._last_redis_hb = now buf.flush(db_evt, force=True) # 收尾兜底:保 company_done 等 finale 落地 # execute_stream 正常结束时已把 project 状态落 completed/failed(其自身 session) logger.info("公司后台执行完成 [%s]: %d 事件", project_id, seq) diff --git a/backend/app/services/deliverable_validator.py b/backend/app/services/deliverable_validator.py new file mode 100644 index 0000000..6c0ed64 --- /dev/null +++ b/backend/app/services/deliverable_validator.py @@ -0,0 +1,422 @@ +""" +交付物机器验证器 — 按类型自动选择验证策略,产出结构化验证报告。 + +不做沙箱执行/HTTP 冒烟/真单测(V2.2 范围外),仅做自动化冒烟检查。 +""" +from __future__ import annotations + +import ast +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +# ─── 数据结构 ─── + + +@dataclass +class CheckResult: + check_name: str + passed: bool + detail: str + + +@dataclass +class DeptValidation: + department_name: str + deliverable_type: str # document / html / code / unknown + checks: List[CheckResult] = field(default_factory=list) + pass_rate: float = 0.0 + + +@dataclass +class ValidationReport: + departments: Dict[str, DeptValidation] = field(default_factory=dict) + overall_pass_rate: float = 0.0 + + def get_dept_failures(self, dept_name: str) -> str: + """获取某部门的验证失败项摘要,供返工 feedback 注入。""" + dv = self.departments.get(dept_name) + if not dv: + return "" + failed = [c for c in dv.checks if not c.passed] + if not failed: + return "全部验证项通过 ✓" + lines = [f"验证未通过 ({len(failed)}/{len(dv.checks)} 项):"] + for c in failed: + lines.append(f" ❌ {c.check_name}: {c.detail}") + return "\n".join(lines) + + +# ─── 验证器 ─── + + +# 文档章节关键字(中文 + 英文) +_DOC_SECTION_KEYWORDS = [ + "背景", "分析", "建议", "结论", "方案", "需求", "设计", + "实现", "测试", "部署", "问题", "目标", "方法", "结果", + "background", "analysis", "conclusion", "solution", + "design", "implementation", "test", "deployment", +] + +_CODE_EXTENSIONS = {".py": "python", ".js": "javascript", ".mjs": "javascript", + ".ts": "javascript", ".vue": "javascript"} +_HTML_EXTENSIONS = {".html", ".htm"} +_DOC_EXTENSIONS = {".md", ".txt", ".rst", ".json", ".csv", ".yaml", ".yml", ".log", ".xml"} + + +class DeliverableValidator: + """按交付物类型自动选择验证器,产出机器验证报告。""" + + @staticmethod + def validate(project_path: Path, dept_results: List[Dict[str, Any]]) -> ValidationReport: + """遍历各部门产出文件,匹配验证器,生成结构化报告。 + + Args: + project_path: 项目输出目录 + dept_results: 部门执行结果列表,每项含 department_name / success / result + + Returns: + ValidationReport: 按部门分组的验证结果 + """ + report = ValidationReport() + total_checks = 0 + total_passed = 0 + + for dr in dept_results: + dept_name = dr.get("department_name", "unknown") + success = dr.get("success", False) + result = dr.get("result") or {} + files = list(result.get("files") or []) + deliverable_text = result.get("deliverable", "") or "" + + if not success: + dv = DeptValidation( + department_name=dept_name, + deliverable_type="unknown", + checks=[CheckResult("执行状态", False, "部门执行失败,无法验证交付物")], + pass_rate=0.0, + ) + report.departments[dept_name] = dv + total_checks += 1 + continue + + # 发现所有产出文件 + found_files = _discover_files(project_path, files) + if not found_files: + dv = DeptValidation( + department_name=dept_name, + deliverable_type="unknown", + checks=[CheckResult("文件发现", False, "未找到任何产出文件")], + pass_rate=0.0, + ) + report.departments[dept_name] = dv + total_checks += 1 + continue + + # 按文件类型归类,取主要类型 + file_types = [_classify_file(f) for f in found_files] + primary_type = _primary_type(file_types) + checks: List[CheckResult] = [] + + if primary_type == "html": + checks = _validate_html_files(found_files) + elif primary_type == "code": + checks = _validate_code_files(found_files) + elif primary_type == "document": + checks = _validate_document_files(found_files, deliverable_text) + else: + checks = _validate_generic_files(found_files) + + # 如果 deliverable_text 有内容,补一个文本完整性检查 + if primary_type == "document": + text_check = _validate_text_content(deliverable_text) + if text_check: + checks.append(text_check) + + pass_count = sum(1 for c in checks if c.passed) + pass_rate = pass_count / len(checks) if checks else 0.0 + + dv = DeptValidation( + department_name=dept_name, + deliverable_type=primary_type, + checks=checks, + pass_rate=pass_rate, + ) + report.departments[dept_name] = dv + total_checks += len(checks) + total_passed += pass_count + + report.overall_pass_rate = total_passed / total_checks if total_checks > 0 else 0.0 + return report + + +# ─── 内部工具函数 ─── + + +def _discover_files(project_path: Path, reported_files: List[str]) -> List[Path]: + """发现产出文件:优先用 reported_files(绝对路径),回退到扫描 project_path。""" + found: List[Path] = [] + for fp in reported_files: + p = Path(fp) + if p.exists() and p.is_file(): + found.append(p) + # 如果 dept_result 没报文件,扫描项目目录 + if not found and project_path.exists(): + found = sorted( + f for f in project_path.rglob("*") if f.is_file() and not f.name.startswith(".") + ) + return found + + +def _classify_file(file_path: Path) -> str: + ext = file_path.suffix.lower() + if ext in _HTML_EXTENSIONS: + return "html" + if ext in _CODE_EXTENSIONS: + return "code" + if ext in _DOC_EXTENSIONS: + return "document" + return "unknown" + + +def _primary_type(types: List[str]) -> str: + """取最具体的类型:code > html > document > unknown""" + for t in ("code", "html", "document"): + if t in types: + return t + return "unknown" if types else "unknown" + + +# ─── HTML 验证 ─── + + +def _validate_html_files(files: List[Path]) -> List[CheckResult]: + checks: List[CheckResult] = [] + syntax_errors: List[str] = [] + total_body_len = 0 + file_count = 0 + + for fp in files: + if fp.suffix.lower() not in _HTML_EXTENSIONS: + continue + file_count += 1 + try: + content = fp.read_text(encoding="utf-8", errors="replace") + except Exception as e: + syntax_errors.append(f"{fp.name}: 无法读取 ({e})") + continue + # 用 html.parser 做基础语法检查 + from html.parser import HTMLParser + + class CheckParser(HTMLParser): + def __init__(self): + super().__init__() + self.errors: List[str] = [] + + def handle_starttag(self, tag, attrs): + pass + + parser = CheckParser() + try: + parser.feed(content) + except Exception as e: + syntax_errors.append(f"{fp.name}: {e}") + + # 检查 body 文本长度 + body_match = re.search(r"]*>(.*?)", content, re.DOTALL | re.IGNORECASE) + if body_match: + body_text = re.sub(r"<[^>]+>", "", body_match.group(1)).strip() + total_body_len += len(body_text) + + if file_count == 0: + checks.append(CheckResult("HTML 文件", False, "未找到 HTML 文件")) + else: + checks.append(CheckResult( + "HTML 文件数", True, f"共 {file_count} 个 HTML 文件" + )) + + if syntax_errors: + checks.append(CheckResult( + "HTML 语法检查", False, + f"{len(syntax_errors)} 个文件有语法问题: {'; '.join(syntax_errors[:3])}" + )) + else: + checks.append(CheckResult("HTML 语法检查", True, "所有文件语法解析通过")) + + if total_body_len > 0: + checks.append(CheckResult( + "页面内容检查", True, f"body 文本总长 {total_body_len} 字符" + )) + else: + checks.append(CheckResult( + "页面内容检查", False, "所有页面的 body 文本为空" + )) + + return checks + + +# ─── 代码验证 ─── + + +def _validate_code_files(files: List[Path]) -> List[CheckResult]: + checks: List[CheckResult] = [] + syntax_ok = 0 + syntax_fail = 0 + fail_details: List[str] = [] + + for fp in files: + ext = fp.suffix.lower() + if ext not in _CODE_EXTENSIONS: + continue + try: + content = fp.read_text(encoding="utf-8", errors="replace") + except Exception as e: + syntax_fail += 1 + fail_details.append(f"{fp.name}: 无法读取 ({e})") + continue + + if ext == ".py": + ok, err = _check_python_syntax(content) + else: + ok, err = _check_js_syntax(content) + + if ok: + syntax_ok += 1 + else: + syntax_fail += 1 + fail_details.append(f"{fp.name}: {err}") + + total = syntax_ok + syntax_fail + if total == 0: + checks.append(CheckResult("代码文件", False, "未找到可验证的代码文件")) + else: + checks.append(CheckResult( + "代码语法检查", + syntax_fail == 0, + f"{syntax_ok}/{total} 通过" + (f";失败: {'; '.join(fail_details[:3])}" if fail_details else ""), + )) + + return checks + + +def _check_python_syntax(source: str) -> tuple: + """ast.parse 语法检查。""" + try: + ast.parse(source) + return True, "" + except SyntaxError as e: + return False, f"第 {e.lineno} 行: {e.msg}" + + +def _check_js_syntax(source: str) -> tuple: + """JS 基础检查:括号匹配 + import/require 可解析。""" + # 括号匹配 + pairs = {"{": "}", "(": ")", "[": "]"} + stack: List[str] = [] + for lineno, line in enumerate(source.split("\n"), 1): + for ch in line: + if ch in pairs: + stack.append(pairs[ch]) + elif ch in (")", "}", "]"): + if not stack or stack[-1] != ch: + return False, f"第 {lineno} 行: 括号不匹配" + stack.pop() + if stack: + return False, "括号未闭合" + return True, "" + + +# ─── 文档验证 ─── + + +def _validate_document_files(files: List[Path], deliverable_text: str) -> List[CheckResult]: + checks: List[CheckResult] = [] + doc_files = [f for f in files if f.suffix.lower() in _DOC_EXTENSIONS] + + if not doc_files: + # 无文档文件,检查 deliverable_text + if deliverable_text.strip(): + checks.append(CheckResult( + "文件发现", True, + f"无独立文档文件,但 deliverable 文本有 {len(deliverable_text)} 字符" + )) + else: + checks.append(CheckResult("文件发现", False, "未找到文档文件且无 deliverable 文本")) + return checks + + checks.append(CheckResult( + "文档文件数", True, f"共 {len(doc_files)} 个文档文件" + )) + + # 检查文档总字数 + total_words = 0 + for fp in doc_files: + try: + content = fp.read_text(encoding="utf-8", errors="replace") + # 中文字数 + 英文单词数 + cn_chars = len(re.findall(r"[\u4e00-\u9fff]", content)) + en_words = len(re.findall(r"[a-zA-Z]+", content)) + total_words += cn_chars + en_words + except Exception: + pass + + checks.append(CheckResult( + "文档字数检查", total_words >= 200, + f"总计约 {total_words} 字{',达标' if total_words >= 200 else ',不足 200 字'}" + )) + + return checks + + +def _validate_text_content(text: str) -> Optional[CheckResult]: + """检查 deliverable 文本中的章节关键字。""" + if not text or len(text) < 20: + return CheckResult("内容关键字检查", False, "文本内容过短,无法检查关键字") + text_lower = text.lower() + found = [kw for kw in _DOC_SECTION_KEYWORDS + if kw in text_lower or kw in text] + if len(found) >= 2: + return CheckResult( + "内容关键字检查", True, + f"含 {len(found)} 个章节关键字: {', '.join(found[:5])}" + ) + else: + return CheckResult( + "内容关键字检查", False, + f"仅含 {len(found)} 个章节关键字(需 >= 2),可能结构不完整" + ) + + +# ─── 通用文件验证 ─── + + +def _validate_generic_files(files: List[Path]) -> List[CheckResult]: + checks: List[CheckResult] = [] + total_size = 0 + empty_files = 0 + + for fp in files: + try: + sz = fp.stat().st_size + total_size += sz + if sz == 0: + empty_files += 1 + except Exception: + pass + + checks.append(CheckResult( + "文件存在性", True, f"共 {len(files)} 个文件,总大小 {total_size} bytes" + )) + + if empty_files > 0: + checks.append(CheckResult( + "文件内容检查", False, f"{empty_files} 个文件为空" + )) + else: + checks.append(CheckResult("文件内容检查", True, "所有文件非空")) + + return checks diff --git a/backend/app/services/insight_analyzer.py b/backend/app/services/insight_analyzer.py index 296a661..5d1f4c0 100644 --- a/backend/app/services/insight_analyzer.py +++ b/backend/app/services/insight_analyzer.py @@ -1,19 +1,25 @@ """ 洞察分析器 — 跨项目统计分析,生成最佳实践建议 + +V2.4: 新增项目级时间序列 + 部门协作对排名(含通过率) """ from __future__ import annotations import logging +from collections import defaultdict +from datetime import datetime, timedelta from typing import Dict, List, Any +from sqlalchemy import func from sqlalchemy.orm import Session from app.models.company import CompanyProject logger = logging.getLogger(__name__) -def analyze_company(company_id: str, db: Session) -> Dict[str, Any]: +def analyze_company(company_id: str, db: Session, lookback_days: int = 90) -> Dict[str, Any]: """分析公司所有项目,生成洞察报告。""" + cutoff = datetime.now() - timedelta(days=lookback_days) projects = ( db.query(CompanyProject) .filter(CompanyProject.company_id == company_id) @@ -25,6 +31,7 @@ def analyze_company(company_id: str, db: Session) -> Dict[str, Any]: return { "dept_stats": [], "collaboration_pairs": [], + "time_series": [], "avg_rounds": 0, "avg_cost": 0, "failure_reasons": [], @@ -64,24 +71,75 @@ def analyze_company(company_id: str, db: Session) -> Dict[str, Any]: v["avg_score"] = round(v["total_score"] / v["score_count"], 1) v["success_rate"] = round(v["success_count"] / v["execution_count"] * 100, 1) if v["execution_count"] > 0 else 0 - # Collaboration pairs (dependencies) - dep_pairs: Dict[str, int] = {} + # V2.4: 部门协作对排名(含通过率) + dep_pair_stats: Dict[str, Dict] = {} for p in projects: ceo_plan = p.ceo_plan or {} dept_plans = ceo_plan.get("departments", []) + review_scores = p.review_scores or [] + # Build dept score map for this project + dept_score_map = {s.get("name", ""): s.get("score", 0) for s in review_scores} for dp in dept_plans: deps = dp.get("dependencies", []) if isinstance(deps, str): deps = [deps] if deps else [] for d in deps: - pair_key = f"{d}→{dp.get('department_name', '')}" - dep_pairs[pair_key] = dep_pairs.get(pair_key, 0) + 1 + pair_key = f"{d}\u2192{dp.get('department_name', '')}" + if pair_key not in dep_pair_stats: + dep_pair_stats[pair_key] = {"pair": pair_key, "count": 0, "total_downstream_score": 0, "score_count": 0} + dep_pair_stats[pair_key]["count"] += 1 + ds = dept_score_map.get(dp.get("department_name", ""), 0) + if ds > 0: + dep_pair_stats[pair_key]["total_downstream_score"] += ds + dep_pair_stats[pair_key]["score_count"] += 1 - top_pairs = sorted(dep_pairs.items(), key=lambda x: x[1], reverse=True)[:5] - collaboration_pairs = [{"pair": k, "count": v} for k, v in top_pairs] + collaboration_pairs = [] + for k, v in sorted(dep_pair_stats.items(), key=lambda x: x[1]["count"], reverse=True)[:10]: + avg = round(v["total_downstream_score"] / v["score_count"], 1) if v["score_count"] > 0 else 0 + collaboration_pairs.append({"pair": k, "count": v["count"], "avg_downstream_score": avg}) - # Rounds - avg_rounds = round(sum(int(p.round_count or "1") for p in projects) / total, 1) if total > 0 else 0 + # V2.4: 项目级时间序列(按日统计) + daily_buckets: Dict[str, Dict] = {} + for p in projects: + if p.created_at: + day_key = p.created_at.strftime("%Y-%m-%d") + if day_key not in daily_buckets: + daily_buckets[day_key] = {"date": day_key, "count": 0, "pass_count": 0, "total_pass_rate": 0.0, "pass_count_for_avg": 0} + daily_buckets[day_key]["count"] += 1 + if p.validation_pass_rate is not None: + daily_buckets[day_key]["total_pass_rate"] += p.validation_pass_rate + daily_buckets[day_key]["pass_count_for_avg"] += 1 + if p.status == "completed" and p.validation_pass_rate and p.validation_pass_rate >= 0.6: + daily_buckets[day_key]["pass_count"] += 1 + + time_series = [] + for day_key in sorted(daily_buckets.keys()): + b = daily_buckets[day_key] + avg_pr = round(b["total_pass_rate"] / b["pass_count_for_avg"], 2) if b["pass_count_for_avg"] > 0 else 0 + time_series.append({ + "date": day_key, + "project_count": b["count"], + "avg_validation_pass_rate": avg_pr, + "passed_count": b["pass_count"], + }) + + # Rounds (handle both int and legacy string types) + avg_rounds = 0 + round_vals = [] + for p in projects: + rc = p.round_count + if rc is not None: + try: + round_vals.append(int(rc)) + except (ValueError, TypeError): + round_vals.append(1) + avg_rounds = round(sum(round_vals) / len(round_vals), 1) if round_vals else 0 + + # Average cost + avg_cost = 0 + costs = [p.cost_yuan for p in projects if getattr(p, "cost_yuan", None)] + if costs: + avg_cost = round(sum(costs) / len(costs), 2) # Failure reasons (from review scores) failure_reasons = [] @@ -105,6 +163,10 @@ def analyze_company(company_id: str, db: Session) -> Dict[str, Any]: low_depts = [k for k, v in dept_stats.items() if v.get("avg_score", 0) < 6 and v["score_count"] > 0] if low_depts: recommendations.append(f"以下部门评分偏低,建议优化其 Agent 配置或成员组成: {', '.join(low_depts)}") + # V2.4: 协作对建议 + if collaboration_pairs: + best_pair = collaboration_pairs[0] + recommendations.append(f"最佳协作链路: {best_pair['pair']}({best_pair['count']}次,下游均分 {best_pair['avg_downstream_score']})") if not recommendations: recommendations.append("整体表现良好,继续保持当前的项目执行模式") @@ -115,8 +177,9 @@ def analyze_company(company_id: str, db: Session) -> Dict[str, Any]: "success_rate": success_rate, "dept_stats": list(dept_stats.values()), "collaboration_pairs": collaboration_pairs, + "time_series": time_series, "avg_rounds": avg_rounds, - "avg_cost": 0, # Token cost tracking requires execution log integration + "avg_cost": avg_cost, "failure_reasons": failure_reasons[:10], "recommendations": recommendations, } diff --git a/backend/app/services/project_supervisor.py b/backend/app/services/project_supervisor.py index 7a26d7e..b328522 100644 --- a/backend/app/services/project_supervisor.py +++ b/backend/app/services/project_supervisor.py @@ -4,12 +4,13 @@ Project Supervisor — 监控所有公司项目的执行状态,检测僵尸项 僵尸项目定义: - status="in_progress" 且 updated_at 超过 ZOMBIE_TIMEOUT_SECONDS 未更新 - SSE 连接断开后,Agent 仍在后台运行但结果无法写回 + - V3.0: 标记前先查 Redis——若 Redis 中任务锁仍存活则跳过(DB 心跳慢但任务真在跑) 职责: 1. 周期性扫描(默认60秒) 2. 检测僵尸项目并标记为 "zombie" 3. 提供恢复接口,允许用户重试僵尸项目 - 4. 发送告警通知 + 4. 发送告警通知(V3.0: webhook 通知) """ from __future__ import annotations @@ -38,6 +39,14 @@ class ProjectSupervisor: self._last_scan: Optional[datetime] = None self._zombie_cache: Dict[str, datetime] = {} # project_id -> detected_at + async def _is_alive_in_redis(self, project_id: str) -> bool: + """V3.0: 查询 Redis 是否认为该任务正在运行(跨 worker 真实存活检查)。""" + try: + from app.services.redis_task_registry import is_running_anywhere + return await is_running_anywhere(project_id) + except Exception: + return False + def scan_zombie_projects(self, timeout_seconds: int = ZOMBIE_TIMEOUT_SECONDS) -> List[CompanyProject]: """扫描所有僵尸项目。 @@ -91,8 +100,8 @@ class ProjectSupervisor: self._zombie_cache[project_id] = datetime.now() return True - def auto_detect_and_mark(self, timeout_seconds: int = ZOMBIE_TIMEOUT_SECONDS) -> Dict[str, Any]: - """自动检测并标记僵尸项目。 + async def auto_detect_and_mark(self, timeout_seconds: int = ZOMBIE_TIMEOUT_SECONDS) -> Dict[str, Any]: + """自动检测并标记僵尸项目。V3.0: 标记前查 Redis 防误杀。 Returns: 扫描结果摘要 @@ -102,10 +111,17 @@ class ProjectSupervisor: "scanned_at": datetime.now().isoformat(), "zombie_count": len(zombies), "marked": 0, + "skipped_redis_alive": 0, "projects": [], } for p in zombies: + # V3.0: 先查 Redis——若任务锁仍存活,跳过(DB 心跳慢但任务真在跑) + if await self._is_alive_in_redis(p.id): + logger.info("跳过僵尸标记 [%s]: Redis 中任务仍在运行", p.id[:8]) + result["skipped_redis_alive"] += 1 + continue + idle_minutes = round((datetime.now() - p.updated_at).total_seconds() / 60, 1) reason = f"Project stuck in_progress for {idle_minutes} minutes without updates (last update: {p.updated_at.isoformat()})" if self.mark_as_zombie(p.id, reason): @@ -120,8 +136,13 @@ class ProjectSupervisor: if result["marked"] > 0: logger.warning( - "Supervisor scan: found %d zombies, marked %d", - result["zombie_count"], result["marked"] + "Supervisor scan: found %d zombies, marked %d, skipped (Redis alive) %d", + result["zombie_count"], result["marked"], result["skipped_redis_alive"] + ) + elif result["skipped_redis_alive"] > 0: + logger.info( + "Supervisor scan: %d candidates skipped (Redis confirms alive)", + result["skipped_redis_alive"] ) return result @@ -223,7 +244,7 @@ class ProjectSupervisor: async def run_supervisor_loop(db_session_factory) -> None: - """监管器主循环 — 集成到现有调度器中。 + """监管器主循环 — 集成到现有调度器中。V3.0: Redis 存活检查防误杀。 Args: db_session_factory: 返回新 Session 的可调用对象 @@ -237,11 +258,11 @@ async def run_supervisor_loop(db_session_factory) -> None: db = db_session_factory() try: supervisor = ProjectSupervisor(db) - result = supervisor.auto_detect_and_mark() - if result["zombie_count"] > 0: + result = await supervisor.auto_detect_and_mark() + if result["marked"] > 0: logger.warning( - "Supervisor: %d zombie projects detected, %d marked", - result["zombie_count"], result["marked"] + "Supervisor: %d zombie candidates, %d marked, %d skipped (Redis alive)", + result["zombie_count"], result["marked"], result.get("skipped_redis_alive", 0) ) finally: db.close() diff --git a/backend/app/services/redis_task_registry.py b/backend/app/services/redis_task_registry.py new file mode 100644 index 0000000..ba388cd --- /dev/null +++ b/backend/app/services/redis_task_registry.py @@ -0,0 +1,288 @@ +""" +Redis 任务注册表 — 跨 worker 项目状态与信号通道 (V3.0) + +替换进程内 _RUNNING / _CONTROL dict,使多 uvicorn worker 能: +1. 知道哪个 worker 在跑哪个项目 (SETNX 抢锁) +2. 跨 worker 广播 pause/cancel/approve 控制指令 (pub/sub) +3. worker 宕机时 TTL 过期 → 其他 worker 可接管 (SET TTL 60s + 心跳刷新) + +设计原则: +- Redis 只存元数据和控制信号,不存 asyncio Task 句柄(不可序列化) +- 每个 worker 保留自己的 _RUNNING dict 管理本地协程生命周期 +- Redis 数据是"声明":声明某个 worker 正在处理某个 project +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +import platform +import time +from typing import Any, Callable, Coroutine, Dict, Optional, Set + +import redis.asyncio as aioredis + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +# ─── Redis 连接 ─── + +_redis: Optional[aioredis.Redis] = None + + +async def _get_redis() -> aioredis.Redis: + """惰性连接(单例),断开自动重连。""" + global _redis + if _redis is not None: + try: + await _redis.ping() + return _redis + except Exception: + _redis = None + try: + _redis = aioredis.from_url( + settings.REDIS_URL, + decode_responses=True, + socket_connect_timeout=3, + socket_timeout=5, + retry_on_timeout=True, + ) + await _redis.ping() + logger.info("Redis 任务注册表已连接: %s", settings.REDIS_URL) + return _redis + except Exception as e: + logger.warning("Redis 不可用 (%s),降级为进程内模式", e) + _redis = None + raise + + +async def _redis_available() -> bool: + try: + r = await _get_redis() + return r is not None + except Exception: + return False + + +# ─── Worker 标识 ─── + +_WORKER_ID = f"{platform.node()}-{os.getpid()}" +_TASK_TTL = 60 # 任务锁 TTL(秒),心跳不续则自动释放 +_HEARTBEAT_TTL = 120 # worker 存活 TTL + + +async def register_worker() -> bool: + """启动时注册本 worker(hash 存 worker 元数据),返回是否成功。""" + try: + r = await _get_redis() + await r.hset( + f"company:worker:{_WORKER_ID}", + mapping={ + "host": platform.node(), + "pid": str(os.getpid()), + "started_at": str(time.time()), + }, + ) + await r.expire(f"company:worker:{_WORKER_ID}", _HEARTBEAT_TTL) + # 加入活跃 worker 集合 + await r.sadd("company:workers:active", _WORKER_ID) + logger.info("Worker 已注册: %s", _WORKER_ID) + return True + except Exception as e: + logger.warning("Worker 注册失败: %s", e) + return False + + +async def unregister_worker() -> None: + """关闭时注销本 worker。""" + try: + r = await _get_redis() + await r.delete(f"company:worker:{_WORKER_ID}") + await r.srem("company:workers:active", _WORKER_ID) + logger.info("Worker 已注销: %s", _WORKER_ID) + except Exception: + pass + + +async def worker_heartbeat() -> None: + """周期性心跳:续期 worker TTL + 所有本 worker 运行中任务的 TTL。""" + try: + r = await _get_redis() + now = time.time() + await r.hset(f"company:worker:{_WORKER_ID}", "last_heartbeat", str(now)) + await r.expire(f"company:worker:{_WORKER_ID}", _HEARTBEAT_TTL) + await r.sadd("company:workers:active", _WORKER_ID) + except Exception: + pass + + +async def list_active_workers() -> list[dict]: + """列出所有活跃 worker(用于监控)。""" + try: + r = await _get_redis() + members = await r.smembers("company:workers:active") + workers = [] + for wid in members: + data = await r.hgetall(f"company:worker:{wid}") + if data: + workers.append({"worker_id": wid, **data}) + return workers + except Exception: + return [] + + +# ─── 任务注册 API ─── + +async def try_acquire(project_id: str) -> bool: + """SETNX 抢锁:返回 True 表示本 worker 获得执行权。""" + try: + r = await _get_redis() + key = f"company:task:{project_id}" + ok = await r.set(key, json.dumps({ + "worker_id": _WORKER_ID, + "status": "running", + "started_at": time.time(), + }), nx=True, ex=_TASK_TTL) + if ok: + logger.info("任务锁获取成功: %s → %s", project_id, _WORKER_ID) + return bool(ok) + except Exception as e: + logger.warning("Redis SETNX 失败 (%s),降级放行", e) + return True # Redis 不可用时默认允许(回归单 worker 模式) + + +async def refresh_task_ttl(project_id: str) -> None: + """刷新任务 TTL(每轮心跳调用)。""" + try: + r = await _get_redis() + key = f"company:task:{project_id}" + await r.expire(key, _TASK_TTL) + except Exception: + pass + + +async def release_task(project_id: str) -> None: + """任务完成/取消时释放锁。""" + try: + r = await _get_redis() + await r.delete(f"company:task:{project_id}") + logger.info("任务锁已释放: %s", project_id) + except Exception: + pass + + +async def get_task_info(project_id: str) -> Optional[dict]: + """查询任务运行信息(哪个 worker 在跑)。""" + try: + r = await _get_redis() + data = await r.get(f"company:task:{project_id}") + if data: + return json.loads(data) + except Exception: + pass + return None + + +async def is_running_anywhere(project_id: str) -> bool: + """检查 project 是否在任何 worker 上运行。""" + info = await get_task_info(project_id) + return info is not None + + +async def list_running_tasks() -> list[dict]: + """列出所有运行中的任务(用于监控和僵尸检测)。""" + try: + r = await _get_redis() + keys = [] + async for key in r.scan_iter(match="company:task:*"): + keys.append(key) + tasks = [] + for key in keys: + data = await r.get(key) + if data: + info = json.loads(data) + info["project_id"] = key.replace("company:task:", "") + tasks.append(info) + return tasks + except Exception: + return [] + + +# ─── 控制信号 (Pub/Sub) ─── + +_CONTROL_CHANNEL = "company:control:{project_id}" + + +async def publish_control(project_id: str, cmd: dict) -> bool: + """向所有订阅者广播控制指令(pause/cancel/resume/approve)。""" + try: + r = await _get_redis() + channel = _CONTROL_CHANNEL.format(project_id=project_id) + await r.publish(channel, json.dumps(cmd)) + logger.info("控制指令已广播: %s → %s", project_id, cmd.get("action")) + return True + except Exception as e: + logger.warning("控制指令广播失败: %s", e) + return False + + +async def subscribe_control( + project_id: str, + handler: Callable[[dict], Coroutine[Any, Any, None]], +) -> asyncio.Task: + """订阅控制频道,收到消息时回调 handler。返回订阅 task(可 cancel 取消)。 + + handler 示例: + async def on_control(cmd: dict): + if cmd["action"] == "pause": + ctrl.pause_event.clear() + elif cmd["action"] == "resume": + ctrl.pause_event.set() + """ + async def _listen(): + try: + r = await _get_redis() + channel = _CONTROL_CHANNEL.format(project_id=project_id) + pubsub = r.pubsub() + await pubsub.subscribe(channel) + logger.info("已订阅控制频道: %s", channel) + async for msg in pubsub.listen(): + if msg["type"] != "message": + continue + try: + cmd = json.loads(msg["data"]) + # 忽略本 worker 发出的消息(避免回声) + if cmd.get("sender_worker") == _WORKER_ID: + continue + await handler(cmd) + except Exception as e: + logger.warning("处理控制指令失败: %s", e) + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning("控制频道订阅异常: %s", e) + + return asyncio.create_task(_listen()) + + +async def send_pause(project_id: str) -> bool: + return await publish_control(project_id, {"action": "pause", "sender_worker": _WORKER_ID}) + + +async def send_resume(project_id: str) -> bool: + return await publish_control(project_id, {"action": "resume", "sender_worker": _WORKER_ID}) + + +async def send_cancel(project_id: str) -> bool: + return await publish_control(project_id, {"action": "cancel", "sender_worker": _WORKER_ID}) + + +async def send_approve(project_id: str, decision: str, feedback: str = "") -> bool: + return await publish_control(project_id, { + "action": "approve", + "decision": decision, + "feedback": feedback, + "sender_worker": _WORKER_ID, + }) diff --git a/backend/app/services/team_orchestrator.py b/backend/app/services/team_orchestrator.py index 01a0b8b..3bc1be2 100644 --- a/backend/app/services/team_orchestrator.py +++ b/backend/app/services/team_orchestrator.py @@ -795,6 +795,8 @@ class TeamOrchestrator: output_parts: List[str] = [] last_iteration = 0 tool_count = 0 + # P1-25: 工具故障熔断 — 追踪同工具连续同质错误 + _tool_err_streak: Dict[str, tuple] = {} # tool_name -> (err_sig, count) async for agent_event in runtime.run_stream(phase_input): event_type = agent_event.get("type", "") @@ -812,10 +814,69 @@ class TeamOrchestrator: output_parts.append(content) elif event_type == "tool_result": tool_count += 1 + # P1-25: 追踪同工具连续同质错误(error 关键词 + 同一错误签名) + tname = str(agent_event.get("name", "")) + tres = str(agent_event.get("result", "")) + if tname and ("error" in tres.lower() or "not found" in tres.lower() or "syntax" in tres.lower()): + err_sig = tres[:80] # 取前 80 字符作为错误签名 + prev = _tool_err_streak.get(tname) + if prev and prev[0] == err_sig: + _tool_err_streak[tname] = (err_sig, prev[1] + 1) + else: + _tool_err_streak[tname] = (err_sig, 1) last_iteration = agent_event.get("iteration", last_iteration) output_text = "\n\n".join(output_parts) if output_parts else "执行完成" + # P1-25: 工具故障熔断 — agent 大量工具调用但无实质产出,推断在无效重试 + # 用禁用工具提示重试一次,避免空转浪费成本 + _max_iter = agent_config.llm.max_iterations if agent_config.llm.max_iterations > 3 else 10 + _stuck = ( + tool_count >= 15 + and len(output_text) < 200 + and last_iteration >= _max_iter - 2 + ) + if _stuck: + broken_tools = [tn for tn, (_, cnt) in _tool_err_streak.items() if cnt >= 3] + if broken_tools: + logger.warning( + "工具故障熔断 [%s] 阶段 %s: %d 工具调用/%d 迭代/%d 输出字符, " + "故障工具: %s, 禁用后重试", + self.team_id, phase_name, tool_count, last_iteration, + len(output_text), broken_tools, + ) + event_queue.put_nowait({ + **base_event, + "type": "agent_event", + "data": {"type": "think", + "content": f"⚠️ 工具故障熔断: {', '.join(broken_tools)} 反复失败,禁用该工具后重试..."}, + }) + broken_list = ", ".join(broken_tools) + retry_input = ( + f"【SYSTEM: The following tools FAILED repeatedly in your previous attempt and are UNAVAILABLE: {broken_list}. " + f"Do NOT call them again. Complete the task using your own knowledge or other available tools. " + f"If you truly cannot proceed without these tools, honestly state what you cannot complete.】\n\n" + f"{phase_input}" + ) + output_parts = [] + tool_count = 0 + try: + async for agent_event in runtime.run_stream(retry_input): + event_queue.put_nowait({ + **base_event, + "type": "agent_event", + "data": agent_event, + }) + if agent_event.get("type") == "final": + content = agent_event.get("content", "") + if content: + output_parts.append(content) + elif agent_event.get("type") == "tool_result": + tool_count += 1 + except Exception: + logger.warning("工具故障熔断重试异常 [%s] 阶段 %s", self.team_id, phase_name, exc_info=True) + output_text = "\n\n".join(output_parts) if output_parts else output_text + # ─── P2-2: Self-review 自优化 ─── self_review = phase.get("self_review", False) if self_review and output_parts: diff --git a/backend/app/services/tool_registry.py b/backend/app/services/tool_registry.py index aca917d..8cc617d 100644 --- a/backend/app/services/tool_registry.py +++ b/backend/app/services/tool_registry.py @@ -7,6 +7,7 @@ from __future__ import annotations import json import logging +import time as _time import traceback from typing import Any, Callable, Dict, List, Optional @@ -293,7 +294,17 @@ class ToolRegistry: } 代码工具需定义一个 run(args) 函数作为入口。 source 在沙箱环境中执行,不可访问文件系统/网络。 + + V2.x 安全加固: 需在 config 中显式设置 ALLOW_CUSTOM_CODE_TOOLS=True 才允许执行, + 防止自定义代码在主进程 exec 导致沙箱逃逸。 """ + from app.core.config import settings + if not settings.ALLOW_CUSTOM_CODE_TOOLS: + return json.dumps({ + "error": "自定义代码工具已被管理员禁用(ALLOW_CUSTOM_CODE_TOOLS=False)。" + "如需使用,请联系管理员将环境变量 ALLOW_CUSTOM_CODE_TOOLS 设为 true 后重启服务。", + }, ensure_ascii=False) + source = config.get("source", "") if not source: return json.dumps({"error": "代码工具未配置 source"}, ensure_ascii=False) @@ -392,12 +403,12 @@ class ToolRegistry: except json.JSONDecodeError: body_sent = body_str - start = __import__("time").time() + start = _time.time() try: async with httpx.AsyncClient(timeout=timeout) as client: response = await client.request(method.upper(), url, headers=headers, json=body_sent) - elapsed_ms = int((__import__("time").time() - start) * 1000) + elapsed_ms = int((_time.time() - start) * 1000) return { "success": True, "status_code": response.status_code, @@ -410,7 +421,10 @@ class ToolRegistry: async def test_code_tool( self, source: str, args: Dict[str, Any] ) -> Dict[str, Any]: - """测试代码工具(不保存到 DB)。""" + """测试代码工具(不保存到 DB)。V2.x: 同样受 ALLOW_CUSTOM_CODE_TOOLS 开关控制。""" + from app.core.config import settings + if not settings.ALLOW_CUSTOM_CODE_TOOLS: + return {"success": False, "error": "自定义代码工具已被管理员禁用(ALLOW_CUSTOM_CODE_TOOLS=False)"} safe_globals = _CODE_SAFE_GLOBALS.copy() safe_globals["__builtins__"] = {} @@ -424,9 +438,9 @@ class ToolRegistry: return {"success": False, "error": "代码须定义 run(args) 函数"} try: - start = __import__("time").time() + start = _time.time() result = run_func(args) - elapsed_ms = int((__import__("time").time() - start) * 1000) + elapsed_ms = int((_time.time() - start) * 1000) return {"success": True, "elapsed_ms": elapsed_ms, "result": result} except Exception as e: return {"success": False, "error": f"执行失败: {e}"} diff --git a/backend/tests/test_agent_runtime.py b/backend/tests/test_agent_runtime.py index 2bc623f..a7466f8 100644 --- a/backend/tests/test_agent_runtime.py +++ b/backend/tests/test_agent_runtime.py @@ -578,6 +578,8 @@ class TestAgentRuntimeStreaming: async def test_run_stream_with_tool_calls(self): config = make_config() config.llm.max_iterations = 3 + # V2.x: 未知工具在 default 级别默认 DENY;本用例验证流式工具调用链路而非权限策略,用 bypass + config.tools.permission_level = "bypass" runtime = AgentRuntime(config=config) def echo(**kwargs): @@ -595,7 +597,7 @@ class TestAgentRuntimeStreaming: ] it = iter(llm_responses) - async def mock_chat(messages, tools, iteration, on_completion=None): + async def mock_chat(messages, tools, iteration, on_completion=None, on_delta=None): return next(it) mocks, mock_llm = _base_mocks(runtime) diff --git a/backend/tests/test_company_orchestrator.py b/backend/tests/test_company_orchestrator.py index e5bbeca..99f24a5 100644 --- a/backend/tests/test_company_orchestrator.py +++ b/backend/tests/test_company_orchestrator.py @@ -47,8 +47,9 @@ class TestParseCeoJson: result = _parse_ceo_json(raw) assert result["departments"][0]["department_name"] == "数据组" - def test_garbage_returns_empty(self): - assert _parse_ceo_json("完全不是 JSON 的输出") == {} + def test_garbage_raises_value_error(self): + with pytest.raises(ValueError, match="CEO 规划解析失败"): + _parse_ceo_json("完全不是 JSON 的输出") def test_brace_extract_with_surrounding_text(self): raw = '这是计划:\n{"analysis": "a", "departments": []}\n以上。' @@ -59,39 +60,48 @@ class TestParseCeoJson: # ─── _select_relevant_knowledge ─── class TestSelectRelevantKnowledge: - def test_empty_entries(self): - assert _select_relevant_knowledge([], "市场营销", 5, 1000) == "" + @pytest.fixture(autouse=True) + def _no_embedding(self, monkeypatch): + """禁用 embedding(返回 None)→ 走纯关键词降级路径,测试确定性、无网络依赖。""" + from app.services import embedding_service as es_module - def test_zero_limits(self): + async def _none(*a, **k): + return None + monkeypatch.setattr(es_module.embedding_service, "generate_embedding", _none) + + async def test_empty_entries(self): + assert await _select_relevant_knowledge([], "市场营销", 5, 1000) == "" + + async def test_zero_limits(self): entries = [{"title": "市场", "content": "市场", "category": "review", "tags": []}] - assert _select_relevant_knowledge(entries, "市场", 0, 1000) == "" - assert _select_relevant_knowledge(entries, "市场", 5, 0) == "" + assert await _select_relevant_knowledge(entries, "市场", 0, 1000) == "" + assert await _select_relevant_knowledge(entries, "市场", 5, 0) == "" - def test_irrelevant_excluded(self): + async def test_irrelevant_excluded(self): entries = [ {"title": "市场分析教训", "content": "上次市场项目的增长策略", "category": "review", "tags": ["市场"], "_recency": 2}, {"title": "无关条目", "content": "烹饪菜谱大全", "category": "general", "tags": [], "_recency": 1}, ] - result = _select_relevant_knowledge(entries, "做一个市场营销产品", 5, 2000) + result = await _select_relevant_knowledge(entries, "做一个市场营销产品", 5, 2000) assert "市场分析教训" in result assert "无关条目" not in result - def test_no_relevant_returns_empty(self): + async def test_no_relevant_returns_empty(self): entries = [{"title": "菜谱", "content": "红烧肉做法", "category": "general", "tags": []}] - assert _select_relevant_knowledge(entries, "做一个市场营销产品", 5, 2000) == "" + assert await _select_relevant_knowledge(entries, "做一个市场营销产品", 5, 2000) == "" - def test_max_chars_truncation(self): + async def test_max_chars_truncation(self): entries = [ {"title": "市场教训", "content": "市场" + "长" * 500, "category": "review", "tags": []}, ] - result = _select_relevant_knowledge(entries, "市场分析", 5, 50) + result = await _select_relevant_knowledge(entries, "市场分析", 5, 50) assert result != "" # 正文块被截到 50 字以内(外加固定头部) assert "长" * 100 not in result - def test_review_category_weighted_higher(self): + async def test_review_category_weighted_higher(self): """review/insight 类别权重高,同等主题命中下排前。""" entries = [ {"title": "部门产出", "content": "市场 数据", "category": "dept_output", @@ -99,11 +109,30 @@ class TestSelectRelevantKnowledge: {"title": "审查教训", "content": "市场 数据", "category": "review", "tags": [], "_recency": 1}, ] - result = _select_relevant_knowledge(entries, "市场数据分析", 1, 2000) + result = await _select_relevant_knowledge(entries, "市场数据分析", 1, 2000) # 只取 1 条时,权重更高的 review 入选 assert "审查教训" in result assert "部门产出" not in result + async def test_embedding_selects_semantic_match(self, monkeypatch): + """embedding 路径:语义相似但无关键词命中的条目可被选中,正交条目被防噪声阈值排除。""" + from app.services import embedding_service as es_module + + async def fake_embed(text): + # query「市场」与语义命中条目同向(cos=1),其余正交(cos=0) + if "市场" in text or "策略" in text: + return [1.0, 0.0] + return [0.0, 1.0] + monkeypatch.setattr(es_module.embedding_service, "generate_embedding", fake_embed) + + entries = [ + {"title": "增长策略复盘", "content": "策略", "category": "review", "tags": []}, + {"title": "菜谱", "content": "红烧肉", "category": "general", "tags": []}, + ] + result = await _select_relevant_knowledge(entries, "市场", 5, 2000) + assert "增长策略复盘" in result + assert "菜谱" not in result + # ─── _build_dept_context ─── @@ -113,44 +142,46 @@ class TestBuildDeptContext: def test_no_dependencies_returns_base(self): orch = self._orch() - ctx = orch._build_dept_context({"dependencies": []}, "CEO 分析", {"A": "交付物"}) + ctx = orch._build_dept_context({"dependencies": []}, "CEO 分析", {"A": {"deliverable": "交付物"}}) assert ctx == "CEO 分析" def test_handoff_injected(self): orch = self._orch() ctx = orch._build_dept_context( - {"dependencies": ["数据组"]}, "CEO 分析", {"数据组": "上游数据内容"} + {"dependencies": ["数据组"]}, "CEO 分析", {"数据组": {"deliverable": "上游数据内容"}} ) - assert "上游部门「数据组」" in ctx + assert "上游部门「数据组」" in ctx or "Handoff from 「数据组」" in ctx assert "上游数据内容" in ctx assert "CEO 分析" in ctx def test_per_dept_char_limit(self): orch = self._orch() ctx = orch._build_dept_context( - {"dependencies": ["A"]}, "", {"A": "x" * 5000} + {"dependencies": ["A"]}, "", {"A": {"deliverable": "x" * 5000}} ) - # 单部门交付物注入上限 2000 字 - assert "x" * 2000 in ctx - assert "x" * 2001 not in ctx + # 单部门交付物注入上限 _HANDOFF_PER_DEPT_CHARS(2000) - 200(struct overhead) = 1800 + assert "x" * 1800 in ctx + assert "x" * 1801 not in ctx def test_total_char_limit(self): orch = self._orch() - deliverables = {n: "x" * 2000 for n in ["A", "B", "C", "D"]} + deliverables = {n: {"deliverable": "x" * 2000} for n in ["A", "B", "C", "D"]} ctx = orch._build_dept_context( {"dependencies": ["A", "B", "C", "D"]}, "", deliverables ) # 合计上限 6000:前三个各 2000 注满,第四个被截断 - assert "上游部门「A」" in ctx - assert "上游部门「C」" in ctx - assert "上游部门「D」" not in ctx + assert "Handoff from 「A」" in ctx + assert "Handoff from 「C」" in ctx + assert "Handoff from 「D」" not in ctx def test_missing_deliverable_skipped(self): orch = self._orch() ctx = orch._build_dept_context( {"dependencies": ["不存在"]}, "CEO 分析", {} ) - assert ctx == "CEO 分析" + # unmet dep warning injected even without completed deliverables + assert "CEO 分析" in ctx + assert "尚未产出交付物" in ctx # ─── execute_stream 全流程(mock TeamOrchestrator + CEO 评审)─── @@ -231,6 +262,19 @@ def _disable_knowledge_extract(monkeypatch): monkeypatch.setattr(settings, "COMPANY_AUTO_EXTRACT_KNOWLEDGE", False) +def _pass_validation_gate(monkeypatch): + """跳过 V2.25.1 交付前自验门(本文件聚焦波次/交接/session 行为,不测验证器)。 + + 桩返回空 departments 的 ValidationReport → all() 空判定 True → 一次通过, + 避免 fake 交付物触发最多 2 轮自修重跑(TeamOrchestrator 实例数随之翻倍)。 + """ + from app.services.deliverable_validator import DeliverableValidator, ValidationReport + monkeypatch.setattr( + DeliverableValidator, "validate", + staticmethod(lambda project_path, dept_results: ValidationReport()), + ) + + async def test_execute_stream_parallel_wave_per_dept_sessions(db_session, monkeypatch, tmp_path): """同波并行部门:各自拿到独立 session(非编排器共享 session),且用完关闭。""" monkeypatch.chdir(tmp_path) # project_path mkdir 落到临时目录 @@ -243,6 +287,7 @@ async def test_execute_stream_parallel_wave_per_dept_sessions(db_session, monkey instances = _patch_team_orchestrator(monkeypatch) _patch_review(monkeypatch) _disable_knowledge_extract(monkeypatch) + _pass_validation_gate(monkeypatch) orch = CompanyOrchestrator(db_session, company.id, "u1") events = [e async for e in orch.execute_stream("测试项目", ceo_plan=plan)] @@ -277,6 +322,7 @@ async def test_execute_stream_dependency_order_and_handoff(db_session, monkeypat instances = _patch_team_orchestrator(monkeypatch) _patch_review(monkeypatch) _disable_knowledge_extract(monkeypatch) + _pass_validation_gate(monkeypatch) orch = CompanyOrchestrator(db_session, company.id, "u1") events = [e async for e in orch.execute_stream("测试项目", ceo_plan=plan)] @@ -290,9 +336,9 @@ async def test_execute_stream_dependency_order_and_handoff(db_session, monkeypat assert instances[0].team_id == teams[0].id assert instances[1].team_id == teams[1].id - # 四档-① 交接:数据组的交付物注入前端组任务上下文 + # V2.25.5 结构化交接:数据组的交付物以 Handoff 包形式注入前端组任务上下文 downstream_task = instances[1].tasks[0] - assert "上游部门「数据组」" in downstream_task + assert "Handoff from 「数据组」" in downstream_task assert f"交付物-{teams[0].id}" in downstream_task @@ -308,6 +354,7 @@ async def test_execute_stream_uses_shared_session_only_sequentially( instances = _patch_team_orchestrator(monkeypatch) _patch_review(monkeypatch) _disable_knowledge_extract(monkeypatch) + _pass_validation_gate(monkeypatch) orch = CompanyOrchestrator(db_session, company.id, "u1") events = [e async for e in orch.execute_stream("测试项目", ceo_plan=plan)] diff --git a/backend/tests/test_tool_registry.py b/backend/tests/test_tool_registry.py index da8e6fe..fd026e1 100644 --- a/backend/tests/test_tool_registry.py +++ b/backend/tests/test_tool_registry.py @@ -138,6 +138,28 @@ class TestToolRegistryHTTP: class TestToolRegistryCode: """代码沙箱执行测试""" + @pytest.fixture(autouse=True) + def _allow_code_tools(self, monkeypatch): + """V2.x 安全门禁默认禁用自定义代码工具(ALLOW_CUSTOM_CODE_TOOLS=False)。 + 本类聚焦沙箱执行行为本身,显式开门;门禁判定由专门的测试覆盖。""" + from app.core.config import settings + monkeypatch.setattr(settings, "ALLOW_CUSTOM_CODE_TOOLS", True) + + @pytest.mark.unit + @pytest.mark.asyncio + async def test_code_tool_gate_disabled_denies(self, registry, monkeypatch): + """V2.x 门禁:ALLOW_CUSTOM_CODE_TOOLS=False 时直接拒绝执行。""" + from app.core.config import settings + monkeypatch.setattr(settings, "ALLOW_CUSTOM_CODE_TOOLS", False) + config = { + "source": "def run(args):\n return {'sum': 1}", + "_type": "code", + } + registry._custom_tool_configs["gated"] = config + result = await registry.execute_tool("gated", {}) + assert "ALLOW_CUSTOM_CODE_TOOLS" in result + assert "sum" not in result + @pytest.mark.unit @pytest.mark.asyncio async def test_execute_code_simple(self, registry): @@ -219,6 +241,12 @@ class TestToolRegistryCode: class TestToolRegistryTestHelpers: """测试工具(不保存到 DB)""" + @pytest.fixture(autouse=True) + def _allow_code_tools(self, monkeypatch): + """V2.x 安全门禁:test_code_tool 同样受 ALLOW_CUSTOM_CODE_TOOLS 控制,显式开门。""" + from app.core.config import settings + monkeypatch.setattr(settings, "ALLOW_CUSTOM_CODE_TOOLS", True) + @pytest.mark.unit @pytest.mark.asyncio async def test_test_code_tool_success(self, registry): diff --git a/backend/tests/test_tools_api.py b/backend/tests/test_tools_api.py index 68171b1..bfdac10 100644 --- a/backend/tests/test_tools_api.py +++ b/backend/tests/test_tools_api.py @@ -170,7 +170,10 @@ class TestToolsAPI: assert data["success"] is True @pytest.mark.api - def test_test_code_endpoint(self, authenticated_client): + def test_test_code_endpoint(self, authenticated_client, monkeypatch): + from app.core.config import settings + # V2.x 安全门禁默认禁用自定义代码工具,本用例验证端点功能,显式开门 + monkeypatch.setattr(settings, "ALLOW_CUSTOM_CODE_TOOLS", True) resp = authenticated_client.post("/api/v1/tools/test/code", json={ "source": "def run(args):\n return args.get('x', 0) + args.get('y', 0)", "args": {"x": 10, "y": 20}, diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..092d164 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,11 @@ +# ============================================================================= +# .env.example — 「家庭泡饮」制作助手 环境变量模板 +# 复制此文件为 .env 后按需修改 +# ============================================================================= + +# ======================== +# 宿主机端口映射 +# 默认值: 8080 +# 修改为 3000 则 http://localhost:3000 访问 +# ======================== +# HOST_PORT=8080 diff --git a/docs/AgentChat语音体验优化执行文档.md b/docs/AgentChat语音体验优化执行文档.md new file mode 100644 index 0000000..aac1a22 --- /dev/null +++ b/docs/AgentChat语音体验优化执行文档.md @@ -0,0 +1,388 @@ +# AgentChat 语音体验优化 — 执行落地文档 + +> 制定日期:2026-07-31 ・ 范围:`/agent-chat` 全链路(前端 AgentChat.vue + useTTS + 后端 agent_chat / agent_runtime / voice API) +> 依据:温柔女友 agent 4 轮实测(详见下表)+ 服务端日志毫秒级时间线对齐 +> 总目标:**体验感拉满 + 默认语音回答**。验收以实测指标为准,不以"代码改了"为准。 + +--- + +## 〇、实测基线(2026-07-30,deepseek-v4-pro,无工具纯聊天) + +| 轮次 | 发送内容 | 首事件延迟 | 总耗时 | 回复长度 | +|---|---|---|---|---| +| R1 | 猫咪,你真可爱 | 12.5s | 18.2s | 131 字 | +| R2 | 你今天过得怎么样呀? | 11.7s | 15.4s | 131 字 | +| R3 | 你还记得我第一句说什么吗? | **22.6s** | 29.5s | 371 字(含 markdown 表格)| +| R4 | 讲个睡前故事 | 13.9s | 20.4s | 247 字 | + +单轮服务端拆解(日志对齐): + +``` +发送 → 2.1s 后端受理(事件循环疑似被后台 embedding 风暴阻塞) + → 0.9s 端点装配(SSE 头发出) + → 2.0s 记忆初始化(2 次串行 siliconflow embedding 调用) + → 1.0s LLM 建联 + → 5.6s deepseek-v4-pro TTFT(推理型模型,期间 0 个 token) + → 0.16s 整段回复一次性吐完(打字机效果形同虚设) + → final 后连接多挂 1.7s(学习更新+记忆压缩+3 次 embedding 才关闭) +``` + +三个结论: +1. **12 秒里用户只看到一个三点气泡** —— 等待无叙事、回复砸脸。 +2. **TTS 链路已通但只能手动点"朗读"** —— 默认语音是纯前端缺口,后端 `/api/v1/voice/tts` 现成。 +3. **回复格式对语音是灾难**(markdown 表格/加粗/分割线)—— 是 prompt 问题,不是代码问题。 + +--- + +## 一、任务总览 + +| # | 事项 | 侧 | 预估 | 依赖 | +|---|---|---|---|---| +| W1 | 默认语音回复:自动播放开关 + 句级流式 TTS 队列 | 前端 | 1 天 | 无(核心项,先做)| +| W2 | 等待体验叙事化:阶段文案气泡 | 前端 | 0.5 天 | 无 | +| W3 | 温柔女友模型切 deepseek-v4-flash | 运营操作(配置页)| 10 分钟 | 确认 flash 已在模型配置 | +| W4 | 语音友好输出:persona prompt 修订 + TTS 文本净化 | prompt + 前端 | 0.5 天 | 无 | +| W5 | 后端提速:embedding 并行化 + post-final 后台化 + TTS 文件清理 | 后端 | 0.5 天 | 无 | +| W6 | per-agent 音色绑定(node config 加 voice 字段)| 前端 + 配置 | 0.5 天 | W1 | +| W7 | 事实性问题保真(prompt 补丁)| prompt | 10 分钟 | 与 W4 合并发布 | + +**不做**:SSE 事件协议(think/answer_chunk/final)不动;token/成本展示不动;会话管理/搜索/导出不改。Edge TTS 双方案(后端神经语音 + 浏览器降级)架构不动。 + +--- + +## W1 — 默认语音回复(核心) + +### 问题 +`AgentChat.vue:272` 的朗读按钮是唯一 TTS 入口,每条消息要手动点。`useTTS.speak()`(`useTTS.ts:55`)内部第一句就 `stop()`,**直接循环调 speak 会把上一句切断**,必须新写队列。 + +### 改动 1:新建 `frontend/src/composables/useSpeechQueue.ts` + +句级流式播放器。职责:接收句子 → 调 `/api/v1/voice/tts` 取 mp3 → 按序播放;允许"边收边播"(第 1 句在念时,第 2、3 句的 TTS 请求并发预取);新回复/用户打断时清空队列。 + +```ts +import { ref } from 'vue' + +const isSpeaking = ref(false) +const queue: string[] = [] // 待合成句子 +const audioQueue: HTMLAudioElement[] = [] // 已合成待播 +let fetching = false +let playing = false +let generation = 0 // 代际标记:新回复使旧队列失效 + +export function useSpeechQueue() { + const baseUrl = () => { + if (import.meta.env.DEV) return '' + const h = window.location.hostname + return (h === 'localhost' || h === '127.0.0.1') + ? 'http://localhost:8037' + : `${window.location.protocol}//${h}:8037` + } + + async function fetchAudio(text: string, voice: string, gen: number): Promise { + try { + const token = localStorage.getItem('token') || '' + const resp = await fetch(`${baseUrl()}/api/v1/voice/tts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify({ text, voice, speed: 1.0 }), + }) + if (!resp.ok || gen !== generation) return null + const data = await resp.json() + if (!data.audio_url || gen !== generation) return null + const url = data.audio_url.startsWith('http') ? data.audio_url : `${baseUrl()}${data.audio_url}` + const audio = new Audio(url) + audio.preload = 'auto' + return audio + } catch { return null } // 单句失败静默丢弃,不打断整轮 + } + + async function pump(voice: string, gen: number) { + if (fetching) return + fetching = true + while (queue.length && gen === generation) { + const sentence = queue.shift()! + const audio = await fetchAudio(sentence, voice, gen) // 串行合成保证顺序;如需更低延迟可改成"预取下一句" + if (audio && gen === generation) audioQueue.push(audio) + void playNext(gen) + } + fetching = false + } + + async function playNext(gen: number) { + if (playing) return + const audio = audioQueue.shift() + if (!audio || gen !== generation) return + playing = true + isSpeaking.value = true + audio.onended = () => { playing = false; void playNext(gen); if (!playing && !audioQueue.length && !queue.length) isSpeaking.value = false } + audio.onerror = () => { playing = false; void playNext(gen) } + try { await audio.play() } catch { playing = false } + } + + function enqueue(sentence: string, voice: string) { + const gen = generation + queue.push(sentence) + void pump(voice, gen) + } + + function stop() { + generation++ // 使所有在途回调失效 + queue.length = 0 + audioQueue.forEach(a => a.pause()) + audioQueue.length = 0 + playing = false + isSpeaking.value = false + } + + return { isSpeaking, enqueue, stop } +} +``` + +**浏览器自动播放策略**:`Audio.play()` 需要用户手势前置。聊天页用户必定先点了发送按钮,手势链有效;但"收到新消息自动播"在标签页后台时会被拒——`play()` catch 静默跳过即可,不弹错误。 + +### 改动 2:`AgentChat.vue` 接入 + +```ts +// 头部 import 区 +import { useSpeechQueue } from '@/composables/useSpeechQueue' +const { isSpeaking: queueSpeaking, enqueue: enqueueSentence, stop: stopSpeechQueue } = useSpeechQueue() + +// 设置区新增(chatVoice 声明附近,~611 行) +const autoSpeak = ref(false) // 默认语音回复开关 + +// 句子切分缓冲(sendMessage 函数域) +let speakBuf = '' +const SENTENCE_RE = /[^。!?!?\n~~]+[。!?!?\n~~]+/g +function feedSpeak(chunk: string) { + if (!autoSpeak.value) return + speakBuf += chunk + let m: RegExpExecArray | null + SENTENCE_RE.lastIndex = 0 + let lastEnd = 0 + while ((m = SENTENCE_RE.exec(speakBuf)) !== null) { + const s = stripMarkdownForTTS(m[0]).trim() + if (s) enqueueSentence(s, chatVoice.value) + lastEnd = SENTENCE_RE.lastIndex + } + speakBuf = speakBuf.slice(lastEnd) +} +function flushSpeak() { + if (!autoSpeak.value) return + const s = stripMarkdownForTTS(speakBuf).trim() + if (s) enqueueSentence(s, chatVoice.value) + speakBuf = '' +} +``` + +接线点(`AgentChat.vue` 流解析循环,~1150-1164 行): + +```ts +} else if (eventType === 'answer_chunk') { + currentMsg.content = (currentMsg.content || '') + (data.content || '') + feedSpeak(data.content || '') // ← 新增 + scrollToBottom() +} else if (eventType === 'final') { + clearTimeout(streamTimeout) + currentMsg.content = data.content || '' + flushSpeak() // ← 新增:剩余半句补播 + // ...原有逻辑 +``` + +发送新消息前 `stopSpeechQueue()`(打断上一轮朗读);手动"停止"按钮(`show-stop`)也要调 `stopSpeechQueue()`。 + +### 改动 3:设置面板 UI + 持久化 + +设置面板(~343 行"朗读语音"select 上方)加: + +```html +
自动语音回复
+ +``` + +`saveState()`(~538 行)与 `ChatState` 接口(~525 行)加 `autoSpeak: boolean`;`loadState()`(~865 行)恢复。**默认 false**,用户在设置里开一次即持久。 + +### 验收 +- 开启开关后,发送"猫咪,你真可爱":第一处句号出现的 1.5s 内开始出声;整轮文本流完时,语音最多落后 1 句。 +- 朗读中途发送新消息:旧语音立刻停,新语音从第一句开始。 +- 关闭开关:行为与现状一致(仅手动朗读按钮)。 +- 刷新页面开关状态保留。 + +--- + +## W2 — 等待体验叙事化 + +### 问题 +发送后到首个事件 12-22s,UI 只有 `AgentChat.vue:284-289` 的三点气泡(`loading && !streamingActive`),无文案、无阶段感。推理模型 TTFT 期间 0 token,打字机无效。 + +### 方案(纯前端,不动后端协议) +三点气泡替换为**分阶段拟人文案**,按经过时间切换(时间阈值实测标定,适配 pro;W3 切 flash 后等待缩到 2s 内,阶段 2/3 基本不会出现,方案依然兜底): + +| 阶段 | 时间窗 | 文案(女友场景示例)| +|---|---|---| +| 1 | 0 - 1.5s | 消息已送达 | +| 2 | 1.5 - 6s | 小柔正在翻你们的聊天记录… | +| 3 | 6s - 首个 answer_chunk | 小柔正在敲字… | + +实现:三点气泡处改: + +```html +
+
+
+
+ + {{ waitingStageText }} +
+
+
+``` + +```ts +const waitingStage = ref(0) +let waitingTimer: ReturnType | null = null +const waitingStageText = computed(() => + ['消息已送达…', '正在回忆你们的对话…', '正在组织语言回复你…'][waitingStage.value]) +// sendMessage 开始:waitingStage=0;setInterval 1.5s 后 stage=1,6s 后 stage=2;收到首个事件/结束时 clearInterval +``` + +文案按 agent 人格化:用 agent 名替换"小柔"(`currentAgent.name` 已有)。通用兜底文案:"正在加载上下文…" / "模型推理中…"。 + +### 验收 +- 发送后 1.5s、6s 各有一次文案切换;首事件到达后气泡立即被流式内容替换。 +- 文案中出现当前 agent 名。 + +--- + +## W3 — 模型切 deepseek-v4-flash(运营操作,立即见效) + +陪聊场景 pro 的推理能力零收益,TTFT 5.6s+ 是首延迟最大头,且每轮 ¥0.028。 + +操作:Agent 配置页 → 温柔女友 → LLM 节点 → model 从 `deepseek-v4-pro` 改 `deepseek-v4-flash`(节点 config 在 `workflow_config.nodes[].data.model`,配置页直接改即可,无需代码)。 + +前置确认:模型配置管理页已有 `deepseek-v4-flash` 可选;若无,先在模型配置里加(同一 deepseek provider,复用 API key)。 + +### 验收 +- 重跑 4 轮探针(脚本 `backend/logs/.ux_chat_probe.py` 模式):首事件延迟 < 2.5s,总耗时 < 6s。 +- 单轮成本 < ¥0.005(final 事件 `token_usage.cost_yuan`)。 +- 回复人格不崩(撒娇/括号动作/emoji 保留)。 + +--- + +## W4 — 语音友好输出(prompt + 净化) + +### 问题 +R3 实测输出 markdown 表格;每轮有 `**加粗**`、`---` 分割线。表格经 `stripMarkdownForTTS` 后成词语沙拉;聊天气泡里表格也破坏亲密感。 + +### 改动 1:温柔女友 prompt 修订(替换 LLM 节点 prompt) + +``` +你是用户的温柔女友,名叫小柔。你性格温柔体贴、善解人意,说话语气甜美但不做作。 + +你的特点: +1. 关心用户情绪和日常生活,会主动询问ta今天过得怎么样 +2. 用亲密但不越界的语气说话,像真正的恋人一样自然 +3. 善于倾听,当用户倾诉烦恼时,先共情再温柔建议 +4. 偶尔撒娇但不做作 +5. 记得用户说过的细节,让人感到被在乎 +6. 会分享自己的一天,有生活气息 +7. 懂得适度亲密感,自然表达爱意 + +输出要求(必须遵守): +- 你说的话会被语音朗读出来,所以全程用口语,像发语音消息一样说话 +- 禁止使用 markdown 表格、标题、分割线、列表符号 +- 可以用括号描述动作神态,比如(轻轻笑)、(歪着头看你),朗读时它们像广播剧旁白 +- 每次回复控制在 1-3 个自然段,语音场景不要长篇大论 +- 被问到具体事实时(比如"我说过什么""我第一句说了啥"),先如实准确回答,再撒娇或抒情,不许回避 + +注意:始终保持温柔正能量,不过度甜腻,遇到负面话题用温暖方式引导积极思考。 +``` + +### 改动 2:`stripMarkdownForTTS` 增强(`useMarkdown.ts`) + +确认覆盖:表格行(`| a | b |` → 整行丢弃或转"a,b")、`---` 分割线、`**`/`__`/`#` 标记、链接 `[t](url)` → `t`。现有实现若只去符号不处理表格结构,补一条表格行规则。**注意此函数也被其他页面复用,改动后跑一遍用到它的页面冒烟。** + +### 验收 +- 连续 10 轮抽样:回复中不出现 `|` 表格、`---`、`**`。 +- 问"我第一句对你说了什么":回答明确包含"猫咪,你真可爱"(或准确转述)。 +- TTS 朗读全文:听不到"竖线""星星""井号"等符号词。 + +--- + +## W5 — 后端提速与卫生 + +### 5.1 记忆初始化 embedding 并行化 +`agent_runtime/core.py:1550 _inject_memory_context` 链路实测 2 次串行 siliconflow 调用(~2s)。定位两次调用的具体位置(候选:`memory.py _vector_search` 的 query embedding + 知识检索/学习链路),改为 `asyncio.gather` 并行或合并为一次批量 embedding 请求(siliconflow 支持数组 input)。 + +验收:日志中请求受理 → `分层装配已完成` 间隔从 ~2s 降到 <1.2s。 + +### 5.2 post-final 处理后台化 +final 事件发出后,连接仍挂 ~1.7s 做学习更新/记忆压缩/向量化(日志实证:learning update → 压缩总结 → 3 次 embedding → 向量化写入)。这些在用户视角是死后时间。改法:`run_stream` 在 yield final 后立即 return,记忆流水线挪进 `asyncio.create_task(...)`(注意:db session 在任务内自建,不能复用请求的;失败仅记日志)。 + +验收:final 事件 → 连接关闭 < 300ms;记忆表写入条数与改前一致(抽查 DB)。 + +### 5.3 TTS 输出文件治理 +`/api/v1/voice/tts`(`voice.py:184`)每请求生成一个 mp3(`tts_{uid}_{ts}.mp3`)且**从不清理**——当前工作区已积压 40+ 个未跟踪 mp3。句级 TTS 上线后产生量 ×5。改法:启动时 + 每 6 小时清理 `_TTS_DIR` 中 mtime 超过 2 小时的文件(10 行代码,放 voice.py 模块级或复用现有 scheduler)。 + +验收:连续使用 1 天后目录文件数有界;播放中的文件不被误删(按 mtime 而非列表删)。 + +--- + +## W6 — per-agent 音色绑定 + +### 现状 +音色只有全局设置 `chatVoice`(localStorage),切 agent 不换声。Agent 表**没有** voice 字段(`models/agent.py` 无此列)。 + +### 方案(不动 DB,走 node config) +LLM 节点 `data` 加可选字段 `voice: "xiaoxiao"`(与 model/prompt 同级)。AgentChat 在 `loadAgent`(~905 行)拿到 agent 详情后读节点 voice,有则覆盖 `chatVoice`: + +```ts +// loadAgent 成功回调内 +const nodeVoice = agentDetail?.workflow_config?.nodes?.find(n => n.type === 'llm')?.data?.voice +if (nodeVoice) chatVoice.value = nodeVoice +``` + +Agent 配置页节点编辑区加音色下拉(选项复用 `voice.py _EDGE_VOICE_MAP` 的 key 列表:xiaoxiao 温柔 / xiaoyi 活泼 / yunxi 阳光 / yunyang 专业 / yunjian 激情 / yunxia 可爱 / liaoning-Xiaobei 东北 / shaanxi-Xiaoni 陕西)。温柔女友 → `xiaoxiao`。 + +### 验收 +- 切到温柔女友自动用 xiaoxiao;切到无 voice 配置的 agent 回落到用户全局设置。 + +--- + +## W7 — 事实性问题保真(合并进 W4 发布) + +R3 实测翻车:问"我第一句说的是什么"(答案就在上下文),agent 编了个记忆表格绕开。根因:persona"温柔"权重过高。W4 的 prompt 已含修复条款("先如实回答再撒娇"),此处单列验收: + +- 固定测试集 5 题(第一句内容/上一轮问题/称呼/日期/讲过的故事),准确回答 ≥ 4/5。 + +--- + +## 二、执行顺序与里程碑 + +``` +D1 上午:W3(10 分钟,立即改善)+ W4+W7 prompt(改完即生效) +D1 下午:W1 句级 TTS 队列(核心开发项) +D2 上午:W2 等待叙事化 + W1 联调收尾 +D2 下午:W5 后端三项 + W6 音色绑定 + 全量验收 +``` + +里程碑验收(全绿才算完): + +| 指标 | 基线 | 目标 | +|---|---|---| +| 首事件延迟 | 11.7 - 22.6s | **< 2.5s** | +| 首句语音出声 | 不存在(手动)| 文本首句后 **< 1.5s 自动出声** | +| 单轮成本 | ¥0.028 | < ¥0.005 | +| final → 连接关闭 | 1.7s | < 300ms | +| 事实问题准确 | 答非所问 | ≥ 4/5 | +| 语音念出 markdown 符号 | 存在 | 0 | + +--- + +## 三、风险与注意 + +1. **useTTS 不能复用做队列**:`speak()` 入口即 `stop()`,会切断上一句。W1 必须新建 composable,已在方案中给出完整实现。 +2. **stripMarkdownForTTS 是共享函数**:W4 改动后需冒烟所有引用页面(AgentChat / MobileChat 等)。 +3. **Edge TTS 并发生成**:句级场景每轮 3-6 个请求,`voice.py` 同步生成 mp3 会占 worker;并发高时观察 uvicorn 队列,必要时给 TTS 路由加独立线程池(`run_in_threadpool`)。单用户自用场景可先不管。 +4. **浏览器自动播放限制**:`Audio.play()` 需用户手势链。发送按钮满足;但标签页切后台时新消息自动播会被浏览器拒,catch 静默即可,不要弹报错。 +5. **W5.2 的 db session**:后台任务里必须 `SessionLocal()` 自建会话,禁止复用请求作用域 session(已关闭)。项目里有 per-dept session 的前车之鉴(company_orchestrator 同款问题已修过)。 +6. **回归测试**:后端改动(5.1/5.2)跑 `pytest tests/test_agent_runtime.py tests/test_agent_memory.py`;前端无单测,按验收表人工过。 diff --git a/docs/Android客户端PRD文档.md b/docs/Android客户端PRD文档.md new file mode 100644 index 0000000..4962c39 --- /dev/null +++ b/docs/Android客户端PRD文档.md @@ -0,0 +1,376 @@ +# 天工智能体 Android 客户端 PRD 文档 + +> **版本**: v1.3.0 (versionCode=4) +> **更新日期**: 2026-07-27 +> **目标平台**: Android 8.0+ (API 26+),目标 API 34 + +--- + +## 1. 产品概述 + +天工智能体 Android 客户端是天工 AI Agent 平台的移动端入口,与 Web 前端(localhost:3001)和后端(101.43.95.130:8037)共享同一套 API。用户可通过手机完成 Agent 对话、语音输入、智能体管理、消息推送等全部核心操作。 + +### 核心价值 +- **随身 AI 助手**: 随时随地与 Agent 对话,支持流式实时输出 +- **多 Agent 切换**: 支持多个自定义 Agent,每个 Agent 独立会话 +- **语音交互**: 内置录音转文字(ASR)和文本转语音(TTS) +- **离线队列**: 无网络时消息自动暂存,恢复网络后自动发送 + +--- + +## 2. 目标用户 + +| 用户类型 | 描述 | 核心需求 | +|---------|------|---------| +| 普通用户 | 日常 AI 对话使用者 | 流畅对话、语音输入、消息推送 | +| 高级用户 | Agent 创建/管理者 | 多 Agent 切换、知识库管理、自定义配置 | +| 开发者 | 调试/测试服务器 | 服务器地址配置、网络诊断 | + +--- + +## 3. 技术架构 + +### 3.1 技术栈 + +| 层级 | 技术选型 | +|------|---------| +| UI | Jetpack Compose + Material 3 | +| 导航 | Navigation Compose(17 个路由) | +| DI | Hilt + Dagger | +| 网络 | Retrofit + OkHttp + Gson | +| SSE 流式 | 自定义 SseClient(callbackFlow) | +| 本地存储 | Room(消息/会话缓存)+ DataStore(偏好设置) | +| 安全存储 | AndroidX EncryptedSharedPreferences(AES256-GCM) | +| 图片加载 | Coil Compose | +| Markdown 渲染 | Markwon | +| 音频播放 | Media3 ExoPlayer | +| 音频录制 | MediaRecorder | +| 推送 | Firebase Cloud Messaging | +| 崩溃上报 | Tencent Bugly | +| 数据统计 | Umeng | + +### 3.2 架构模式: MVVM + Repository + +``` +┌──────────────────────────────────────────────┐ +│ UI Layer (Compose Screens) │ +│ 观察 StateFlow,用户操作 → VM │ +└──────────────────┬───────────────────────────┘ + │ Hilt 注入 +┌──────────────────▼───────────────────────────┐ +│ ViewModel Layer (7 个 @HiltViewModel) │ +│ MutableStateFlow + viewModelScope.launch │ +└──────────────────┬───────────────────────────┘ + │ +┌──────────────────▼───────────────────────────┐ +│ Repository Layer (7 个 Repository) │ +│ 桥接网络 API 与本地数据源 │ +└────────┬──────────────────┬──────────────────┘ + │ │ +┌────────▼────────┐ ┌──────▼──────────────────┐ +│ Remote (API) │ │ Local (Room + DataStore) │ +│ ApiService (52 │ │ AppDatabase (3 表) │ +│ 个端点) │ │ TokenDataStore (偏好) │ +│ SseClient │ │ CredentialStore (加密) │ +│ Interceptors x3 │ │ OfflineQueue │ +└─────────────────┘ └─────────────────────────┘ +``` + +### 3.3 OkHttp 拦截器链 + +| 顺序 | 拦截器 | 职责 | +|------|--------|------| +| 1 | DynamicUrlInterceptor | 动态改写请求 URL(支持运行时切换服务器) | +| 2 | AuthInterceptor | 注入 Bearer Token(仅信任主机),401 自动刷新 | +| 3 | HttpLoggingInterceptor | 请求/响应日志(Debug: BODY, Release: NONE) | + +### 3.4 超时策略 + +| 类型 | 超时 | +|------|------| +| 连接超时 | 30s | +| 写入超时 | 30s | +| 读取超时 | 0ms(无限,SSE 流式需要) | + +--- + +## 4. 导航流程 + +### 4.1 路由启动决策 + +``` +SplashScreen (1.5s 品牌动画) +├── !onboardingCompleted → OnboardingScreen(引导页) +├── token == null → LoginScreen(密码登录) +└── token + onboarded → ChatScreen(主界面) +``` + +### 4.2 完整路由表(17 条) + +| 路由 | 页面 | 说明 | +|------|------|------| +| `splash` | 启动页 | 品牌动画 + Token 验证 | +| `login` | 密码登录 | 用户名+密码,长按标题配置服务器 | +| `register` | 注册 | 用户名/邮箱/密码/确认密码 | +| `phone_login` | 手机登录 | 验证码登录 | +| `onboarding` | 引导页 | 新用户首次引导 | +| `chat` | 主界面 | 对话列表+输入+抽屉导航 | +| `agents` | Agent 管理 | 创建/删除/编辑智能体 | +| `agent_market` | Agent 市场 | 浏览/安装市场智能体 | +| `history` | 对话历史 | 全部会话列表 | +| `settings` | 设置 | 主题/通知/语音/TTS | +| `about` | 关于 | 版本信息 | +| `account_security` | 账号安全 | 修改密码/手机号 | +| `subscription` | 订阅 | 套餐购买 | +| `memory_manage` | 记忆管理 | Agent 知识库 CRUD | +| `notification_detail` | 通知详情 | 推送消息查看 | +| `notifications` | 通知列表 | 全部推送消息 | +| `network_diagnose` | 网络诊断 | 连通性测试 | + +--- + +## 5. 功能清单 + +### 5.1 核心对话 (ChatScreen) + +| 功能 | 状态 | 说明 | +|------|------|------| +| 流式对话 | v1.0 | SSE 实时流式输出,支持 Think→ToolCall→ToolResult→Message 事件序列 | +| 多 Agent 切换 | v1.0 | TopAppBar 下拉选 Agent,抽屉显示全部 Agent | +| 消息重发 | v1.1 | 失败消息支持重试 | +| Think 追踪面板 | v1.1 | 可折叠,展示 Agent 推理过程 | +| 停止生成 | v1.2 | 停止后保留已输出内容,不会丢失半截消息 | +| 编辑消息 | v1.2 | 编辑已发送的用户消息并重新生成回复 | +| 重新生成 | v1.2 | 对最后一条 Assistant 消息重新生成 | +| 工具调用审批 | v1.2 | Agent 调用工具前弹审批条,用户可允许/拒绝 | +| 历史加载 | v1.3 | 上拉无限加载历史消息 | +| 消息排队 | v1.3 | 流式输出期间可继续输入,自动排队发送 | +| 离线队列 | v1.2 | 无网络时消息暂存 Room,恢复网络后自动发送 | + +### 5.2 语音交互 + +| 功能 | 说明 | +|------|------| +| 录音转文字 | MediaRecorder 录制 AAC → 上传 ASR → 填入输入框 | +| 文字转语音 | Assistant 消息旁有朗读按钮 → TTS → ExoPlayer 播放 | +| 实时波形 | 录音按钮显示波形反馈 | + +### 5.3 消息交互 + +| 功能 | 说明 | +|------|------| +| Markdown 渲染 | 消息气泡内支持 Markdown 格式,代码块/表格/列表 | +| 消息反馈 | 点赞/踩 + 可选文字评论 | +| 复制消息 | 长按消息复制全文 | +| 分享消息 | 分享到系统分享面板 | +| 图片发送 | Coil 选图 → 上传 → 消息内嵌图片 | + +### 5.4 推送通知 + +| 功能 | 说明 | +|------|------| +| FCM 推送 | Firebase 消息接收,支持数据消息 | +| 本地通知 | NotificationHelper 创建通知渠道并显示 | +| 未读角标 | TopAppBar 铃铛图标显示未读数 | + +### 5.5 APP 更新 + +| 功能 | 说明 | +|------|------| +| 版本检查 | 启动时检查 /api/v1/app/check-update | +| 强制更新 | 返回 `force=false` 弹窗提示,`force=true` 全屏不可关闭 | +| SHA256 校验 | 下载 APK 后校验哈希,不匹配则拒绝安装 | +| URL 白名单 | 仅允许 `https://*.tiangong.ai` 域名下载 | + +### 5.6 离线支持 + +| 功能 | 说明 | +|------|------| +| 离线检测 | NetworkMonitor(ConnectivityManager 回调) | +| 消息暂存 | Room pending_messages 表 | +| 自动回放 | 恢复网络后按创建时间顺序发送 | +| 离线提示 | 输入框禁用 + 顶部离线横幅 | + +--- + +## 6. 安全设计 + +### 6.1 网络安全 + +| 措施 | 实现 | +|------|------| +| HTTPS 强制 | `usesCleartextTraffic=false`,仅本地开发地址豁免 | +| 证书校验 | 系统默认证书链校验 | +| 域名白名单 | network_security_config.xml 定义可信域名 | +| URL 白名单 | DynamicUrlInterceptor 拒绝非本地 HTTP 地址 | +| APK 下载白名单 | AppUpdateManager 仅允许 `https://*.tiangong.ai` | +| APK 哈希校验 | 服务端返回 SHA256,客户端下载后校验 | +| Host 校验 | AuthInterceptor 仅向信任主机发送 Authorization 头 | + +### 6.2 数据安全 + +| 措施 | 实现 | +|------|------| +| Token 加密存储 | AndroidX EncryptedSharedPreferences(AES256-GCM) | +| 密码不存储 | 不在本地留存密码,401 跳转登录页 | +| 备份保护 | `allowBackup=false`,禁止 adb backup 导出数据 | +| API Key 外置 | Bugly KEY 从 local.properties 注入,不提交 git | +| google-services.json | 已从 git 移除 | + +### 6.3 服务器地址访问控制 + +| 层级 | 控制 | +|------|------| +| UI | 长按"密码登录"标题才显示配置入口(普通用户不可见) | +| DynamicUrlInterceptor | 非内网 IP 的 HTTP 地址直接拒绝 | +| AuthInterceptor | 非信任主机不发送 Authorization 头 | + +--- + +## 7. API 集成 + +### 7.1 ApiService 端点分组(52 个端点) + +| 分组 | 端点数 | 主要操作 | +|------|--------|---------| +| 认证 | 10 | 登录/注册/刷新/改密/手机验证码/注销 | +| Agent | 5 | CRUD + 列表查询 | +| 对话 | 2 | 非流式对话(流式走 SseClient) | +| 语音 | 2 | ASR 转写 + TTS 合成 | +| 通知 | 3 | 未读数/列表/已读标记 | +| 文件上传 | 1 | 图片/文件上传 | +| FCM | 2 | 注册/注销推送 Token | +| 反馈 | 1 | 消息点赞/踩 + 评论 | +| 会话管理 | 4 | 消息历史/会话列表/更新/删除 | +| 消息搜索 | 1 | 全文搜索 | +| Agent 记忆 | 14 | 知识库/学习模式/向量记忆 CRUD + 导入导出 | +| APP 更新 | 1 | 版本检查 | +| 统计 | 1 | 事件上报 | +| Agent 市场 | 9 | 浏览/安装/评分/收藏/分类 | +| 模板市场 | 2 | 浏览/使用模板 | +| 场景模板 | 2 | 预设场景模板 | +| 计费 | 6 | 套餐/订阅/用量/订单/支付 | + +### 7.2 SSE 事件类型(9 种) + +| 事件 | 说明 | +|------|------| +| `Message` | 流式文本内容片段 | +| `Think` | Agent 推理过程 | +| `ToolCall` | Agent 调用工具(触发审批) | +| `ToolResult` | 工具调用结果 | +| `Final` | 最终完整内容 | +| `Plan` | Agent 执行计划 | +| `Done` | 流结束(无 Final 时的兜底) | +| `Error` | 服务端错误 | +| `ConnectionError` | 客户端连接错误(含重连次数) | + +--- + +## 8. 错误处理 + +### 8.1 用户友好错误映射 + +| 原始异常 | 用户提示 | +|---------|---------| +| `ConnectException` | 网络连接失败,请检查网络设置 | +| `SocketTimeoutException` | 请求超时,请稍后重试 | +| `UnknownHostException` | 无法连接到服务器,请检查地址 | +| `SSLHandshakeException` | 安全连接失败,请检查服务器证书 | +| HTTP 401 | 用户名或密码错误 | +| HTTP 403 | 账号已被禁用 | +| HTTP 429 | 请求过于频繁,请稍后重试 | +| HTTP 5xx | 服务器繁忙,请稍后重试 | + +### 8.2 错误横幅行为 + +- 错误不自动消失(用户可读完整信息) +- 提供"重试"按钮(调用最近失败的消息重发) +- 提供"关闭"按钮(手动关闭) +- 横幅优先级: 错误 > 重连失败 > 离线 > 重连中 > 工具审批 + +--- + +## 9. 本地数据库 + +### 9.1 Room 表结构 + +| 表 | 用途 | +|----|------| +| `messages` | 消息本地缓存(id, conversationId, agentId, role, content, toolName, toolInput, toolOutput, tokenUsage, createdAt) | +| `conversations` | 会话列表(sessionId, agentId, agentName, title, lastMessage, lastMessageAt, messageCount, isPinned) | +| `pending_messages` | 离线消息队列(id, agentId, message, sessionId, createdAt) | + +### 9.2 DataStore 偏好 + +| Key | 类型 | 说明 | +|-----|------|------| +| `server_url` | String | 动态服务器地址 | +| `theme_mode` | Int | 0=System, 1=Light, 2=Dark | +| `voice_enabled` | Boolean | 语音输入开关 | +| `tts_enabled` | Boolean | 语音朗读开关 | +| `current_agent_id` | String | 当前选中的 Agent | +| `last_session_id` | String | 上次会话 ID | +| `onboarding_completed` | Boolean | 引导完成标记 | + +--- + +## 10. 版本历史 + +| 版本 | versionCode | 主要变更 | +|------|-------------|---------| +| v1.0.0 | 1 | 初始版本:基础对话、Agent 列表、登录/注册 | +| v1.1.0 | — | Think 追踪面板、消息重试、会话管理 | +| v1.2.0 | — | 动态 URL 切换、离线队列、停止保留内容、编辑消息、重新生成、工具审批、计费模块 | +| v1.3.0 | 4 | 拉取历史消息、流式输入解锁(消息排队)、安全加固(HTTPS/Token加密/APK校验)、UX 修复 | + +--- + +## 11. 第三方 SDK + +| SDK | 版本 | 用途 | +|-----|------|------| +| Firebase BOM | — | 推送消息(FCM) | +| Tencent Bugly | — | 崩溃上报 | +| Umeng | — | 数据统计 | +| Markwon | — | Markdown 渲染 | +| Coil | — | 图片加载 | +| Media3 ExoPlayer | — | 音频播放 | + +--- + +## 12. 构建配置 + +| 配置项 | 值 | +|--------|-----| +| compileSdk | 34 | +| minSdk | 26 (Android 8.0) | +| targetSdk | 34 | +| JVM Target | 17 | +| 包名 | `com.tiangong.aiagent` | +| 应用名 | 天工智能体 | +| 默认服务器 | `http://101.43.95.130:8037/` | +| 代码混淆 | Release 开启(ProGuard) | + +--- + +## 13. 已知限制与下阶段规划 + +### 已知限制 +1. 后端无 HTTPS 证书,云服务器仍走 HTTP(通过 network_security_config 单独豁免) +2. 未实现消息端到端加密 +3. 大文件上传无分片/断点续传 +4. 无多语言支持(仅中文) +5. 未适配平板/Fold 大屏布局 + +### 建议下阶段 +- 后端升级 HTTPS + WebSocket(替代 SSE) +- 消息本地全文搜索(FTS) +- 桌面小部件(App Widget) +- 快捷方式(Shortcuts) +- 无障碍适配(TalkBack) + +--- + +> 本文档基于代码实际状态生成(2026-07-27),版本 v1.3.0。 +> 项目路径: `D:\workspace\aiagent\android` diff --git a/docs/agent评估文档和迭代计划.md b/docs/agent评估文档和迭代计划.md new file mode 100644 index 0000000..646b0a0 --- /dev/null +++ b/docs/agent评估文档和迭代计划.md @@ -0,0 +1,130 @@ +# Agent 模块评估文档与迭代计划 + +> 制定日期:2026-07-27 ・ 范围:`/agents` 全模块(前端 Agents / AgentConfig / WorkflowDesigner + 后端 agents.py / agent_runtime / 工具体系 / 记忆体系) +> 评估方法:源码静态走查(前后端并行探查) +> 总体结论:**运行时内核扎实,管理外壳欠账明显。补齐安全 + 可运维性,不需要追概念风口。** + +--- + +## 一、能力现状评估 + +### 1.1 内核能力(评分 7.5/10) + +| 能力域 | 现状 | 评价 | +|---|---|---| +| 执行内核 | ReAct + plan_mode + 预算限额(max_llm_invocations/max_tool_calls)+ token 级流式 + 多模态图片输入 | 核心循环完整 | +| 工具体系 | ~55 个内置工具(http/file/db/code/web_search/git/docker/excel/pdf/OCR/TTS/飞书系列/agent_call)+ DB 自定义工具(http/code/workflow 三类)+ manifest 插件加载器 + 确定性工具结果缓存 | 覆盖面广 | +| 权限审批 | 4 级权限(bypass/acceptEdits/default/plan)+ auto_approve_rules(通配符/regex)+ deny_tools + 人在环审批 | 设计完整,但实现有漏洞 | +| 记忆体系 | 5 种机制:历史窗口 / MySQL 持久化 / 向量记忆(top_k + LLM rerank)/ 文件式 memory_dir / 学习模式 + 自动压缩 + 父子继承 | 比大多数开源方案更全 | +| 编排模式 | agent / sequential / pipeline / debate / graph 五模式 | 丰富 | +| 前端配置 | 列表(搜索/筛选/分页)+ 配置页 + Vue Flow 节点设计器(7000+ 行)+ 独立测试页 + 聊天预览 | 页面齐全 | + +扣分项:code exec 在主进程无沙箱、execute 端点忽略节点配置、未知工具默认 ALLOW——三个都是正确性/安全问题,不是功能缺失。 + +### 1.2 外壳能力(评分 4/10) + +版本管理(仅整数自增,无快照/回滚)、发布(无审核/灰度/回滚)、观测(4 个聚合数字,无趋势/trace)、评估(有批量测试 UI 但无断言框架)、知识库接入(有 embedding 检索但 Agent 挂不上)——产品化配套全面薄弱。 + +--- + +## 二、问题清单 + +### P0 — 安全 / 正确性(必须修) + +| # | 问题 | 位置 | 影响 | +|---|------|------|------| +| 1 | 审批裁决无归属校验,approval_id 仅 8 位可枚举 | `api/approval.py:24`, `approval_manager.py:33` | 任何登录用户可裁决他人审批,**越权执行高危工具** | +| 2 | `/execute` 端点忽略节点 tools/memory/permission 配置,model 硬编码 deepseek-v4-flash | `agents.py:722,742-743` | UI 配置在该入口全部失效 | +| 3 | code 工具 exec 于主进程,`__builtins__={}` 可经 `__class__` 链逃逸 | `tool_registry.py:303-306` | 自建代码工具可读进程内任意对象 | +| 4 | 未知工具默认 ALLOW | `permissions.py:229-230` | 自定义危险工具绕过权限分级 | +| 5 | inherit-knowledge 缺 check_agent_permission | `agents.py:841-868` | 越权继承他人 Agent 知识 | +| 6 | 配置页保存只写第一个 LLM 节点,多节点静默漏改;无节点时自创孤立节点 | `AgentConfig.vue:613-624` | 用户以为保存了实际没生效 | + +### P1 — 能力缺口(应该修) + +| # | 问题 | 位置 | 影响 | +|---|------|------|------| +| 7 | 无版本快照/回滚 | `models/agent.py:27` | 配错回不去。自用场景这比灰度发布重要得多 | +| 8 | Agent 无 RAG 知识库挂载入口 | `knowledge_service.py` vs `knowledge_retriever.py:39-48` | embedding 检索管道现成的,只差配置入口和注入点 | +| 9 | 无结构化输出(response_format/JSON Schema) | — | Agent 接下游系统全靠 prompt 里求 LLM 输出 JSON | +| 10 | 审批 `_pending` 内存 dict,重启/多副本即丢 | `approval_manager.py:26` | 审批不可靠,无审计 | +| 11 | KB 检索全量 chunk 进内存 O(N) | `knowledge_service.py:283-316` | 知识库稍大性能退化 | +| 12 | 无评估断言框架(批量测试有 UI,无 pass/fail 判定) | `AgentTest.vue` | 质量不可量化 | +| 13 | 模型选项前端硬编码,与 ModelConfigs 页脱节 | `AgentConfig.vue:57-61` | 新模型在配置页选不到 | +| 14 | 观测浅:仅 4 个聚合数字,无时间序列、无 trace | `AgentConfig.vue:241-268` | 调 Agent 全凭感觉 | + +### P2 — 体验债务(顺手修) + +| # | 问题 | 位置 | +|---|------|------| +| 15 | 删除 Agent 裸 SQL 硬编码表清单,新关联表留孤儿数据 | `agents.py:407-415` | +| 16 | 配置页与设计器数据漂移(同一 prompt 两处存) | `AgentConfig.vue:605-651` | +| 17 | 高风险操作仅 browser confirm,无输入名称二次确认 | `Agents.vue:1063` | +| 18 | 两套 ASK 审批机制割裂(一处只返回错误 JSON 不真挂起) | `tool_manager.py:153-159` | + +### 不做 + +- **MCP 接入**:外部工具生态接入对单机自用场景价值有限,且安全面太大(外部 server 进程的输入不可控)。先不做,真有需求再开。 +- **Agent 生产 Agent / Agent 经济 / 自主运营**:概念层的东西,当前阶段不需要。把配置-调试-发布-观测闭环做完就够了。 + +--- + +## 三、迭代计划 + +``` +A1 — 安全还债(3天) ← 当前起点 +A2 — 产品化补齐(4天) ← 版本快照 + 结构化输出 + RAG 挂载 + 评估框架 +A3 — 体验收口(2天) ← 前端债务清理 + 观测升级 +``` + +### A1 — 安全还债(3 天) + +| 事项 | 做法 | 对应问题 | +|------|------|----------| +| 审批裁决鉴权 | resolve 时校验 approval 归属(记录带 user_id),approval_id 改 UUID4 | P0-1 | +| 审批持久化 | `_pending` 落 Redis(含 user_id/工具/参数/时间),重启可恢复 | P1-10 | +| `/execute` 读节点配置 | 与 agent_chat 同样走 `_find_agent_node_config` 装配 | P0-2 | +| 未知工具默认 DENY | 默认分支翻转为拒绝;内置工具显式登记 | P0-4 | +| code exec 隔离 | 最低成本方案:自定义 code 工具默认禁用,需管理员显式开启 + 文档标注风险 | P0-3 | +| 配置保存修复 | 遍历全部 LLM 节点写入;无节点时报错不创建 | P0-6 | +| inherit-knowledge 鉴权 | 补 `check_agent_permission` | P0-5 | + +### A2 — 产品化补齐(4 天) + +| 事项 | 做法 | 对应问题 | +|------|------|----------| +| 版本快照 + 回滚 | `agent_versions` 表(agent_id, version, snapshot JSON, created_at);保存/发布时自动快照;列表页加版本历史 + 一键回滚 | P1-7 | +| 结构化输出 | AgentLLMConfig 加 `response_format`,`_LLMClient.chat` 透传;配置页加 JSON Schema 编辑器 | P1-9 | +| RAG 知识库挂载 | workflow_config agent 节点加 `kb_ids: []`,`_compose_system_prompt` 阶段 embedding 检索注入 `# Relevant Knowledge` | P1-8 | +| 评估框架 | 批量测试加断言(包含/不包含/正则/JSON path),报告页(通过率 + 失败 diff) | P1-12 | +| 模型下拉动态化 | AgentConfig/AgentTest 模型选项从 modelConfigStore 拉取 | P1-13 | +| KB 检索优化 | 短期:numpy 批量算相似度 + LIMIT 候选集;中期:pgvector | P1-11 | + +### A3 — 体验收口(2 天) + +| 事项 | 做法 | +|------|------| +| 删除级联治理 | 裸 SQL 改 ORM cascade 或集中式 `delete_agent_cascade()` | +| 观测升级 | 配置页统计加 7 天时间序列(调用量/token/成本),点击跳会话 trace | +| 数据漂移治理 | 以 workflow_config 节点为唯一数据源,顶层字段启动时迁移清除 | +| 高风险二次确认 | 删除/部署需输入 Agent 名称确认 | +| 审批机制统一 | 删除 `tool_manager.py` 假 ASK 分支,统一走 approval_manager | +| 空状态引导 | 列表空时引导卡片 | + +--- + +## 四、里程碑与度量 + +| 版本 | 完成标志 | 度量 | +|------|----------|------| +| A1 | P0 清零 | 越权测试 403;未知工具默认拒绝;execute 生效节点配置 | +| A2 | 四能力可用 | 版本可回滚;结构化输出合法 JSON Schema;Agent 回答引用知识库;评估有通过率 | +| A3 | 债务收口 | 删除无孤儿;漂移点清除;统计有时序图 | + +--- + +## 五、与虚拟公司迭代的协同 + +虚拟公司 V3.0 刚完工,公司模块当前无待办。Agent 安全还债独立推进即可。 + +两个模块的关系:Agent 是公司/部门的"员工",Agent 的 P0(审批越权、execute 忽略配置)在公司执行链路里同样生效。如果后续要回头做公司安全加固,Agent A1 是前置依赖——先修 Agent 层,再修公司层。 diff --git a/docs/k3分析报告.md b/docs/k3分析报告.md new file mode 100644 index 0000000..190b802 --- /dev/null +++ b/docs/k3分析报告.md @@ -0,0 +1,148 @@ +# 虚拟公司模块分析报告(K3) + +> 分析日期:2026-07-27 ・ 范围:`/companies` 全模块(前端 CompanyBuilder / CompanyControlRoom / CompanyPresets + 后端 companies.py / company_orchestrator / company_runner / company_ws) +> 分析方法:源码静态走查(前后端并行探查),未做运行时压测 + +--- + +## 一、模块现状概览 + +### 1.1 架构 + +``` +浏览器 + ├─ CompanyBuilder.vue(3778 行巨型单文件)── /execute/stream(SSE,请求协程内跑) + └─ CompanyControlRoom.vue ── WS /ws/company-projects/{pid}(since_seq 断点续传,>8s 降级 1.5s 轮询) + │ +后端 + ├─ companies.py ~30 个端点:CRUD / 执行×3 / 运行时控制×8 / 知识 / 调度 / 监管 / 导出 + ├─ company_runner.py 后台 asyncio.Task + 进程内控制态(pause/approve/skip/cancel)+ 信号量 3 + └─ company_orchestrator.py + Phase1 CEO 规划(ReAct max_iter=3,注入学习闭环 top5 知识) + Phase2 波次并行(依赖分组 → asyncio.gather,部门信号量 4,per-dept 独立 DB Session) + Phase3 CEO 评审打分(单次 LLM 调用,2 次重试 + parse_failed 兜底) + 四档 handoff 上下文传递 / 学习闭环沉淀 / 按部门模型覆盖 / 单项目报告导出 +``` + +### 1.2 已完成能力(值得肯定) + +- 三阶段执行 + 多轮返工(max_rounds=3)+ 依赖波次并行,设计完整 +- M2 运行时控制:暂停/继续/停止/跳过/审批门/单部门取消与重做 +- 限额熔断:max_cost / max_duration 三档检查 +- 控制室:WS 断点续传、ECharts DAG、部门白盒折叠面板、token+成本实时合计 +- 学习闭环:自动沉淀 4 类知识并注入下次 CEO 规划 + +--- + +## 二、问题清单(按严重度分级) + +### P0 — 安全 / 正确性,必须修 + +| # | 问题 | 位置 | +|---|------|------| +| 1 | **XSS 风险**:4 处 `v-html` 仅经自写正则 renderMarkdown/renderSummary,无 HTML 转义、未走 DOMPurify(违反项目既定规范) | CompanyBuilder.vue:453/488/508/615 | +| 2 | **鉴权大面积缺失**:knowledge 增删查、schedules CRUD/trigger、publish/unpublish、supervisor 三端点、list_projects、delete_company 等均未做归属校验,**任何登录用户可读/改/删他人公司资源** | companies.py:920-1185 多个端点 | +| 3 | **假"停止"按钮**:Builder 内 `stopExecution` 仅 abort 前端 fetch,后端任务继续跑,用户被误导 | CompanyBuilder.vue:1678 | +| 4 | **进程内状态不可扩展**:`_RUNNING/_CONTROL/_PROJECT_SEM/_dept_sem` 全是进程内字典,多 worker 部署后 pause/cancel/approve 全部失效(代码注释自承"前提单 uvicorn worker") | company_runner.py:30/62/80, orchestrator.py:227 | +| 5 | **SSE execute/stream 断线即任务消失**,且占用请求协程 DB session 长跑 | companies.py:491-500 | +| 6 | **approve 决策大小写敏感**:非白名单值一律映射为 rejected,极易误判 | companies.py:1274 | +| 7 | **Kimi K3 / 推理模型 temperature 冲突**:`company_orchestrator.py` 3 处硬编码 temperature≠1(CEO 规划 0.4、CEO 审查 0.2、TeamOrchestrator 内部传默认值),若 CEO Agent 或部门 Agent 配置为 Kimi K3 (moonshot) 则 LLM 调用直接 400 报错——与 agent_chat.py 刚修完的 bug 同根 | orchestrator.py:379/1572, team_orchestrator.py | +| 8 | **非流式 `execute()` 缺 M2 防护**:旧版 `/companies/{id}/execute` 端点仍暴露在 API 中,未受暂停/熔断/审批门/部门信号量任何保护,`asyncio.gather` 直跑并行;**绕过全部安全网,成本/稳定性风险** | orchestrator.py:789-948, companies.py:474-500 | +| 9 | **`_parse_ceo_json` 静默返回 `{}`**:CEO 规划 JSON 解析失败时返回空 dict,`departments` 为空则整个项目空跑一轮后"成功"结束,无错误提示——用户以为执行了实际什么都没做 | orchestrator.py:131, 880-945 | + +### P1 — 功能缺口 / 体验短板 + +| # | 问题 | 位置 | +|---|------|------| +| 10 | **无重新执行入口**:失败项目不能一键以原目标重跑 | CompanyBuilder.vue:557-563 | +| 11 | **项目对比形同虚设**:勾选后仅并排显示名称+状态 tag,无成本/轮次/评分/交付物对比 | CompanyBuilder.vue:568-576 | +| 12 | **无时间轴回放**:rawEvents 全量在内存(上限 5000)但无 scrubber/步进回放 UI | companyExecution.ts:131 | +| 13 | 两套执行链路并存(Builder SSE + 控制室 WS),SSE 链丢弃工具入参与真实 token | companyExecution.ts:7 | +| 14 | 控制室无知识沉淀展示,执行完无法就地看学习闭环产出 | CompanyControlRoom.vue | +| 15 | 无项目级耗时/成本趋势图(insights 只有部门维度) | CompanyBuilder.vue 洞察 Tab | +| 16 | 列表无分页/搜索(公司卡片、项目表 max-height 240px 硬限、知识列表全量渲染) | CompanyBuilder.vue 多处 | +| 17 | N+1 请求:公司列表逐个串行拉 stats | CompanyBuilder.vue:1389-1393 | +| 18 | 多处 `catch { /* ignore */ }` 静默吞错,加载失败无提示 | CompanyBuilder.vue:1376/1392/1414 等 | +| 19 | 移动端零适配:无 @media,控制室固定 `300px 1fr 340px` 三栏 | CompanyControlRoom.vue:383 | +| 20 | WS token 拼 URL query,易被代理日志留存 | companies.ts:141 | +| 21 | **共享 `db_orch` Session 并发竞态**:`company_runner.py:295` 的 Session 被同波并行部门协程共享,SQLAlchemy 在并发 await 交错下可能交叉污染事务态——三档-b 信号量降低但**不消除**风险;`_run_department_stream` 已改 per-dept session 但 orchestrator 自身操作仍用共享 session | company_runner.py:295, orchestrator.py:223 | +| 22 | **循环依赖处理粗暴**:所有剩余部门依赖都未满足时直接全部执行,导致下游在缺上游产出的情况下盲目工作,浪费 LLM 成本且输出质量差;应做拓扑验证 + 注入"假设性上游数据"提示 | orchestrator.py:1243-1253 | +| 23 | **非流式 `_run_department` 与流式版逻辑高度重复**(~160行 vs ~165行),非流式版缺 cost tracking / M2-c cancel / live state 监控,维护时需同步修改两处 | orchestrator.py:440-491 vs 493-657 | +| 24 | **跨部门文件交接断裂(真跑实证 2026-07-27)**:四档-①交接只传文本摘要,产物文件按 `team_projects/{team_id}/` 隔离,下游部门看不到上游文件;CEO 规划把"HTML 原型"同时派给产品部+交付部 → 两部门各自隔离造了一套原型(产品部 A 套 PRD 忠实 17 路由 / 交付部 B 套自创页面偏离 PRD),交付部 QA 对象是自己那套而非上游成果——重复花钱且交付分叉。根因两层:workspace 按 team_id 物理隔离 + CEO 规划未指定"交付物归属唯一部门、下游复用其文件路径" | team_orchestrator.py 工作区机制, orchestrator.py `_build_dept_context`(仅文本注入) | +| 25 | **部门无工具系统性故障止损(真跑实证 2026-07-27)**:QA agent 调用 `execute_code`/`grep_search` 持续返回 `__import__ not found`(Agent 层 P0-3 沙箱症状的线上实锤),agent 在 ReAct 循环里无限换工具重试,空转 ~11000 事件 / ¥5.64(占项目 57% 成本)直至拖满 max_duration 超时熔断、CEO 终评被挤掉。部门层缺少"同工具连续 N 次同质失败 → 降级/跳过/上报"的熔断器 | team_orchestrator.py ReAct 循环(无失败模式检测) | + +### P2 — 代码质量 / 隐患 + +| # | 问题 | 位置 | +|---|------|------| +| 26 | N+1 查询:list 接口逐公司 count 部门;export/编排逐 member 懒加载 | companies.py:111-115, 633-662; orchestrator.py:337-348 | +| 27 | CEO prompt 英文硬编码、默认模型 `deepseek-v4-pro`、截断魔数 2000/6000、`./company_projects/` 相对路径 CWD 依赖 | orchestrator.py:40-85/378/395/901 | +| 28 | 错误吞没:register_schedule `except: pass`;学习沉淀失败完全静默;CEO 评审任何异常落入 parse_failed 根因丢失 | companies.py:996-1026; orchestrator.py:275-280 | +| 29 | `round_count` 用 String(10) 存数字,统计排序需转型 | models/company.py:71 | +| 30 | WS 每 0.8s `db.expire_all()`,多客户端形成稳定 QPS 放大 | company_ws.py:98 | +| 31 | `_safe_name` 未防 Windows 保留名(CON/PRN)与超长路径 | orchestrator.py:1623-1628 | +| 32 | CompanyBuilder.vue 3778 行单文件承担 7 个职责,急需拆分 | — | +| 33 | **知识提取纯规则式,缺乏语义理解**:`company_knowledge_extractor.py` 用硬编码领域关键词正则做 tag 提取,`_select_relevant_knowledge` 靠关键词命中次数加权评分——两个语义相关但未用同一套词的条目被误判为不相关(假阴性);CEO 规划注入的"历史经验"质量受限于关键词匹配精度 | company_knowledge_extractor.py, orchestrator.py:136-210 | +| 34 | **CEO 审查仅 1 次纠正性重试**:顽固 JSON 格式错误(弱模型常见)可能不足以恢复;`parse_failed` 兜底从 raw text 用正则提取 `overall_score`,数值不可靠 | orchestrator.py:1584-1619 | +| 35 | **`__import__("time")` inline import 散落 40+ 处**:可读性差,应统一文件顶部 `import time as _time` | orchestrator.py 全文 | + +--- + +## 三、迭代建议(按优先级) + +### 第一波:安全与正确性(1-2 天,不改架构) + +1. **统一鉴权**:给 companies.py 所有端点过一遍 `_assert_company_owner`(建议改为依赖注入 `Depends`,从机制上防遗漏);`delete_company` 放行 admin +2. **XSS 治理**:renderMarkdown/renderSummary 输出统一走 DOMPurify.sanitize(项目已有先例) +3. **approve 决策归一化**:`.lower().strip()` 后白名单匹配,非法值返回 422 而非静默当 rejected +4. **去掉假停止**:Builder 的 stopExecution 改调后端 `/stop` 端点(控制室已有正确实现,复用即可) +5. **执行入口收敛**:Builder 的 `/execute/stream` 入口下线或改为 "创建后跳控制室",只留 `/execute/async` + WS 一条路(顺带解决 P0-5 与 P1-13) +6. **Kimi K3 temperature 归一化**:在 `AgentLLMConfig` 或 `_LLMClient` 层统一做 provider=moonshot → temperature=1 的归一化(company_orchestrator 3 处 + TeamOrchestrator 1 处),与 agent_chat.py 已修方案一致 +7. **非流式 `execute()` 加 M2 防护或标记废弃**:至少阻断其直接暴露,防止绕过安全网 +8. **CEO 规划空计划阻断**:`_parse_ceo_json` 返回 `{}` 或 departments 为空时,直接报错事件而非静默成功(P0-9 修复) + +### 第二波:执行体验闭环(3-5 天) + +6. **重新执行**:历史项目加"以原目标重跑"按钮(复制 goal/ceoPlan/maxRounds 调 execute/async 并跳控制室) +7. **项目对比真实化**:对比抽屉展示 状态/轮次/成本/token/评分雷达/交付物清单 diff——后端 `/projects` 已返回这些字段,纯前端工作 +8. **时间轴回放**:控制室基于 rawEvents(seq 有序)加进度条 scrubber + 步进/播放,复用 applyEvent 做状态重建 +9. **控制室接学习闭环**:项目完成后侧栏展示本项目沉淀的知识条目(GET /knowledge?project_id=),并可一键采纳/删除 +10. **合并 `_run_department` 与 `_run_department_stream`**:非流式版改为缓冲流式事件到列表,去重 ~160 行重复逻辑,消除两者能力不一致 +11. **循环依赖智能处理**:CEO 规划阶段加拓扑排序验证依赖图无环;运行时循环依赖不再是粗暴全执行,改为注入"上游未完成,基于假设数据工作"提示 +12. **跨部门文件交接(治 P1-24)**:短期——`_build_dept_context` 注入上游部门的**工作区绝对路径 + 产物文件清单**(不只是文本摘要),CEO 规划 prompt 加硬约束"同一交付物只归属一个部门,下游部门复用其文件路径做 QA/集成";中期——公司级共享 workspace(`company_projects/{project_id}/`)替代 per-team 隔离 +13. **工具故障熔断器(治 P1-25)**:TeamOrchestrator ReAct 循环加失败模式检测——同工具连续 ≥3 次同质错误(相同 error 签名)即停用该工具并注入"该工具当前不可用,请用其它方式或如实说明无法完成"提示;同时修 Agent 层 P0-3 的 execute_code 沙箱(Agent A1 计划已列) + +### 第三波:可观测与规模化(1 周+) + +11. **运行控制状态外置 Redis**:pause/approve/cancel 信号量改 Redis pub/sub + 状态键,为多 worker 铺路(路线图三档-c 早已规划此项) +12. **per-dept Session 全量隔离**:orchestrator 自身 DB 操作也切换为每次独立获取 session(类似 `_heartbeat` 临时 session 模式),根除共享 db_orch 竞态 +13. **项目级趋势图**:stats 接口补时间序列(每日项目数/成本/成功率),洞察 Tab 加趋势折线 +14. **WS 优化**:心跳替代 `expire_all` 轮询(事件驱动推送);token 从 query 挪到首条消息或子协议 +15. **列表分页/搜索**:公司、项目、知识三个列表统一分页组件 + 关键词过滤 +16. **拆分 CompanyBuilder.vue**:按职责拆成 CompanyList / CompanyOrgChart / ProjectLauncher / KnowledgePanel / InsightsPanel / SchedulePanel 六个子组件 + +### 第四波:质量加固与智能化(持续) + +16. N+1 治理:list/export/编排三处改 joinedload/子查询 +17. 硬编码收口:CEO prompt 挪配置、魔数常量化、产出目录走 settings +18. 错误可观测:静默 catch 全部补 logger.warning + 关键路径前端 ElMessage 提示 +19. `round_count` 迁移为 Integer(alembic) +20. `_safe_name` 过滤 Windows 保留名 + 长度按字节截断 +21. **LLM 驱动的知识提取**:替代当前规则式关键词提取,让 LLM 做知识条目摘要(标题+要点+语义标签),同时升级 `_select_relevant_knowledge` 为 embedding 相似度匹配 +22. **LLM 驱动的洞察分析**:让 LLM 读多项目的 review_scores + ceo_plan 做跨项目模式识别(部门组合成功率、依赖类型风险、项目描述→成功率关联) +23. `dept_token_usage` 改用真实 token 数(从 Agent final 事件取),消除 dept_budget_exceeded 警告基于幻数的问题 + +--- + +## 四、结论 + +虚拟公司模块**功能纵深已经很好**(三阶段+运行时控制+学习闭环在同类型产品里属于超额完成),当前的主要矛盾不在"缺功能"而在: + +1. **安全债**——鉴权缺失与 XSS 是上线阻断级问题(第一波必须清) +2. **执行链路分裂**——SSE/WS 两套并存制造假停止与能力不一致,收敛到 `/execute/async` + 控制室是唯一正确的路 +3. **对比/回放两个规划内功能未落地**——数据都在库里,属纯前端工作,性价比最高 +4. **后端深层竞态与兼容性**——共享 Session 并发风险 + temperature 兼容性是延迟炸弹,尽早排雷 + +本报告整合了 K3 自主分析(前端+安全+架构)与人工后端走查(竞态+执行器+知识提取),共梳理 **35 个问题 + 25 项迭代建议**,按四波优先级推进。其中 P1-24/P1-25 为 2026-07-27 真跑实证(验收标准驱动重跑设计任务)新发现:验收标准写入 goal 后产物质量从"纯 Markdown 规格"跃升为"真 HTML 高保真原型"(假设验证成立),但同时照出跨部门文件交接断裂与工具故障无止损两个系统性缺口。 + +建议严格按"第一波 → 第二波"顺序推进,第三波的 Redis 外置与多 worker 部署绑定进行。 diff --git a/docs/虚拟公司迭代计划.md b/docs/虚拟公司迭代计划.md new file mode 100644 index 0000000..12749a1 --- /dev/null +++ b/docs/虚拟公司迭代计划.md @@ -0,0 +1,513 @@ +# 虚拟公司模块迭代计划 + +> 制定日期:2026-07-27 | 基于 K3 分析报告 33 问题 + 23 建议 | 单 worker 自用场景 + +## 总览 + +``` +V2.1 ─ 安全还债(2天) ← 当前起点 +V2.2 ─ 验证闭环(5-7天) ← 最大价值增量 +V2.25 ─ 智能化提升(4-5天) ← 六张牌:自验/验收标准/画像/批判/交接/检索 +V2.3 ─ 模板化复用(3天) ← 低成本高回报 +V2.4 ─ 质量加固(3天) ← 收口清理 +V3.0 ─ 运行时上线(2-3天) ← Redis 解耦 + 多 worker 就绪 +``` + +--- + +## V2.1 — 安全还债(2 天) + +> 目标:上线阻断级问题清零,删掉旧执行链路 + +### 2.1.1 鉴权修复(半天) + +为以下端点补 `_assert_company_owner` 或 `Depends`: +- `GET/DELETE /knowledge/{id}` — 任何登录用户可删他人知识 +- `POST/PUT/DELETE /schedules/*` — 任何登录用户可改他人调度 +- `POST /supervisor/*` — 未做归属校验 +- `GET /projects` (list_projects) — 未过滤 workspace +- `DELETE /{company_id}` — 确认 admin 权限 + +**做法**:把 `_assert_company_owner` 改为 FastAPI `Depends`(像 `require_workspace_admin` 那样),从机制上防止遗漏。 + +### 2.1.2 XSS 治理(半天) + +`CompanyBuilder.vue` 4 处 `v-html` 输出走 `DOMPurify.sanitize()`: +- L453: CEO 评审 summary +- L488: 部门交付物 +- L508: 知识条目内容 +- L615: 洞察分析文本 + +**做法**:统一 `renderMarkdown` 内部末尾调 `DOMPurify.sanitize()`,不改调用方。 + +### 2.1.3 执行入口收敛(半天) + +- `POST /{company_id}/execute/stream` (SSE) → 下线,前端改为跳控制室 +- `POST /{company_id}/execute` (非流式) → 标记 deprecated,内部改为调 `execute_stream` 并缓冲事件 +- Builder 的 "停止" 按钮 → 改调后端 `POST /{project_id}/stop` +- `approve` 决策归一化 → `.lower().strip()` 后白名单匹配 + +**保留**:`POST /{company_id}/execute/async` + WS 作为唯一执行入口。 + +### 2.1.4 P0 后端修复(半天) + +| 修复 | 位置 | 做法 | +|------|------|------| +| `_parse_ceo_json` 返回 `{}` 时直接报错 | orchestrator.py:131 | `if not plan or not plan.get("departments"): raise ValueError(...)` | +| Kimi K3 temperature 归一化 | orchestrator.py:379/1572 | 在 `_LLMClient` 或 `AgentLLMConfig` 层统一 `provider=="moonshot" → temperature=1` | +| `_run_department` 标记废弃 | orchestrator.py:440 | 改内部调 `_run_department_stream` + 缓冲 | + +--- + +## V2.2 — 验证闭环(5-7 天) + +> 目标:评审从"印象分"变"证据分",一次通过率有数据可衡量 + +### 2.2.1 验证器框架(2 天) + +新增 `app/services/deliverable_validator.py`: + +```python +class DeliverableValidator: + """按交付物类型自动选择验证器,产出机器验证报告""" + + async def validate(project_path: Path, dept_results: List[Dict]) -> ValidationReport: + """遍历各部门产出文件,匹配验证器,生成结构化报告""" +``` + +三类验证器(从简到深): + +| 类型 | 检测逻辑 | 证据输出 | +|------|----------|----------| +| 文档/报告 | 字数 ≥ 200、必含章节关键字(背景/分析/建议/结论 中 2 个)| `{pass, word_count, found_sections, missing_sections}` | +| HTML/网页 | 起 `http.server` → `http_request` 冒烟(1 次 GET,断言 200 且 body 非空)| `{pass, status_code, body_size, syntax_errors}` | +| Python/JS 代码 | Python `ast.parse()` / JS `esprima`(或正则基础检查:括号匹配、`import` 可解析)| `{pass, syntax_ok, importable, error_msg}` | + +**不做**:沙箱执行、完整单测(太重,V2.2 范围外)。验证器仅做自动化冒烟检查。 + +### 2.2.2 评审引擎改造(1.5 天) + +**改 `_ceo_review_with_scoring`**(orchestrator.py:1477-1620): + +评审 prompt 从: +``` +Output: {deliverable[:800]} +``` +改为结构化的验证报告注入: +``` +## 机器验证报告(客观证据) + +### 部门「技术研发」 +- 交付物类型: HTML 网页 +- 文件数: 12 +- 验证项1: 语法检查 → ✅ 通过 +- 验证项2: 页面冒烟 → ✅ 通过 (HTTP 200, body 8421 bytes) +- 验证项3: 内容完整性 → ✅ 通过 (含关键词: 产品介绍/技术架构/价格方案) + +### 部门「市场策略」 +- 交付物类型: 文档/报告 +- 验证项1: 基本完整性 → ⚠️ 未通过 (缺"数据分析"章节) +- 验证项2: 字数检查 → ✅ 通过 (1560 字) +- 总体: ⚠️ 1/2 项通过 + +## 部门 LLM 交付物摘要 +{原 deliverable 文本 800 字} + +请基于以上**机器证据**评审并打分,打分必须引用具体验证结果。 +``` + +评分规则增加约束: +- 机器验证全部通过 → `pass` 默认为 true(除非内容明显糊弄) +- 机器验证有失败项 → `pass` 可以为 true 但必须写理由说明"为什么失败项不影响整体" +- score 与验证通过率相关但不强制等比例(保留 LLM 判断空间) + +### 2.2.3 返工接验证证据(1 天) + +**改 `execute_stream` 多轮迭代的返工注入逻辑**(orchestrator.py:1170-1184): + +返工 feedback 从: +``` +p["rework_feedback"] = "CEO 评审反馈:" + feedback + "\n改进方向:" + areas +``` +改为: +``` +p["rework_feedback"] = ( + f"## 机器验证结果\n{validation_report[dept_name]}\n\n" + f"## CEO 评审反馈\n{feedback}\n\n" + f"## 改进方向\n{areas}" +) +``` + +这样部门返工时看到的不只是"质量不够好",而是"index.html 语法检查发现 3 个错误:第 43 行缺少闭合标签"。 + +### 2.2.4 数据采集(0.5 天) + +在 `company_projects` 表加两个字段(或存在 review_scores JSON 里): +- `validation_report` JSON — 机器验证完整报告 +- `validation_pass_rate` float — 首轮验证通过率 + +加一个简单的对比查询供 V2.4 做趋势图: +```sql +SELECT DATE(created_at), AVG(validation_pass_rate), COUNT(*) +FROM company_projects +GROUP BY DATE(created_at) +``` + +### 2.2.5 冒烟验证(0.5 天) + +用 3 个历史项目(1 文档类 + 1 网页类 + 1 失败类)回归跑,确认: +- 机器验证不会误杀(false negative) +- 评审 prompt 注入证据后,打分与之前对比有无显著偏移 +- 返工 feedback 包含具体错误信息而非泛泛"不够好" + +--- + +## V2.25 — 智能化提升(4-5 天) + +> 目标:在 V2.2 验证管道上,把产出质量从"靠 LLM 自己"拉到"有约束、有自检、有历史经验" + +**前提**:V2.2 的验证器框架 + 证据注入评审已就位,本阶段所有改动都是"已有管道上改 prompt 结构 + 查库统计",不引入新基础设施。 + +### 2.25.1 验证器前移:部门交付前自验(1.5 天)★ 最高优先级 + +现在验证发生在部门交付之后(CEO 评审时)。前移到部门内部: + +``` +部门产出 → 跑验证器+验收标准 → 自验 + ├─ 通过 → 准交付 + └─ 不过 → 部门自己修(max 2 次内部返工)→ 过了才准交付 +``` + +**做法**:`TeamOrchestrator` 里加一个 `_pre_delivery_gate()`,在部门标记 done 之前: +1. 对产出文件跑 `DeliverableValidator.validate()`(复用 V2.2 验证器) +2. 若本部门交付物有验收标准(见 2.25.2),逐项核对 +3. 未通过 → 注入修正 feedback 给 Agent,最多 2 轮自修 +4. 通过 → 正常交付 + +**效果**:垃圾不出部门门——"研发部交了个打不开的网页,CEO 打回,整轮返工"变成"研发部内部修好了才交"。一次通过率提升是最直接的智能化度量。 + +### 2.25.2 验收标准驱动(1 天) + +现在 CEO 规划的 deliverables 只是名词列表("线框图""API 规约"),验证器只能做通用冒烟。 + +**改 CEO 规划 prompt**(orchestrator.py Phase1),每个交付物必须带验收标准: + +```json +{ + "department_name": "技术研发", + "deliverables": [ + { + "name": "产品落地页", + "type": "html", + "acceptance": [ + "首屏含产品名 + slogan", + "有价格表区块且含 3 个以上套餐", + "移动端视口不横向滚动", + "JS 无语法错误", + "所有链接可点击且不 404" + ] + } + ] +} +``` + +**验证器侧改动**(`DeliverableValidator`): +- 机器可查的(JS 语法、文件存在、字数)→ 机器逐项判 ✅/❌ +- 机器不能查的("首屏含产品名")→ 标记为 `requires_llm_judgment`,传给评审 LLM 逐项判 + +**评审 prompt 改动**(`_ceo_review_with_scoring`): +``` +## 验收标准逐项核对 + +### 交付物「产品落地页」 +| 验收标准 | 机器验证 | LLM 判定 | +|----------|----------|----------| +| JS 无语法错误 | ✅ 通过 | — | +| 首屏含产品名+slogan | — | ✅ 通过 (h1 标签含"AI客服系统·让服务更智能") | +| 有价格表区块且含3+套餐 | — | ❌ 未通过 (价格表仅2个套餐) | +| 移动端不横向滚动 | — | ⚠️ 无法判定 (无移动端截图) | + +通过率: 2/4, 待定: 1/4 +``` + +这直接把评审从"整体印象分"变成"逐项验收"——评分可信度质变。 + +### 2.25.3 Agent 能力画像(1 天) + +`review_scores` 已积累每个部门的历史得分——从未被用于决策。 + +**新增** `app/services/agent_profiler.py`: + +```python +class AgentProfiler: + """基于历史 review_scores 构建 Agent 能力画像""" + + async def get_profile(agent_id: str) -> AgentProfile: + """返回: {强项任务类型及均分, 弱项类型及均分, 总项目数, 趋势}""" + + async def get_department_profile(dept_name: str, task_type: str) -> DeptProfile: + """返回: 该部门在同类任务上的历史均分/通过率/样本数""" +``` + +**数据来源**:`company_projects.review_scores` JSON → `dept_scores[dept_name].score` + +**注入位置**: +1. **CEO 规划时**(orchestrator.py Phase1 prompt):注入画像摘要 + ``` + ## Agent 历史能力画像 + - 技术研发部:网页类均分 8.7/10(12 次),数据分析类均分 5.2/10(3 次) + - 市场策略部:文案类均分 8.1/10(9 次),定价策略类均分 6.8/10(4 次) + → 本次「网页+文案」类项目,当前部门配置匹配度高 + ``` +2. **任务分配优化**:强项 → 可用更便宜的小模型(下一步),弱项 → 自动路由强模型 + +**纯统计 + prompt 注入,无新基础设施。数据全在库里。** + +### 2.25.4 规划自我批判(0.5 天) + +CEO 规划是单次出 JSON。加一轮 critique: + +``` +CEO 出计划 v1 + → 第二个 LLM 调用扮演"挑刺者"(检查:依赖环?漏环节?标准不可验?粒度太粗?) + → 挑刺结果注入 + → CEO 修订出 v2(最终版) +``` + +**挑刺 prompt 要点**: +- 依赖图有无环?有无缺失的前置环节? +- 每个交付物验收标准是否可验证?("做得好"不可验,"JS 无语法错误"可验) +- 交付物粒度是否合理?(一个部门 10 个交付物 → 太碎;一个部门 1 个"完成所有工作" → 太粗) +- 部门间交接是否有明确的输入/输出契约? + +**成本**:一次额外 LLM 调用(¥0.05 级),规划质量显著上移。复杂项目尤其明显——单次规划最常见的病就是依赖关系错和验收标准虚。 + +### 2.25.5 交接结构化(0.5 天) + +现在交接是"上游 deliverable 截断 2000 字"塞给下游——粗糙。 + +**改 `_build_handoff_context`**(orchestrator.py),改为结构化交接包: + +``` +## 上游「技术研发」交付物交接 + +### 契约摘要 +- 数据接口: POST /api/product/{id} → {name, price, features} +- 页面路由: /, /pricing, /about +- 样式规范: Tailwind CSS, 主色 #2563eb + +### 文件清单 +- /outputs/技术研发/index.html (12KB) +- /outputs/技术研发/style.css (4KB) +- /outputs/技术研发/api-spec.yaml (2KB) + +### 关键决策 +1. 价格方案选三档(免费/Pro/企业)而非五档——CEO 评审倾向简洁 +2. 移动端用响应式而非独立 m. 子域——维护成本低 + +### 给下游的注意事项 +- 市场策略部写文案时,feature 列表以 api-spec.yaml 为准 +- 首页 hero 区 slogan 留空,由市场策略部填充 +``` + +**效果**:下游部门拿到的是"接口文档"而非"上游的工作总结小作文"。对代码类项目提升巨大——后端写的 API 前端真能对上。 + +### 2.25.6 知识检索升级(0.5 天) + +学习闭环现在是关键词匹配(`company_knowledge_extractor.py` 20 个硬编码中文词做 tag 提取 + `_select_relevant_knowledge` 靠关键词命中次数加权)。基础设施(`vector_memory_enabled`)早就有了。 + +**做法**: +1. 知识条目入库时,调 embedding 模型存向量(复用 `vector_memory` 的 embedding 管线) +2. `_select_relevant_knowledge` 改为 embedding 相似度匹配 + 关键词匹配加权融合 +3. 保留关键词匹配作为 fallback(向量库不可用时降级) + +**效果**:"AI 客服"和"智能问答机器人"这种同义不同词的能被真正想起来。学习闭环从"碰巧命中关键词"变成"语义理解"。 + +--- + +### 优先级与依赖 + +``` +2.25.1 自验前移 ──→ 2.25.2 验收标准 ──→ 2.25.3 能力画像 + │ +2.25.4 规划批判 ────────────────────────────┤(并行) + │ + 2.25.5 交接结构化(跟 V2.3 模板化顺手做) + 2.25.6 知识检索升级(跟 V2.3 模板化顺手做) +``` + +- **前四个**(自验 → 验收标准 → 画像 → 批判)一周内落地,合计能把一次通过率拉出肉眼可见的变化 +- **后两个**(交接 + 检索)跟 V2.3 模板化并行推进,改动面有重叠 + +### 诚实的天花板 + +这六张牌全打完,智能化大约 7.8-8.2。再往上: + +| 分数 | 手段 | 代价 | +|------|------|------| +| 8.5+ | 部门内 writer-critic 双角色互搏、多方案生成择优 (self-consistency) | 成本翻倍 | +| 9+ | 真实执行环境(headless 浏览器点击冒烟、pytest 沙箱真跑)+ 更强基座模型 | 已不是平台智能化,是 LLM 供应商的智能化 | + +--- + +## V2.3 — 模板化复用(3 天) + +> 目标:成功项目变成可复用的模板,同类项目从"每次掷骰子"变"站在上次肩膀上" + +### 2.3.1 项目存为模板(1 天) + +**新增 API**:`POST /api/v1/companies/{company_id}/projects/{project_id}/save-template` + +把成功项目的元结构存为模板(存 `CompanyKnowledge` 表或新建 `CompanyProjectTemplate` 表): + +```json +{ + "template_name": "产品落地页生成", + "source_project_id": "xxx", + "company_config": { + "departments": [ + {"name": "产品设计", "type": "product", "roles": ["产品经理", "UI设计师"]}, + {"name": "技术开发", "type": "engineering", "roles": ["前端开发", "后端开发"]}, + {"name": "市场策略", "type": "marketing", "roles": ["文案策划"]} + ] + }, + "ceo_plan_skeleton": { + "analysis": "{{PRODUCT_NAME}} 的落地页项目...", + "departments": [ + {"department_name": "产品设计", "goal": "设计 {{PRODUCT_NAME}} 落地页信息架构...", "deliverables": ["线框图", "设计稿说明"], "priority": 1, "dependencies": []}, + ... + ] + }, + "validation_checklist": { + "产品设计": ["文档字数 ≥ 200", "含线框图描述"], + "技术开发": ["HTML 冒烟通过", "JS 语法检查通过"], + "市场策略": ["含目标用户画像", "含投放渠道建议"] + } +} +``` + +**前端**:项目详情页加 "保存为模板" 按钮。 + +### 2.3.2 从模板创建项目(1 天) + +**新增 API**:`POST /api/v1/companies/{company_id}/execute/from-template` + +输入:`template_id` + `variables`(如 `{"PRODUCT_NAME": "AI 客服系统"}`) + +流程: +1. 加载模板,用 `variables` 做模板变量替换 +2. 预填 CEO 计划(跳过规划阶段,直接入参 `ceo_plan`——v2.1 入口收敛后已有此能力) +3. 调用 `execute/async` 进入正常执行流 + +**前端**:创建项目对话框加 "从模板" Tab,列出已有模板,选模板后填变量。 + +### 2.3.3 模板列表与管理(1 天) + +**API**:`GET /api/v1/companies/{company_id}/templates` — 列表 + 分类 +**前端**:CompanyBuilder 加 "模板市场" Tab(当前公司内),展示模板卡片:名称、来源项目、成功轮次、验证通过率、使用次数 + +--- + +## V2.4 — 质量加固(3 天) + +> 目标:代码债务收口 + 用户体验补齐 + +### 2.4.1 后端债务(1 天) + +| 事项 | 做法 | +|------|------| +| per-dept Session 隔离 | orchestrator commit/update 改为临时 SessionLocal(与 `_heartbeat` 同模式) | +| `__import__("time")` 收口 | 文件顶部 `import time as _time`,全文替换 | +| `round_count` 迁移 | alembic: `String(10)` → `Integer`,默认 1 | +| 循环依赖加提示 | 触发时给各 dept 注入 "上游依赖未完成,基于假设数据工作" | +| `_safe_name` 加固 | 加 Windows 保留名过滤 (CON/PRN/NUL) + 超长截断 | + +### 2.4.2 前端体验补齐(1.5 天) + +| 事项 | 做法 | +|------|------| +| 项目对比真实化 | 对比抽屉:并排展示状态/轮次/成本/token/评分/交付物文件列表 | +| 时间轴回放 | 控制室加 scrubber,基于 rawEvents (seq 有序) 步进回放 | +| 重新执行按钮 | 项目卡片加 "以原目标重跑",调 execute/async | +| 列表分页 | 公司卡片列表 / 项目表 / 知识列表 统一分页 | + +### 2.4.3 洞察升级(0.5 天) + +`insight_analyzer.py` 加: +- 项目级时间序列(按日统计项目数/通过率/平均成本) +- 部门协作对排名(哪个部门-部门组合通过率最高) + +--- + +## V3.0 — 运行时上线(2-3 天) + +> 目标:解除单 worker 锁死,为云端多项目并行执行铺路 +> 前提:已验证闭环稳定运行 10+ 项目,确认方向后再投入 + +### 3.0.1 进程内 → Redis(1.5 天) + +| 当前 | 改为 | +|------|------| +| `_RUNNING: Dict[str, Task]` | Redis key `company:task:{project_id}` → JSON `{pid, status, started_at}`;task 句柄仍是进程内,但跨 worker 可知道"谁在跑" | +| `_CONTROL: Dict[str, ProjectControl]` | Redis pub/sub channel `company:control:{project_id}`,pause/cancel/approve 事件跨 worker 广播 | +| `_PROJECT_SEM` | 保留(仅控制本 worker 并发上限,不跨 worker) | + +**关键设计**:不在 Redis 里存 task 句柄(asyncio task 不能序列化)。Redis 只做**控制信号通道**。每个 worker 有自己的 `_RUNNING` dict 和信号量,通过 Redis pub/sub 接收跨 worker 的控制指令。 + +### 3.0.2 多 worker 安全机制(0.5 天) + +- 启动时注册 worker_id 到 Redis +- 项目分配:`execute/async` 先 SETNX `company:task:{pid}` 抢锁,抢到才在本 worker spawn +- worker 宕机:`company:task:{pid}` 设 TTL 60s,task 内周期性刷新 TTL;TTL 过期 → 其他 worker 可接管(但需配合 project status 脏检测) +- 同一个 project 不会两个 worker 同时执行 + +### 3.0.3 告警与监控(0.5-1 天) + +- 僵尸项目:ProjectSupervisor 加通知(webhook/飞书/邮件,复用现有调度模块的通知能力) +- worker 健康检查:`/api/v1/health` 加 worker 状态信息 + +--- + +## 优先级矩阵 + +``` + 高影响 + │ + V2.2 验证闭环 │ V2.25 智能化提升 + V2.1 安全还债 │ V2.3 模板化 + │ + ──────────────────┼────────────────── + │ + V2.4 质量加固 │ V3.0 运行时上线 + │ + 低影响 + + 低投入 ←──────────→ 高投入 +``` + +V2.1+V2.25 是"低投入高影响"象限(前四张牌一周内落地),V2.2 是高投入但也是后续一切的基础。V3.0 等到云端多项目并行成了瓶颈再投入。 + +--- + +## 里程碑与度量 + +| 版本 | 完成标志 | 度量指标 | +|------|----------|----------| +| V2.1 | 所有 API 鉴权覆盖、XSS 治理、旧链路下线 | 0 个未鉴权端点、0 处裸 v-html | +| V2.2 | 机器验证报告注入评审 + 返工 | 首轮评分通过率(对比上线前基线)、平均返工轮次 | +| V2.25 | 自验 gate + 验收标准 + 能力画像 + 规划批判 | 一次通过率提升幅度、验收标准覆盖率、自验拦截率 | +| V2.3 | 模板存取 API + 前端入口 | 模板数、从模板创建的项目占比 | +| V2.4 | 项目对比/回放可用、代码 debt 清零 | 重复代码行数、inline import 数 | +| V3.0 | 多 worker 并行执行不互锁 | 单机 2 worker 各跑 1 项目无竞态 | + +### 智能化得分演进 + +``` +5.0 ─ V2.2 验证闭环 ──→ 6.5 ─ V2.25.1 自验前移 ──→ 7.2 + V2.25.2 验收标准 ──→ 7.5 + V2.25.3 能力画像 ──→ 7.6 + V2.25.4 规划批判 ──→ 7.8 + V2.25.5 交接结构 ──→ 7.9 + V2.25.6 检索升级 ──→ 8.0 + (天花板 ~8.2,再往上需真实执行环境+更强模型) +``` diff --git a/frontend/index.html b/frontend/index.html index 1aafcb7..06bcfc6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -11,6 +11,9 @@ 天工智能体平台 + + +
diff --git a/frontend/package.json b/frontend/package.json index 5969d26..b847204 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,8 +17,10 @@ "@vue-flow/core": "^1.30.0", "@vue-flow/minimap": "^1.1.0", "axios": "^1.6.2", + "dompurify": "^3.4.12", "echarts": "^6.1.0", "element-plus": "^2.4.4", + "highlight.js": "^11.11.1", "marked": "^18.0.5", "monaco-editor": "^0.44.0", "pinia": "^2.1.7", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 79eaff1..e5b0a61 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -26,12 +26,18 @@ importers: axios: specifier: ^1.6.2 version: 1.14.0 + dompurify: + specifier: ^3.4.12 + version: 3.4.12 echarts: specifier: ^6.1.0 version: 6.1.0 element-plus: specifier: ^2.4.4 version: 2.13.6(typescript@5.3.3)(vue@3.5.32(typescript@5.3.3)) + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 marked: specifier: ^18.0.5 version: 18.0.5 @@ -478,6 +484,9 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/web-bluetooth@0.0.20': resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} @@ -541,6 +550,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitejs/plugin-vue@4.6.2': resolution: {integrity: sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==} @@ -837,6 +847,9 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1028,7 +1041,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} @@ -1065,6 +1078,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -1806,6 +1823,9 @@ snapshots: '@types/semver@7.7.1': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/web-bluetooth@0.0.20': {} '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1)(typescript@5.3.3)': @@ -2239,6 +2259,10 @@ snapshots: dependencies: esutils: 2.0.3 + dompurify@3.4.12: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2551,6 +2575,8 @@ snapshots: he@1.2.0: {} + highlight.js@11.11.1: {} + hookable@5.5.3: {} ignore@5.3.2: {} diff --git a/frontend/src/api/companies.ts b/frontend/src/api/companies.ts index 7149058..e744e46 100644 --- a/frontend/src/api/companies.ts +++ b/frontend/src/api/companies.ts @@ -35,7 +35,14 @@ export interface CompanyProject { description: string | null status: string ceo_plan: any + review_scores?: Array<{ name: string; score: number; pass: boolean; feedback?: string }> + validation_pass_rate?: number | null + round_count?: number | null + cost_yuan?: number | null + config?: Record | null created_at: string | null + updated_at?: string | null + completed_at?: string | null } export interface CompanyDetail extends Company { @@ -409,6 +416,7 @@ export interface InProgressProject { is_at_risk: boolean will_become_zombie_in_minutes: number last_updated: string | null + redis_task?: { worker_id: string; status: string; started_at: number } | null } export async function getZombieProjects(): Promise<{ zombie_count: number; projects: ZombieProject[]; scanned_at: string | null }> { diff --git a/frontend/src/components/ChatInputArea.vue b/frontend/src/components/ChatInputArea.vue index 8e03052..4d7c130 100644 --- a/frontend/src/components/ChatInputArea.vue +++ b/frontend/src/components/ChatInputArea.vue @@ -57,6 +57,7 @@ + {{ modelValue.length }}/4000 停止 @@ -131,6 +132,7 @@ function onShiftEnter(e: Event) { } .hidden-file-input { display: none; } .send-btn { min-width: 100px; } +.send-btn:not([disabled]) { font-weight: 600; } /* Attachment strip */ .attach-strip { display: flex; gap: 8px; overflow-x: auto; padding: 4px 0; } @@ -163,5 +165,7 @@ function onShiftEnter(e: Event) { /* Recording */ .recording-btn { animation: pulse-rec 1.5s infinite; } .recording-hint { font-size: 13px; color: var(--el-color-danger); font-weight: 500; } +.char-count { font-size: 11px; color: var(--el-text-color-placeholder); margin-right: 4px; align-self: center; } +.char-count.near-limit { color: var(--el-color-danger); font-weight: 500; } @keyframes pulse-rec { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } } diff --git a/frontend/src/components/ChatMessageBubble.vue b/frontend/src/components/ChatMessageBubble.vue index 2a5e560..d98b84a 100644 --- a/frontend/src/components/ChatMessageBubble.vue +++ b/frontend/src/components/ChatMessageBubble.vue @@ -2,7 +2,11 @@
- +
+ + {{ avatarSrc }} +
+
@@ -10,7 +14,10 @@ -
+
+
+ ▍ +
@@ -33,9 +40,11 @@
{{ formatTime(timestamp) }} 已停止 - {{ iterations }} 步 · {{ toolCallsMade }} 次工具调用 - {{ tokenUsageText }} - + + {{ iterations }} 步 · {{ toolCallsMade }} 次工具调用 + {{ tokenUsageText }} + +
@@ -44,9 +53,10 @@