scorpio d6df056687 feat: Part 模型 + 文件版本追踪 + 写手团队工作流 v2
- 数据层:messages 表增加 part_type 字段,新建 file_versions 表支持版本追踪
- 后端:saveWorkspace 版本追踪、saveAgentOutput 源头分离、generateBriefMessage 成员简报
- 后端:applyDocumentEdit 增量编辑、buildWorkflowStep phase-aware 工作流引擎
- API:文件版本查询/回退接口
- 前端:part_type 驱动渲染,产物面板版本历史
- 新增写手团队(主编/搜索员/策划编辑/合规审查员)配置
- store 模块、scheduler 模块、web-search skill

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:44:34 +08:00

572 lines
15 KiB
Bash

#!/bin/bash
# Web Search CLI - Simplified search interface for Claude
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
DEFAULT_SERVER_URL="http://127.0.0.1:8923"
SERVER_URL="${WEB_SEARCH_SERVER:-$DEFAULT_SERVER_URL}"
ACTIVE_SERVER_URL="$SERVER_URL"
CONNECTION_CACHE="$PROJECT_DIR/.connection"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
HTTP_NODE_CMD=""
HTTP_NODE_ARGS=()
HTTP_NODE_ENV_PREFIX=()
is_windows_bash() {
case "$(uname -s 2>/dev/null)" in
MINGW*|MSYS*|CYGWIN*) return 0 ;;
*) return 1 ;;
esac
}
# Usage information
usage() {
cat << EOF
Usage: $(basename "$0") <query|@query_file> [max_results]
Arguments:
query Search query (required), or @UTF-8-file-path for non-ASCII
max_results Maximum number of results (default: 10)
Examples:
$(basename "$0") "TypeScript tutorial" 5
$(basename "$0") "React hooks" 10
Environment:
WEB_SEARCH_SERVER Bridge Server URL (default: http://127.0.0.1:8923)
WEB_SEARCH_ENGINE Preferred engine: auto|google|bing (default: auto)
WEB_SEARCH_CLEANUP Set to 1 to close browser after each search (default: keep alive)
EOF
exit 1
}
resolve_http_node_runtime() {
if [ -n "$HTTP_NODE_CMD" ]; then
return 0
fi
if command -v node > /dev/null 2>&1; then
HTTP_NODE_CMD="node"
HTTP_NODE_ARGS=()
HTTP_NODE_ENV_PREFIX=()
return 0
fi
if [ -n "${LOBSTERAI_ELECTRON_PATH:-}" ] && [ -x "${LOBSTERAI_ELECTRON_PATH}" ]; then
HTTP_NODE_CMD="$LOBSTERAI_ELECTRON_PATH"
HTTP_NODE_ARGS=()
HTTP_NODE_ENV_PREFIX=("ELECTRON_RUN_AS_NODE=1")
return 0
fi
return 1
}
http_request() {
local METHOD="$1"
local URL="$2"
local BODY="${3:-}"
# On Windows Git Bash/MSYS/Cygwin, prefer Node fetch to avoid codepage-related
# corruption for non-ASCII payloads in curl/wget command-line arguments.
if ! is_windows_bash; then
if command -v curl > /dev/null 2>&1; then
if [ "$METHOD" = "GET" ]; then
if curl -s -f "$URL" 2>/dev/null; then
return 0
fi
else
if curl -s -f -X "$METHOD" "$URL" \
-H "Content-Type: application/json" \
-d "$BODY" 2>/dev/null; then
return 0
fi
fi
fi
if command -v wget > /dev/null 2>&1; then
if [ "$METHOD" = "GET" ]; then
if wget -q -O- "$URL" 2>/dev/null; then
return 0
fi
else
if wget -q -O- --method="$METHOD" \
--header="Content-Type: application/json" \
--body-data="$BODY" \
"$URL" 2>/dev/null; then
return 0
fi
fi
fi
fi
if ! resolve_http_node_runtime; then
return 127
fi
env "${HTTP_NODE_ENV_PREFIX[@]}" "$HTTP_NODE_CMD" "${HTTP_NODE_ARGS[@]}" - "$METHOD" "$URL" "$BODY" <<'NODE'
const [method, url, body] = process.argv.slice(2);
(async () => {
try {
const init = { method };
if (method !== 'GET') {
init.headers = { 'Content-Type': 'application/json' };
init.body = body ?? '';
}
const response = await fetch(url, init);
if (!response.ok) {
process.exit(22);
}
process.stdout.write(await response.text());
} catch {
process.exit(1);
}
})();
NODE
}
http_get() {
http_request "GET" "$1"
}
http_post_json() {
local BODY="${2:-}"
if [ -z "$BODY" ]; then
BODY='{}'
fi
http_request "POST" "$1" "$BODY"
}
build_search_payload() {
local CONNECTION_ID="$1"
local QUERY="$2"
local MAX_RESULTS="$3"
local ENGINE="$4"
if resolve_http_node_runtime; then
env "${HTTP_NODE_ENV_PREFIX[@]}" "$HTTP_NODE_CMD" "${HTTP_NODE_ARGS[@]}" - "$CONNECTION_ID" "$QUERY" "$MAX_RESULTS" "$ENGINE" <<'NODE'
const [connectionId, query, maxResultsRaw, engineRaw] = process.argv.slice(2);
const maxResults = Number.parseInt(maxResultsRaw, 10);
const engine = engineRaw || 'auto';
process.stdout.write(JSON.stringify({
connectionId,
query,
maxResults: Number.isFinite(maxResults) ? maxResults : 10,
engine,
}));
NODE
return $?
fi
# Fallback when Node runtime is unavailable.
local ESCAPED_QUERY
ESCAPED_QUERY=$(printf '%s' "$QUERY" | sed 's/\\/\\\\/g; s/"/\\"/g')
printf '{"connectionId":"%s","query":"%s","maxResults":%s,"engine":"%s"}' \
"$CONNECTION_ID" "$ESCAPED_QUERY" "${MAX_RESULTS:-10}" "${ENGINE:-auto}"
}
ensure_http_client_available() {
if is_windows_bash; then
if resolve_http_node_runtime; then
return 0
fi
echo -e "${RED}✗ No supported HTTP client available for web-search on Windows${NC}" >&2
echo -e "${YELLOW} Node/Electron runtime is required in Windows shell mode.${NC}" >&2
return 1
fi
if command -v curl > /dev/null 2>&1; then
return 0
fi
if command -v wget > /dev/null 2>&1; then
return 0
fi
if resolve_http_node_runtime; then
return 0
fi
echo -e "${RED}✗ No HTTP client available for web-search${NC}" >&2
echo -e "${YELLOW} Install curl or wget, or ensure Node/Electron runtime is available.${NC}" >&2
return 1
}
is_server_healthy() {
local BASE_URL="${1%/}"
local HEALTH_RESPONSE
HEALTH_RESPONSE=$(http_get "$BASE_URL/api/health" || true)
echo "$HEALTH_RESPONSE" | grep -q '"success":true'
}
try_switch_to_local_server() {
if [ "$ACTIVE_SERVER_URL" = "$DEFAULT_SERVER_URL" ]; then
return 1
fi
if is_server_healthy "$DEFAULT_SERVER_URL"; then
echo -e "${YELLOW}Bridge Server at $ACTIVE_SERVER_URL is unavailable, falling back to ${DEFAULT_SERVER_URL}${NC}" >&2
ACTIVE_SERVER_URL="$DEFAULT_SERVER_URL"
return 0
fi
return 1
}
# Check if server is running
check_server() {
if is_server_healthy "$ACTIVE_SERVER_URL"; then
return 0
fi
if try_switch_to_local_server; then
return 0
fi
echo -e "${YELLOW}Bridge Server is not running, trying to start it...${NC}" >&2
if ! WEB_SEARCH_SERVER="$ACTIVE_SERVER_URL" bash "$SCRIPT_DIR/start-server.sh" > /dev/null 2>&1; then
if is_server_healthy "$ACTIVE_SERVER_URL" || try_switch_to_local_server; then
echo -e "${YELLOW}Bridge Server startup returned an error, but a healthy server is already available. Continuing...${NC}" >&2
return 0
fi
echo -e "${RED}✗ Failed to auto-start Bridge Server${NC}" >&2
echo -e "${YELLOW} Try manually: bash $SCRIPT_DIR/start-server.sh${NC}" >&2
if [ -f "$PROJECT_DIR/.server.log" ]; then
echo -e "${YELLOW} Recent logs:${NC}" >&2
tail -20 "$PROJECT_DIR/.server.log" >&2
fi
exit 1
fi
sleep 2
if is_server_healthy "$ACTIVE_SERVER_URL"; then
return 0
fi
if try_switch_to_local_server; then
return 0
fi
if ! is_server_healthy "$ACTIVE_SERVER_URL"; then
echo -e "${RED}✗ Bridge Server still unavailable after start${NC}" >&2
echo -e "${YELLOW} Endpoint checked: $ACTIVE_SERVER_URL/api/health${NC}" >&2
if [ -f "$PROJECT_DIR/.server.log" ]; then
echo -e "${YELLOW} Recent logs:${NC}" >&2
tail -20 "$PROJECT_DIR/.server.log" >&2
fi
exit 1
fi
}
is_iconv_runtime_error() {
local RESPONSE="$1"
if echo "$RESPONSE" | grep -q "Cannot find module" && echo "$RESPONSE" | grep -q "encodings"; then
return 0
fi
return 1
}
is_connection_runtime_error() {
local RESPONSE="$1"
if echo "$RESPONSE" | grep -q "Connection not found"; then
return 0
fi
if echo "$RESPONSE" | grep -q "Connection not active"; then
return 0
fi
if echo "$RESPONSE" | grep -q "Connection became invalid"; then
return 0
fi
if echo "$RESPONSE" | grep -q "browserContext.newPage"; then
return 0
fi
if echo "$RESPONSE" | grep -q "Target page, context or browser has been closed"; then
return 0
fi
if echo "$RESPONSE" | grep -q "Failed to connect to CDP"; then
return 0
fi
return 1
}
repair_server_runtime() {
echo -e "${YELLOW}Detected broken web-search runtime, trying automatic repair...${NC}" >&2
rm -f "$CONNECTION_CACHE"
bash "$SCRIPT_DIR/stop-server.sh" > /dev/null 2>&1 || true
if ! WEB_SEARCH_FORCE_REPAIR=1 bash "$SCRIPT_DIR/start-server.sh" > /dev/null 2>&1; then
echo -e "${RED}✗ Failed to repair web-search runtime${NC}" >&2
if [ -f "$PROJECT_DIR/.server.log" ]; then
echo -e "${YELLOW} Recent logs:${NC}" >&2
tail -20 "$PROJECT_DIR/.server.log" >&2
fi
return 1
fi
sleep 2
return 0
}
is_cached_connection_valid() {
local CONNECTION_ID="$1"
local VALIDATE_RESPONSE
VALIDATE_RESPONSE=$(http_post_json "$ACTIVE_SERVER_URL/api/page/text" "{\"connectionId\":\"$CONNECTION_ID\"}" || true)
if echo "$VALIDATE_RESPONSE" | grep -q '"success":true'; then
return 0
fi
if is_connection_runtime_error "$VALIDATE_RESPONSE"; then
return 1
fi
# Unknown validation failure should not block new connection creation.
return 1
}
# Get or create browser connection
get_connection() {
local ATTEMPT="${1:-1}"
local CONNECTION_ID=""
# Try to use cached connection
if [ -f "$CONNECTION_CACHE" ]; then
CONNECTION_ID=$(cat "$CONNECTION_CACHE")
# Verify cached connection is actually usable.
if [ -n "$CONNECTION_ID" ] && is_cached_connection_valid "$CONNECTION_ID"; then
echo "$CONNECTION_ID"
return 0
fi
# Stale connection, remove cache
rm -f "$CONNECTION_CACHE"
fi
# Launch browser if not running
local LAUNCH_RESPONSE
LAUNCH_RESPONSE=$(http_post_json "$ACTIVE_SERVER_URL/api/browser/launch" "{}" || true)
if ! echo "$LAUNCH_RESPONSE" | grep -q '"success":true'; then
if [ "$ATTEMPT" -eq 1 ] && is_iconv_runtime_error "$LAUNCH_RESPONSE"; then
if repair_server_runtime; then
get_connection 2
return $?
fi
fi
if [ "$ATTEMPT" -eq 1 ] && is_connection_runtime_error "$LAUNCH_RESPONSE"; then
rm -f "$CONNECTION_CACHE"
bash "$SCRIPT_DIR/stop-server.sh" > /dev/null 2>&1 || true
if WEB_SEARCH_FORCE_REPAIR=1 bash "$SCRIPT_DIR/start-server.sh" > /dev/null 2>&1; then
get_connection 2
return $?
fi
fi
echo -e "${RED}✗ Failed to launch browser${NC}" >&2
echo "$LAUNCH_RESPONSE" >&2
return 1
fi
# Connect to browser
local CONNECT_RESPONSE
CONNECT_RESPONSE=$(http_post_json "$ACTIVE_SERVER_URL/api/browser/connect" "{}" || true)
if ! echo "$CONNECT_RESPONSE" | grep -q '"success":true'; then
if [ "$ATTEMPT" -eq 1 ] && is_iconv_runtime_error "$CONNECT_RESPONSE"; then
if repair_server_runtime; then
get_connection 2
return $?
fi
fi
if [ "$ATTEMPT" -eq 1 ] && is_connection_runtime_error "$CONNECT_RESPONSE"; then
rm -f "$CONNECTION_CACHE"
bash "$SCRIPT_DIR/stop-server.sh" > /dev/null 2>&1 || true
if WEB_SEARCH_FORCE_REPAIR=1 bash "$SCRIPT_DIR/start-server.sh" > /dev/null 2>&1; then
get_connection 2
return $?
fi
fi
echo -e "${RED}✗ Failed to connect to browser${NC}" >&2
echo "$CONNECT_RESPONSE" >&2
return 1
fi
# Extract connection ID
CONNECTION_ID=$(echo "$CONNECT_RESPONSE" | grep -o '"connectionId":"[^"]*"' | cut -d'"' -f4)
if [ -z "$CONNECTION_ID" ]; then
echo -e "${RED}✗ Failed to get connection ID${NC}" >&2
return 1
fi
# Cache connection ID
echo "$CONNECTION_ID" > "$CONNECTION_CACHE"
echo "$CONNECTION_ID"
}
# Perform search
search() {
local QUERY="$1"
local MAX_RESULTS="${2:-10}"
local CONNECTION_ID="$3"
local ATTEMPT="${4:-1}"
local ENGINE="${WEB_SEARCH_ENGINE:-auto}"
echo -e "${BLUE}🔍 Searching for: \"$QUERY\"${NC}" >&2
echo "" >&2
# Perform search via API
local SEARCH_RESPONSE
local SEARCH_PAYLOAD
if ! SEARCH_PAYLOAD="$(build_search_payload "$CONNECTION_ID" "$QUERY" "$MAX_RESULTS" "$ENGINE")"; then
echo -e "${RED}✗ Failed to build search payload${NC}" >&2
return 1
fi
SEARCH_RESPONSE=$(http_post_json "$ACTIVE_SERVER_URL/api/search" "$SEARCH_PAYLOAD" || true)
if ! echo "$SEARCH_RESPONSE" | grep -q '"success":true'; then
if [ "$ATTEMPT" -eq 1 ] && is_iconv_runtime_error "$SEARCH_RESPONSE"; then
if repair_server_runtime; then
if CONNECTION_ID="$(get_connection 2)"; then
search "$QUERY" "$MAX_RESULTS" "$CONNECTION_ID" 2
return $?
fi
return 1
fi
fi
if [ "$ATTEMPT" -eq 1 ] && is_connection_runtime_error "$SEARCH_RESPONSE"; then
rm -f "$CONNECTION_CACHE"
if CONNECTION_ID="$(get_connection 2)"; then
search "$QUERY" "$MAX_RESULTS" "$CONNECTION_ID" 2
return $?
fi
fi
echo -e "${RED}✗ Search failed${NC}" >&2
echo "$SEARCH_RESPONSE" >&2
return 1
fi
# Parse and display results
local DURATION=$(echo "$SEARCH_RESPONSE" | grep -o '"duration":[0-9]*' | cut -d':' -f2)
local TOTAL=$(echo "$SEARCH_RESPONSE" | grep -o '"totalResults":[0-9]*' | cut -d':' -f2)
local ENGINE_USED=$(echo "$SEARCH_RESPONSE" | grep -o '"engine":"[^"]*"' | cut -d'"' -f4)
if [ -z "$ENGINE_USED" ]; then
ENGINE_USED="unknown"
fi
echo -e "${GREEN}✓ Found $TOTAL results in ${DURATION}ms (engine: ${ENGINE_USED})${NC}" >&2
echo "" >&2
# Format results as Markdown
echo "# Search Results: $QUERY"
echo ""
echo "**Query:** $QUERY "
echo "**Engine:** $ENGINE_USED "
echo "**Results:** $TOTAL "
echo "**Time:** ${DURATION}ms "
echo ""
echo "---"
echo ""
# Extract and format each result
# Note: This is a simplified parser. For production, use jq or node.js
echo "$SEARCH_RESPONSE" | grep -o '"title":"[^"]*","url":"[^"]*","snippet":"[^"]*"' | while IFS= read -r result; do
local TITLE=$(echo "$result" | sed -n 's/.*"title":"\([^"]*\)".*/\1/p')
local URL=$(echo "$result" | sed -n 's/.*"url":"\([^"]*\)".*/\1/p')
local SNIPPET=$(echo "$result" | sed -n 's/.*"snippet":"\([^"]*\)".*/\1/p')
echo "## $TITLE"
echo ""
echo "**URL:** [$URL]($URL)"
echo ""
echo "$SNIPPET"
echo ""
echo "---"
echo ""
done
}
# Close browser after search completes
cleanup_browser() {
local CONNECTION_ID="$1"
# Disconnect the Playwright connection
if [ -n "$CONNECTION_ID" ]; then
http_post_json "$ACTIVE_SERVER_URL/api/browser/disconnect" "{\"connectionId\":\"$CONNECTION_ID\"}" > /dev/null 2>&1 || true
fi
# Close the browser process (only kills the browser spawned by web-search, not user's browser)
http_post_json "$ACTIVE_SERVER_URL/api/browser/close" "{}" > /dev/null 2>&1 || true
# Clear connection cache
rm -f "$CONNECTION_CACHE"
}
# Main execution
main() {
# Parse arguments
if [ $# -lt 1 ]; then
usage
fi
local QUERY_ARG="$1"
local QUERY="$QUERY_ARG"
local MAX_RESULTS="${2:-10}"
# Support @file syntax to avoid command-line encoding issues for non-ASCII query.
if [ "${QUERY_ARG#@}" != "$QUERY_ARG" ]; then
local QUERY_FILE="${QUERY_ARG#@}"
if [ ! -f "$QUERY_FILE" ]; then
echo -e "${RED}✗ Query file not found: $QUERY_FILE${NC}" >&2
exit 1
fi
QUERY="$(cat "$QUERY_FILE")"
fi
if ! ensure_http_client_available; then
exit 1
fi
# Check server
check_server
# Get connection
local CONNECTION_ID=""
if ! CONNECTION_ID="$(get_connection)"; then
exit 1
fi
# Perform search
local SEARCH_EXIT_CODE=0
if ! search "$QUERY" "$MAX_RESULTS" "$CONNECTION_ID" 1; then
SEARCH_EXIT_CODE=1
fi
# By default, keep the browser alive so subsequent searches can reuse the
# existing Chrome process and Playwright connection (avoids re-launching
# Chrome which steals window focus). Set WEB_SEARCH_CLEANUP=1 to force
# cleanup after each search.
# Legacy: WEB_SEARCH_NO_CLEANUP=1 is now a no-op (kept for compatibility).
if [ "${WEB_SEARCH_CLEANUP:-}" = "1" ]; then
cleanup_browser "$CONNECTION_ID"
fi
exit $SEARCH_EXIT_CODE
}
# Run main function
main "$@"