Jelajahi Sumber

Merge pull request #2428 from visuddhinanda/development

Development
visuddhinanda 1 Minggu lalu
induk
melakukan
5a6a0dfab2
43 mengubah file dengan 4327 tambahan dan 112 penghapusan
  1. 1 0
      .claude/skills/wikipali-write
  2. 1 0
      .gitignore
  3. 11 7
      api-v13/app/Http/Api/AiAssistantApi.php
  4. 34 28
      api-v13/app/Http/Controllers/AccessTokenController.php
  5. 74 36
      api-v13/app/Http/Controllers/AiModelController.php
  6. 86 0
      api-v13/app/Http/Controllers/AiModelTokenController.php
  7. 16 7
      api-v13/app/Http/Controllers/SentenceController.php
  8. 11 1
      api-v13/app/Http/Requests/StoreAiModelRequest.php
  9. 10 1
      api-v13/app/Http/Requests/UpdateAiModelRequest.php
  10. 47 6
      api-v13/app/Http/Resources/AiModelResource.php
  11. 84 24
      api-v13/app/Services/AuthService.php
  12. 60 0
      api-v13/database/factories/AiModelFactory.php
  13. 30 0
      api-v13/database/migrations/2026_08_05_110345_add_token_version_to_ai_models_table.php
  14. 7 2
      api-v13/phpunit.xml
  15. 3 0
      api-v13/routes/api.php
  16. 51 0
      api-v13/tests/Feature/AccessTokenExpiryTest.php
  17. 118 0
      api-v13/tests/Feature/AiModelCrudTest.php
  18. 56 0
      api-v13/tests/Feature/AiModelResourceTest.php
  19. 139 0
      api-v13/tests/Feature/AiModelTokenTest.php
  20. 32 0
      api-v13/tests/Feature/ChannelUserEditListTest.php
  21. 144 0
      api-v13/tests/Feature/WriteAsAiModelTest.php
  22. 99 0
      api-v13/tests/Pest.php
  23. 278 0
      docs/wikipali-research-agent-design.md
  24. 643 0
      docs/wikipali-write-skill-design.md
  25. 20 0
      plugins/wikipali/.claude-plugin/plugin.json
  26. 89 0
      plugins/wikipali/README.md
  27. 13 0
      plugins/wikipali/bin/wikipali
  28. 134 0
      plugins/wikipali/bin/wikipali-login
  29. 84 0
      plugins/wikipali/install.sh
  30. 127 0
      plugins/wikipali/lib/cli.py
  31. 178 0
      plugins/wikipali/lib/client.py
  32. 284 0
      plugins/wikipali/lib/cmd_read.py
  33. 82 0
      plugins/wikipali/lib/cmd_site.py
  34. 465 0
      plugins/wikipali/lib/cmd_write.py
  35. 61 0
      plugins/wikipali/lib/coords.py
  36. 80 0
      plugins/wikipali/lib/creds.py
  37. 37 0
      plugins/wikipali/lib/errors.py
  38. 59 0
      plugins/wikipali/lib/sites.py
  39. 102 0
      plugins/wikipali/references/api-read.md
  40. 158 0
      plugins/wikipali/references/api-write.md
  41. 81 0
      plugins/wikipali/references/conventions.md
  42. 131 0
      plugins/wikipali/skills/research/SKILL.md
  43. 107 0
      plugins/wikipali/skills/write/SKILL.md

+ 1 - 0
.claude/skills/wikipali-write

@@ -0,0 +1 @@
+../../plugins/wikipali/skills/write

+ 1 - 0
.gitignore

@@ -9,3 +9,4 @@
 *.md5
 
 /k8s/
+__pycache__/

+ 11 - 7
api-v13/app/Http/Api/AiAssistantApi.php

@@ -3,19 +3,21 @@
 namespace App\Http\Api;
 
 use App\Models\AiModel;
-use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Storage;
 
 class AiAssistantApi
 {
     public static function getByUuid($id)
     {
         $user = AiModel::where('uid', $id)->first();
+
         return self::userInfo($user);
     }
+
     public static function userInfo($user)
     {
-        if (!$user) {
+        if (! $user) {
             return [
                 'id' => 0,
                 'nickName' => 'unknown',
@@ -43,21 +45,23 @@ class AiAssistantApi
         } else {
             $logo = null;
             foreach (config('mint.ai.logo') as $key => $value) {
-                if (strpos($user->model, $key) !== false) {
+                // model / url 均可为 null(新建模型时未必填写),须转字符串避免 strpos 弃用告警
+                if (strpos((string) $user->model, $key) !== false) {
                     $logo = $value;
                     break;
-                } else if (strpos($user->url, $key) !== false) {
+                } elseif (strpos((string) $user->url, $key) !== false) {
                     $logo = $value;
                     break;
                 }
             }
-            $base = config('app.url') . '/assets/images/avatar/';
+            $base = config('app.url').'/assets/images/avatar/';
             if ($logo === null) {
-                $data['avatar'] = $base . 'ai-assistant.png';
+                $data['avatar'] = $base.'ai-assistant.png';
             } else {
-                $data['avatar'] = $base . $logo;
+                $data['avatar'] = $base.$logo;
             }
         }
+
         return $data;
     }
 }

+ 34 - 28
api-v13/app/Http/Controllers/AccessTokenController.php

@@ -2,56 +2,60 @@
 
 namespace App\Http\Controllers;
 
+use App\Http\Api\ChannelApi;
 use App\Models\AccessToken;
-use Illuminate\Http\Request;
-use Illuminate\Support\Str;
+use App\Services\AuthService;
 use Firebase\JWT\JWT;
-use Firebase\JWT\Key;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
 use Illuminate\Support\Facades\Log;
-use App\Services\AuthService;
-use App\Http\Api\ChannelApi;
+use Illuminate\Support\Str;
 
 class AccessTokenController extends Controller
 {
+    /**
+     * 签出的 access token 有效期(秒)。7 天足够一次批量写入作业,
+     * 又能把凭据泄漏的窗口限制在可接受范围内。
+     */
+    private const TOKEN_TTL = 60 * 60 * 24 * 7;
+
     /**
      * Display a listing of the resource.
      *
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function index()
     {
         //
     }
 
-
     /**
      * Store a newly created resource in storage.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function store(Request $request)
     {
         //
         $user = AuthService::current($request);
-        if (!$user) {
+        if (! $user) {
             return $this->error(__('auth.failed'), [], 401);
         }
         $payload = $request->input('payload');
-        $result = array();
+        $result = [];
         foreach ($payload as $key => $value) {
-            //鉴权
+            // 鉴权
             switch ($value['res_type']) {
                 case 'channel':
-                    if (!isset($value['power']) || !isset($value['res_id'])) {
+                    if (! isset($value['power']) || ! isset($value['res_id'])) {
                         continue 2;
                     }
                     if ($value['power'] === 'edit') {
-                        if (!ChannelApi::userCanEdit($user['user_uid'], $value['res_id'])) {
+                        if (! ChannelApi::userCanEdit($user['user_uid'], $value['res_id'])) {
                             continue 2;
                         }
                     } else {
-                        if (!ChannelApi::userCanRead($user['user_uid'], $value['res_id'])) {
+                        if (! ChannelApi::userCanRead($user['user_uid'], $value['res_id'])) {
                             continue 2;
                         }
                     }
@@ -60,39 +64,44 @@ class AccessTokenController extends Controller
                     continue 2;
                     break;
             }
-            //获取token
+            // 获取token
             $token = AccessToken::firstOrNew(
                 [
                     'res_type' => $value['res_type'],
-                    'res_id' => $value['res_id']
+                    'res_id' => $value['res_id'],
                 ],
                 [
-                    'token' => (string)Str::uuid()
+                    'token' => (string) Str::uuid(),
                 ]
             );
-            if (!$token->exists) {
+            if (! $token->exists) {
                 $token->save();
             }
 
+            // 有效期:payload 里不注入 exp 的话,签出的 token 永久有效,泄漏后无法失效
+            $value['nbf'] = time();
+            $value['exp'] = time() + self::TOKEN_TTL;
+
             try {
-                $jwt = JWT::encode($value, $token->token . $token->token, 'HS512');
+                $jwt = JWT::encode($value, $token->token.$token->token, 'HS512');
             } catch (\Exception $e) {
                 Log::error('jwt', ['error' => $e]);
+
                 continue;
             }
             $result[] = [
                 'payload' => $value,
-                'token' => $jwt
+                'token' => $jwt,
             ];
         }
+
         return $this->ok(['rows' => $result, 'count' => count($result)]);
     }
 
     /**
      * Display the specified resource.
      *
-     * @param  \App\Models\AccessToken  $accessToken
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function show(AccessToken $accessToken)
     {
@@ -102,9 +111,7 @@ class AccessTokenController extends Controller
     /**
      * Update the specified resource in storage.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @param  \App\Models\AccessToken  $accessToken
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function update(Request $request, AccessToken $accessToken)
     {
@@ -114,8 +121,7 @@ class AccessTokenController extends Controller
     /**
      * Remove the specified resource from storage.
      *
-     * @param  \App\Models\AccessToken  $accessToken
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function destroy(AccessToken $accessToken)
     {

+ 74 - 36
api-v13/app/Http/Controllers/AiModelController.php

@@ -2,35 +2,42 @@
 
 namespace App\Http\Controllers;
 
+use App\Http\Api\StudioApi;
 use App\Http\Requests\StoreAiModelRequest;
 use App\Http\Requests\UpdateAiModelRequest;
+use App\Http\Resources\AiModelResource;
 use App\Models\AiModel;
-use Illuminate\Http\Request;
 use App\Services\AuthService;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
 use Illuminate\Support\Str;
-use App\Http\Api\StudioApi;
-use App\Http\Resources\AiModelResource;
-
 
 class AiModelController extends Controller
 {
+    /**
+     * 客户端可写的字段(name / privacy 另行处理:前者参与重名校验,后者建档时有默认值)。
+     */
+    private const EDITABLE_FIELDS = ['description', 'system_prompt', 'url', 'model', 'key'];
+
     /**
      * Display a listing of the resource.
      *
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function index(Request $request)
     {
         //
         $user = AuthService::current($request);
-        if (!$user) {
+        if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
         }
+
         switch ($request->input('view')) {
             case 'all':
                 $table = AiModel::whereNotNull('owner_id');
                 break;
             case 'studio':
+                // 指定用户名下的记录
                 $studioId = StudioApi::getIdByName($request->input('name'));
                 $table = AiModel::where('owner_id', $studioId);
                 break;
@@ -39,11 +46,11 @@ class AiModelController extends Controller
                     ->orWhere('privacy', 'public');
                 break;
             case 'chat':
-                $table = AiModel::where('owner_id', config("mint.admin.root_uuid"));
+                $table = AiModel::where('owner_id', config('mint.admin.root_uuid'));
                 break;
         }
         if ($request->has('keyword')) {
-            $table = $table->where('name', 'like', '%' . $request->input('keyword') . '%');
+            $table = $table->where('name', 'like', '%'.$request->input('keyword').'%');
         }
         $count = $table->count();
 
@@ -52,15 +59,15 @@ class AiModelController extends Controller
             $request->input('dir', 'asc')
         );
 
-        $table = $table->skip($request->input("offset", 0))
+        $table = $table->skip($request->input('offset', 0))
             ->take($request->input('limit', 1000));
 
         $result = $table->get();
 
         return $this->ok(
             [
-                "rows" => AiModelResource::collection(resource: $result),
-                "count" => $count,
+                'rows' => AiModelResource::collection(resource: $result),
+                'count' => $count,
             ]
         );
     }
@@ -68,88 +75,119 @@ class AiModelController extends Controller
     /**
      * Store a newly created resource in storage.
      *
-     * @param  \App\Http\Requests\StoreAiModelRequest  $request
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function store(StoreAiModelRequest $request)
     {
         //
         $user = AuthService::current($request);
-        if (!$user) {
+        if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
         }
         $studioId = StudioApi::getIdByName($request->input('studio_name'));
-        if (!self::canEdit($user['user_uid'], $studioId)) {
+        if (! self::canEdit($user['user_uid'], $studioId)) {
             return $this->error(__('auth.failed'), 403, 403);
         }
-        $new = new AiModel();
-        $new->name = $request->input('name');
+        // 同一 studio 内 name 必须唯一:客户端(wikipali-write Skill)靠 name 做幂等匹配,
+        // 重名会让「查不到就创建」的流程反复建出同名记录
+        $duplicated = AiModel::where('owner_id', $studioId)
+            ->where('name', $request->input('name'))
+            ->exists();
+        if ($duplicated) {
+            return $this->error(__('validation.unique', ['attribute' => 'name']), null, 409);
+        }
+
+        $new = new AiModel;
         $new->uid = Str::uuid();
         $new->real_name = Str::uuid();
         $new->owner_id = $studioId;
         $new->editor_id = $user['user_uid'];
+        $new->name = $request->input('name');
+        $new->privacy = $request->input('privacy', 'private');
+        foreach (self::EDITABLE_FIELDS as $field) {
+            if ($request->has($field)) {
+                $new->{$field} = $request->input($field);
+            }
+        }
         $new->save();
+
         return $this->ok(new AiModelResource($new));
     }
 
     /**
      * Display the specified resource.
      *
-     * @param  \App\Models\AiModel  $aiModel
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
-    public function show(AiModel $aiModel)
+    public function show(Request $request, AiModel $aiModel)
     {
         //
+        $user = AuthService::current($request);
+        if (! $user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        if (! self::canEdit($user['user_uid'], $aiModel->owner_id)) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+
         return $this->ok(new AiModelResource($aiModel));
     }
 
     /**
      * Update the specified resource in storage.
      *
-     * @param  \App\Http\Requests\UpdateAiModelRequest  $request
-     * @param  \App\Models\AiModel  $aiModel
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function update(UpdateAiModelRequest $request, AiModel $aiModel)
     {
         //
         $user = AuthService::current($request);
-        if (!$user) {
+        if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
         }
-        if (!self::canEdit($user['user_uid'], $aiModel->owner_id)) {
+        if (! self::canEdit($user['user_uid'], $aiModel->owner_id)) {
             return $this->error(__('auth.failed'), 403, 403);
         }
-        $aiModel->name = $request->input('name');
-        $aiModel->description = $request->input('description');
-        $aiModel->system_prompt = $request->input('system_prompt');
-        $aiModel->url = $request->input('url');
-        $aiModel->model = $request->input('model');
-        $aiModel->key = $request->input('key');
-        $aiModel->privacy = $request->input('privacy');
+        if ($request->has('name')) {
+            $duplicated = AiModel::where('owner_id', $aiModel->owner_id)
+                ->where('name', $request->input('name'))
+                ->where('uid', '<>', $aiModel->uid)
+                ->exists();
+            if ($duplicated) {
+                return $this->error(__('validation.unique', ['attribute' => 'name']), null, 409);
+            }
+        }
+        // 增量更新:只改请求里出现的字段。
+        // 用 has() 而非 filled(),好让前端能把 description 之类的字段显式清空;
+        // 但未提交的字段必须原样保留——否则客户端的局部 PUT 会把其余字段全置 null。
+        foreach (array_merge(['name', 'privacy'], self::EDITABLE_FIELDS) as $field) {
+            if ($request->has($field)) {
+                $aiModel->{$field} = $request->input($field);
+            }
+        }
         $aiModel->editor_id = $user['user_uid'];
         $aiModel->save();
+
         return $this->ok(new AiModelResource($aiModel));
     }
 
     /**
      * Remove the specified resource from storage.
      *
-     * @param  \App\Models\AiModel  $aiModel
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function destroy(Request $request, AiModel $aiModel)
     {
         //
         $user = AuthService::current($request);
-        if (!$user) {
+        if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
         }
-        if (!self::canEdit($user['user_uid'], $aiModel->owner_id)) {
+        if (! self::canEdit($user['user_uid'], $aiModel->owner_id)) {
             return $this->error(__('auth.failed'), 403, 403);
         }
         $del = $aiModel->delete();
+
         return $this->ok($del);
     }
 

+ 86 - 0
api-v13/app/Http/Controllers/AiModelTokenController.php

@@ -0,0 +1,86 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\AiModel;
+use App\Services\AuthService;
+use App\Tools\OpsLog;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+/**
+ * 签发 AI 模型的身份 token。
+ *
+ * 外部客户端(如 wikipali-write Skill)拿到该 token 后,即可以「模型身份」
+ * 调用写入类端点,使 editor_uid 记为模型 uid 而非操作者本人。
+ *
+ * @see docs/wikipali-write-skill-design.md §5.1
+ */
+class AiModelTokenController extends Controller
+{
+    /**
+     * 取得指定 AI 模型的 user token。
+     *
+     * 仅模型 owner 本人可调用(设计决策:模型只挂个人 studio,不支持 group studio)。
+     * 签出的 token 有效期 30 天,可用 destroy() 提前撤销;属高敏感凭据,故记入 ops 日志。
+     */
+    public function show(Request $request, AiModel $aiModel): JsonResponse
+    {
+        $user = AuthService::current($request);
+        if (! $user) {
+            return $this->error(__('auth.failed'), null, 401);
+        }
+        if (! AiModelController::canEdit($user['user_uid'], $aiModel->owner_id)) {
+            return $this->error(__('auth.failed'), null, 403);
+        }
+
+        $token = AuthService::getUserToken($aiModel->uid);
+        if (! $token) {
+            return $this->error('ai model not found', null, 404);
+        }
+
+        OpsLog::debug($user['user_uid'], [
+            'action' => 'ai-model-token.issue',
+            'model_uid' => $aiModel->uid,
+            'model_name' => $aiModel->name,
+        ]);
+
+        return $this->ok([
+            'uid' => $aiModel->uid,
+            'name' => $aiModel->name,
+            'token' => $token,
+        ]);
+    }
+
+    /**
+     * 撤销该模型已签出的全部身份 token。
+     *
+     * 版本号自增后,旧 token 里的 ver 立即对不上(见 AuthService::current())。
+     * 无法只撤销其中一张——凭据泄漏时本就该全部作废。
+     */
+    public function destroy(Request $request, AiModel $aiModel): JsonResponse
+    {
+        $user = AuthService::current($request);
+        if (! $user) {
+            return $this->error(__('auth.failed'), null, 401);
+        }
+        if (! AiModelController::canEdit($user['user_uid'], $aiModel->owner_id)) {
+            return $this->error(__('auth.failed'), null, 403);
+        }
+
+        $aiModel->increment('token_version');
+
+        OpsLog::debug($user['user_uid'], [
+            'action' => 'ai-model-token.revoke',
+            'model_uid' => $aiModel->uid,
+            'model_name' => $aiModel->name,
+            'token_version' => (int) $aiModel->token_version,
+        ]);
+
+        return $this->ok([
+            'uid' => $aiModel->uid,
+            'name' => $aiModel->name,
+            'token_version' => (int) $aiModel->token_version,
+        ]);
+    }
+}

+ 16 - 7
api-v13/app/Http/Controllers/SentenceController.php

@@ -85,7 +85,7 @@ class SentenceController extends Controller
                     return $this->error('没有关键词');
                 }
                 $table = Sentence::select($indexCol)
-                    ->where('content', 'like', '%' . $key . '%')
+                    ->where('content', 'like', '%'.$key.'%')
                     ->where('editor_uid', $userUid);
 
                 break;
@@ -203,7 +203,7 @@ class SentenceController extends Controller
                 break;
         }
         if (! empty($request->input('key'))) {
-            $table = $table->where('content', 'like', '%' . $request->input('key') . '%');
+            $table = $table->where('content', 'like', '%'.$request->input('key').'%');
         }
 
         $count = $table->count();
@@ -269,7 +269,7 @@ class SentenceController extends Controller
         }
     }
 
-    private function UserCanEdit(string $userId, string $channelId, int  $book, $access_token = null)
+    private function UserCanEdit(string $userId, string $channelId, int $book, $access_token = null)
     {
         $channel = Channel::where('uid', $channelId)->first();
         if (! $channel) {
@@ -284,8 +284,17 @@ class SentenceController extends Controller
                     return false;
                 }
                 $key = AccessToken::where('res_id', $channelId)->value('token');
-                $jwt = JWT::decode($access_token, new Key($key . $key, 'HS512'));
-                if (isset($jwt->book) && $jwt->book !== 0 &&  $jwt->book !== $book) {
+                if (! $key) {
+                    return false;
+                }
+                try {
+                    // access token 现在带 exp,过期会抛 ExpiredException;
+                    // 伪造/损坏的 token 同样抛异常。一律当作无权,不要冒泡成 500。
+                    $jwt = JWT::decode($access_token, new Key($key.$key, 'HS512'));
+                } catch (\Exception $e) {
+                    return false;
+                }
+                if (isset($jwt->book) && $jwt->book !== 0 && $jwt->book !== $book) {
                     return false;
                 }
             }
@@ -316,7 +325,7 @@ class SentenceController extends Controller
             if ($this->UserCanEdit(
                 $user['user_uid'],
                 $request->input('channel'),
-                (int)$request->input('book', 0),
+                (int) $request->input('book', 0),
                 $request->input('access_token', null)
             )) {
                 $destChannel = Channel::where('uid', $request->input('channel'))->first();
@@ -333,7 +342,7 @@ class SentenceController extends Controller
                 if ($this->UserCanEdit(
                     $user['user_uid'],
                     $sent['channel_uid'],
-                    (int)$sent['book_id'],
+                    (int) $sent['book_id'],
                     isset($sent['access_token']) ? $sent['access_token'] : null
                 )) {
                     $destChannel = Channel::where('uid', $sent['channel_uid'])->first();

+ 11 - 1
api-v13/app/Http/Requests/StoreAiModelRequest.php

@@ -13,18 +13,28 @@ class StoreAiModelRequest extends FormRequest
      */
     public function authorize()
     {
+        // 真正的鉴权在 AiModelController::store():需要先把 studio_name 解成 studio uid
         return true;
     }
 
     /**
      * Get the validation rules that apply to the request.
      *
+     * 长度上限对齐 ai_models 表的列定义。
+     *
      * @return array
      */
     public function rules()
     {
         return [
-            //
+            'name' => ['required', 'string', 'max:64'],
+            'studio_name' => ['required', 'string'],
+            'description' => ['nullable', 'string'],
+            'system_prompt' => ['nullable', 'string'],
+            'url' => ['nullable', 'string', 'max:1024'],
+            'model' => ['nullable', 'string', 'max:1024'],
+            'key' => ['nullable', 'string', 'max:1024'],
+            'privacy' => ['nullable', 'string', 'in:private,public,disable'],
         ];
     }
 }

+ 10 - 1
api-v13/app/Http/Requests/UpdateAiModelRequest.php

@@ -13,18 +13,27 @@ class UpdateAiModelRequest extends FormRequest
      */
     public function authorize()
     {
+        // 真正的鉴权在 AiModelController::update():需要拿到 $aiModel 才能判 owner
         return true;
     }
 
     /**
      * Get the validation rules that apply to the request.
      *
+     * 全部字段用 sometimes:update 是增量的,未提交的字段保持原值。
+     *
      * @return array
      */
     public function rules()
     {
         return [
-            //
+            'name' => ['sometimes', 'required', 'string', 'max:64'],
+            'description' => ['sometimes', 'nullable', 'string'],
+            'system_prompt' => ['sometimes', 'nullable', 'string'],
+            'url' => ['sometimes', 'nullable', 'string', 'max:1024'],
+            'model' => ['sometimes', 'nullable', 'string', 'max:1024'],
+            'key' => ['sometimes', 'nullable', 'string', 'max:1024'],
+            'privacy' => ['sometimes', 'required', 'string', 'in:private,public,disable'],
         ];
     }
 }

+ 47 - 6
api-v13/app/Http/Resources/AiModelResource.php

@@ -2,21 +2,62 @@
 
 namespace App\Http\Resources;
 
-use Illuminate\Http\Resources\Json\JsonResource;
 use App\Http\Api\AiAssistantApi;
+use App\Services\AuthService;
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
 
 class AiModelResource extends JsonResource
 {
     /**
-     * Transform the resource into an array.
+     * 把资源转成数组。
+     *
+     * 字段白名单:绝不能回落到 parent::toArray(),那会把 key(第三方 API key)
+     * 和 system_prompt 一并吐出去,而 index() 的 view=all / view=usable 分支
+     * 对任何登录用户可见,等于公开泄漏所有模型的 API key。
+     *
+     * key / system_prompt 仅在请求者是 owner 本人时附带——dashboard 的模型编辑页
+     * (AiModelEdit)需要回填这两个字段。
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
+     * @param  Request  $request
+     * @return array
      */
     public function toArray($request)
     {
-        $data = parent::toArray($request);
-        $data['user'] = AiAssistantApi::userInfo($this);
+        $data = [
+            'uid' => $this->uid,
+            'name' => $this->name,
+            'description' => $this->description,
+            'url' => $this->url,
+            'model' => $this->model,
+            'privacy' => $this->privacy,
+            'owner_id' => $this->owner_id,
+            'editor_id' => $this->editor_id,
+            'created_at' => $this->created_at,
+            'updated_at' => $this->updated_at,
+            'user' => AiAssistantApi::userInfo($this),
+        ];
+
+        if ($this->isRequestedByOwner($request)) {
+            $data['key'] = $this->key;
+            $data['system_prompt'] = $this->system_prompt;
+        }
+
         return $data;
     }
+
+    /**
+     * 请求者是否为本模型的 owner。
+     *
+     * 结果按请求缓存:index() 一次可返回上千行,逐行解一次 JWT 代价过高。
+     */
+    private function isRequestedByOwner($request): bool
+    {
+        if (! $request->attributes->has('auth.current')) {
+            $request->attributes->set('auth.current', AuthService::current($request));
+        }
+        $user = $request->attributes->get('auth.current');
+
+        return $user && $user['user_uid'] === $this->owner_id;
+    }
 }

+ 84 - 24
api-v13/app/Services/AuthService.php

@@ -3,43 +3,70 @@
 namespace App\Services;
 
 use App\Http\Api\UserApi;
-use App\Http\Api\AiAssistantApi;
-
-use Illuminate\Http\Request;
+use App\Models\AiModel;
 use Firebase\JWT\JWT;
 use Firebase\JWT\Key;
+use Illuminate\Http\Request;
 
 class AuthService
 {
+    /**
+     * 人类用户登录 token 的有效期(秒)。
+     */
+    private const USER_TOKEN_TTL = 60 * 60 * 24 * 365;
+
+    /**
+     * AI 模型身份 token 的有效期(秒)。
+     *
+     * 模型 token 会被复制到外部客户端(如 wikipali-write Skill)的凭据文件里,
+     * 泄漏面比人类登录 token 大得多,故取远短的 30 天。
+     */
+    private const AI_MODEL_TOKEN_TTL = 60 * 60 * 24 * 30;
+
+    /**
+     * 模型身份 token 的 typ 标记。带此标记的 token 每次校验都要比对版本号,
+     * 人类 token 不带,避免为每个请求多查一次库。
+     */
+    private const AI_MODEL_TOKEN_TYPE = 'ai-model';
+
     public static function getUserToken(string $userUid)
     {
-        $user = UserApi::getByUuid($userUid);
-        if (!$user) {
-            $user = AiAssistantApi::getByUuid($userUid);
+        // 先判模型:UserApi::getByUuid() 查不到用户时会回落到 AiAssistantApi,
+        // 分不清「模型」和「查无此人」,而这两者签出的 token 完全不同。
+        $aiModel = AiModel::where('uid', $userUid)->first();
+        if ($aiModel) {
+            return self::encode([
+                'uid' => $aiModel->uid,
+                'id' => 0,
+                'typ' => self::AI_MODEL_TOKEN_TYPE,
+                'ver' => (int) $aiModel->token_version,
+            ], self::AI_MODEL_TOKEN_TTL);
         }
-        if ($user) {
-            $ExpTime = time() + 60 * 60 * 24 * 365;
-            $key = self::getJwtKey();
-            $payload = [
-                'nbf' => time(),
-                'exp' => $ExpTime,
-                'uid' => $user['id'],
-                'id' => $user['sn'],
-            ];
-            $jwt = JWT::encode($payload, $key, 'HS512');
-            return $jwt;
+
+        $user = UserApi::getByUuid($userUid);
+        if (! $user || ! isset($user['sn'])) {
+            // 查无此人时 UserApi 返回的是 id=0 的占位结构,不能拿它签 token
+            return null;
         }
-        return null;
+
+        return self::encode([
+            'uid' => $user['id'],
+            'id' => $user['sn'],
+        ], self::USER_TOKEN_TTL);
     }
+
     public static function getJwtKey()
     {
         return config('mint.app.jwt_secrets_key');
     }
+
     public static function getToken(Request $request)
     {
         $token = $request->bearerToken();
+
         return $token;
     }
+
     public static function current(Request $request)
     {
         $token = $request->bearerToken();
@@ -51,19 +78,52 @@ class AuthService
                 return false;
             }
             if ($jwt->exp < time()) {
-                //过期
+                // 过期
+                return false;
+            }
+            if (! self::modelTokenIsValid($jwt)) {
                 return false;
-            } else {
-                //有效的token
-                return ['user_uid' => $jwt->uid, 'user_id' => $jwt->id];
             }
-        } else if (isset($_COOKIE['user_uid'])) {
+
+            // 有效的token
+            return ['user_uid' => $jwt->uid, 'user_id' => $jwt->id];
+        } elseif (isset($_COOKIE['user_uid'])) {
             return [
                 'user_uid' => $_COOKIE['user_uid'],
-                'user_id' => $_COOKIE['user_id']
+                'user_id' => $_COOKIE['user_id'],
             ];
         } else {
             return false;
         }
     }
+
+    /**
+     * 校验模型身份 token 是否已被撤销。
+     *
+     * 撤销即把 ai_models.token_version 自增,旧 token 里的 ver 随即对不上。
+     * 模型被删除同样视为失效。人类 token(id 为用户自增主键,恒 > 0)直接放行,不查库。
+     */
+    private static function modelTokenIsValid(object $jwt): bool
+    {
+        if (isset($jwt->typ) && $jwt->typ === self::AI_MODEL_TOKEN_TYPE) {
+            $version = AiModel::where('uid', $jwt->uid)->value('token_version');
+
+            return $version !== null && (int) $version === (int) ($jwt->ver ?? 0);
+        }
+
+        // 引入版本号之前签出的模型 token(typ 缺失、id 恒为 0)无法撤销,一律作废,
+        // 持有者须重新签发。
+        return (int) ($jwt->id ?? 0) !== 0;
+    }
+
+    /**
+     * @param  array<string, mixed>  $claims
+     */
+    private static function encode(array $claims, int $ttl): string
+    {
+        return JWT::encode(array_merge([
+            'nbf' => time(),
+            'exp' => time() + $ttl,
+        ], $claims), self::getJwtKey(), 'HS512');
+    }
 }

+ 60 - 0
api-v13/database/factories/AiModelFactory.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\AiModel;
+use Illuminate\Database\Eloquent\Factories\Factory;
+use Illuminate\Support\Str;
+
+/**
+ * @extends Factory<AiModel>
+ */
+class AiModelFactory extends Factory
+{
+    protected $model = AiModel::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        return [
+            'uid' => (string) Str::uuid(),
+            'name' => fake()->unique()->slug(2),
+            // real_name 是模型的登录标识,表上有 unique 约束
+            'real_name' => (string) Str::uuid(),
+            'description' => fake()->sentence(),
+            'url' => 'https://api.example.com',
+            'model' => 'gpt-4',
+            'key' => 'sk-'.fake()->uuid(),
+            'system_prompt' => 'you are a helpful assistant',
+            'privacy' => 'private',
+            'owner_id' => (string) Str::uuid(),
+            'editor_id' => (string) Str::uuid(),
+        ];
+    }
+
+    /**
+     * AiModel 没有声明 $fillable(默认 guarded = ['*']),构造器里 fill() 会抛
+     * MassAssignmentException。这里绕开批量赋值保护,而不是为了测试去放开生产模型的写入面。
+     */
+    public function newModel(array $attributes = [])
+    {
+        $model = $this->modelName();
+
+        return (new $model)->forceFill($attributes);
+    }
+
+    /**
+     * 归属于指定 studio(个人 studio 即用户 uid)。
+     */
+    public function ownedBy(string $ownerId): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'owner_id' => $ownerId,
+            'editor_id' => $ownerId,
+        ]);
+    }
+}

+ 30 - 0
api-v13/database/migrations/2026_08_05_110345_add_token_version_to_ai_models_table.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * 模型身份 token 的版本号。owner 撤销时自增,已签出 token 里的 ver 对不上即失效。
+     */
+    public function up(): void
+    {
+        Schema::table('ai_models', function (Blueprint $table) {
+            $table->unsignedInteger('token_version')
+                ->default(1)
+                ->comment('身份 token 版本号,自增即撤销该模型全部已签出 token');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('ai_models', function (Blueprint $table) {
+            $table->dropColumn('token_version');
+        });
+    }
+};

+ 7 - 2
api-v13/phpunit.xml

@@ -23,8 +23,13 @@
         <env name="BCRYPT_ROUNDS" value="4"/>
         <env name="BROADCAST_CONNECTION" value="null"/>
         <env name="CACHE_STORE" value="array"/>
-        <env name="DB_CONNECTION" value="sqlite"/>
-        <env name="DB_DATABASE" value=":memory:"/>
+        <!--
+            迁移文件含 Postgres 专有语句(CREATE EXTENSION "uuid-ossp" 等),无法在 sqlite 上跑,
+            故测试也走 pgsql。DB_DATABASE 必须固定为独立的测试库:RefreshDatabase 会清空目标库,
+            指向开发库会直接毁掉数据。
+        -->
+        <env name="DB_CONNECTION" value="pgsql"/>
+        <env name="DB_DATABASE" value="mint_test"/>
         <env name="DB_URL" value=""/>
         <env name="MAIL_MAILER" value="array"/>
         <env name="QUEUE_CONNECTION" value="sync"/>

+ 3 - 0
api-v13/routes/api.php

@@ -3,6 +3,7 @@
 use App\Http\Controllers\AccessTokenController;
 use App\Http\Controllers\AiAssistantController;
 use App\Http\Controllers\AiModelController;
+use App\Http\Controllers\AiModelTokenController;
 use App\Http\Controllers\AiTranslateController;
 use App\Http\Controllers\ApiController;
 use App\Http\Controllers\ArticleController;
@@ -299,6 +300,8 @@ Route::group([
     Route::apiResource('access-token', AccessTokenController::class);
     Route::apiResource('search-word-slice', SearchWordSliceController::class);
     Route::apiResource('ai-model', AiModelController::class);
+    Route::get('ai-model-token/{ai_model}', [AiModelTokenController::class, 'show']);
+    Route::delete('ai-model-token/{ai_model}', [AiModelTokenController::class, 'destroy']);
     Route::apiResource('ai-assistant', AiAssistantController::class);
     Route::apiResource('model-log', ModelLogController::class);
     Route::apiResource('sentence-attachment', SentenceAttachmentController::class);

+ 51 - 0
api-v13/tests/Feature/AccessTokenExpiryTest.php

@@ -0,0 +1,51 @@
+<?php
+
+use App\Models\AccessToken;
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+it('signs channel access tokens with an expiry', function () {
+    $owner = makeStudio('tester');
+    $channel = makeChannel($owner);
+
+    $token = $this->postJson('/api/v2/access-token', [
+        'payload' => [[
+            'res_type' => 'channel',
+            'res_id' => $channel,
+            'power' => 'edit',
+            'book' => 0,
+        ]],
+    ], authHeader($owner))
+        ->assertOk()
+        ->assertJsonPath('data.count', 1)
+        ->json('data.rows.0.token');
+
+    $key = AccessToken::where('res_id', $channel)->value('token');
+    $jwt = JWT::decode($token, new Key($key.$key, 'HS512'));
+
+    // 修复前 payload 里没有 exp,签出的 token 永久有效
+    expect($jwt->exp)->toBeGreaterThan(time());
+    expect($jwt->exp)->toBeLessThanOrEqual(time() + 60 * 60 * 24 * 7);
+    expect($jwt->res_id)->toBe($channel);
+});
+
+it('returns an empty row set when the user cannot edit the channel', function () {
+    $owner = makeStudio('owner');
+    $channel = makeChannel($owner);
+    $stranger = makeStudio('stranger');
+
+    // 无权时该条被静默跳过——客户端必须靠 count 判空,不能当成功
+    $this->postJson('/api/v2/access-token', [
+        'payload' => [[
+            'res_type' => 'channel',
+            'res_id' => $channel,
+            'power' => 'edit',
+            'book' => 0,
+        ]],
+    ], authHeader($stranger))
+        ->assertOk()
+        ->assertJsonPath('data.count', 0);
+});

+ 118 - 0
api-v13/tests/Feature/AiModelCrudTest.php

@@ -0,0 +1,118 @@
+<?php
+
+use App\Models\AiModel;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+it('stores every field the client sends', function () {
+    $ownerId = makeStudio('tester');
+
+    $this->postJson('/api/v2/ai-model', [
+        'studio_name' => 'tester',
+        'name' => 'claude-opus-5',
+        'model' => 'claude-opus-5-20260101',
+        'url' => 'https://api.anthropic.com',
+        'key' => 'sk-ant-test',
+        'privacy' => 'public',
+        'description' => 'writes sentences',
+    ], authHeader($ownerId))->assertOk();
+
+    $saved = AiModel::where('owner_id', $ownerId)->where('name', 'claude-opus-5')->first();
+
+    // 修复前 store() 只写 name/uid/real_name/owner_id/editor_id,其余全丢
+    expect($saved->model)->toBe('claude-opus-5-20260101');
+    expect($saved->url)->toBe('https://api.anthropic.com');
+    expect($saved->key)->toBe('sk-ant-test');
+    expect($saved->privacy)->toBe('public');
+    expect($saved->description)->toBe('writes sentences');
+});
+
+it('defaults privacy to private', function () {
+    $ownerId = makeStudio('tester');
+
+    $this->postJson('/api/v2/ai-model', [
+        'studio_name' => 'tester',
+        'name' => 'no-privacy-given',
+    ], authHeader($ownerId))->assertOk();
+
+    expect(AiModel::where('owner_id', $ownerId)->first()->privacy)->toBe('private');
+});
+
+it('refuses a duplicate name inside the same studio', function () {
+    $ownerId = makeStudio('tester');
+    AiModel::factory()->ownedBy($ownerId)->create(['name' => 'claude-opus-5']);
+
+    $this->postJson('/api/v2/ai-model', [
+        'studio_name' => 'tester',
+        'name' => 'claude-opus-5',
+    ], authHeader($ownerId))->assertStatus(409);
+
+    expect(AiModel::where('owner_id', $ownerId)->count())->toBe(1);
+});
+
+it('requires a name', function () {
+    $ownerId = makeStudio('tester');
+
+    $this->postJson('/api/v2/ai-model', [
+        'studio_name' => 'tester',
+    ], authHeader($ownerId))->assertStatus(422);
+});
+
+it('updates only the fields present in the request', function () {
+    $owner = (string) Str::uuid();
+    $model = AiModel::factory()->ownedBy($owner)->create([
+        'name' => 'claude-opus-5',
+        'key' => 'sk-keep-me',
+        'system_prompt' => 'keep me too',
+        'url' => 'https://api.anthropic.com',
+    ]);
+
+    // 只改 model 一个字段——这正是 Skill 的 ensure-model 会发的局部 PUT
+    $this->putJson("/api/v2/ai-model/{$model->uid}", [
+        'model' => 'claude-opus-5-20260101',
+    ], authHeader($owner))->assertOk();
+
+    $model->refresh();
+
+    expect($model->model)->toBe('claude-opus-5-20260101');
+    // 修复前这些字段会被 input() 的 null 覆盖掉
+    expect($model->key)->toBe('sk-keep-me');
+    expect($model->system_prompt)->toBe('keep me too');
+    expect($model->url)->toBe('https://api.anthropic.com');
+    expect($model->name)->toBe('claude-opus-5');
+});
+
+it('still allows clearing a field explicitly', function () {
+    $owner = (string) Str::uuid();
+    $model = AiModel::factory()->ownedBy($owner)->create(['description' => 'old text']);
+
+    $this->putJson("/api/v2/ai-model/{$model->uid}", [
+        'description' => null,
+    ], authHeader($owner))->assertOk();
+
+    expect($model->refresh()->description)->toBeNull();
+});
+
+it('refuses to rename onto an existing name', function () {
+    $owner = (string) Str::uuid();
+    AiModel::factory()->ownedBy($owner)->create(['name' => 'taken']);
+    $model = AiModel::factory()->ownedBy($owner)->create(['name' => 'mine']);
+
+    $this->putJson("/api/v2/ai-model/{$model->uid}", [
+        'name' => 'taken',
+    ], authHeader($owner))->assertStatus(409);
+
+    expect($model->refresh()->name)->toBe('mine');
+});
+
+it('rejects an update from a non-owner', function () {
+    $model = AiModel::factory()->create(['name' => 'untouched']);
+
+    $this->putJson("/api/v2/ai-model/{$model->uid}", [
+        'name' => 'hijacked',
+    ], authHeader((string) Str::uuid()))->assertStatus(403);
+
+    expect($model->refresh()->name)->toBe('untouched');
+});

+ 56 - 0
api-v13/tests/Feature/AiModelResourceTest.php

@@ -0,0 +1,56 @@
+<?php
+
+use App\Models\AiModel;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+const SECRET_KEY = 'sk-super-secret-api-key';
+const SECRET_PROMPT = 'you are a very secret assistant';
+
+function modelWithSecrets(string $ownerId): AiModel
+{
+    return AiModel::factory()->ownedBy($ownerId)->create([
+        'name' => 'secret-model',
+        'key' => SECRET_KEY,
+        'system_prompt' => SECRET_PROMPT,
+        'privacy' => 'public',
+    ]);
+}
+
+it('never exposes the api key to a stranger listing models', function () {
+    modelWithSecrets((string) Str::uuid());
+
+    $body = $this->getJson('/api/v2/ai-model?view=all', authHeader((string) Str::uuid()))
+        ->assertOk()
+        ->getContent();
+
+    expect($body)->not->toContain(SECRET_KEY);
+    expect($body)->not->toContain(SECRET_PROMPT);
+    // 非敏感字段仍须返回,否则前端列表会空
+    expect($body)->toContain('secret-model');
+});
+
+it('gives the owner back key and system_prompt so the edit form can prefill', function () {
+    $owner = (string) Str::uuid();
+    $model = modelWithSecrets($owner);
+
+    $this->getJson("/api/v2/ai-model/{$model->uid}", authHeader($owner))
+        ->assertOk()
+        ->assertJsonPath('data.key', SECRET_KEY)
+        ->assertJsonPath('data.system_prompt', SECRET_PROMPT);
+});
+
+it('does not leak internal columns', function () {
+    $owner = (string) Str::uuid();
+    $model = modelWithSecrets($owner);
+
+    $data = $this->getJson("/api/v2/ai-model/{$model->uid}", authHeader($owner))
+        ->assertOk()
+        ->json('data');
+
+    // real_name 是模型的登录身份标识,id 是自增主键,都不该外露
+    expect($data)->not->toHaveKey('real_name');
+    expect($data)->not->toHaveKey('id');
+});

+ 139 - 0
api-v13/tests/Feature/AiModelTokenTest.php

@@ -0,0 +1,139 @@
+<?php
+
+use App\Models\AiModel;
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+it('rejects an anonymous request', function () {
+    $model = AiModel::factory()->create();
+
+    $this->getJson("/api/v2/ai-model-token/{$model->uid}")
+        ->assertStatus(401);
+});
+
+it('rejects a user who does not own the model', function () {
+    $model = AiModel::factory()->create();
+
+    $this->getJson(
+        "/api/v2/ai-model-token/{$model->uid}",
+        authHeader((string) Str::uuid())
+    )->assertStatus(403);
+});
+
+it('issues a token to the owner', function () {
+    $owner = (string) Str::uuid();
+    $model = AiModel::factory()->ownedBy($owner)->create(['name' => 'claude-opus-5']);
+
+    $response = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($owner))
+        ->assertOk()
+        ->assertJsonPath('data.uid', $model->uid)
+        ->assertJsonPath('data.name', 'claude-opus-5');
+
+    // 关键断言:签出的 token 代表「模型」而非发起请求的用户。
+    // 用它写句子时,editor_uid 才会记成模型 uid。
+    $jwt = JWT::decode(
+        $response->json('data.token'),
+        new Key(config('mint.app.jwt_secrets_key'), 'HS512')
+    );
+    expect($jwt->uid)->toBe($model->uid);
+    expect($jwt->exp)->toBeGreaterThan(time());
+});
+
+it('issues a model token that expires in 30 days', function () {
+    $owner = (string) Str::uuid();
+    $model = AiModel::factory()->ownedBy($owner)->create();
+
+    $response = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($owner))
+        ->assertOk();
+
+    $jwt = decodeToken($response->json('data.token'));
+
+    // 30 天,不是人类登录 token 的 365 天
+    expect($jwt->exp - time())->toBeLessThanOrEqual(60 * 60 * 24 * 30)
+        ->and($jwt->exp - time())->toBeGreaterThan(60 * 60 * 24 * 29);
+    expect($jwt->typ)->toBe('ai-model');
+    expect($jwt->ver)->toBe(1);
+});
+
+it('404s on an unknown model', function () {
+    $this->getJson(
+        '/api/v2/ai-model-token/'.Str::uuid(),
+        authHeader((string) Str::uuid())
+    )->assertStatus(404);
+});
+
+it('rejects an anonymous revoke', function () {
+    $model = AiModel::factory()->create();
+
+    $this->deleteJson("/api/v2/ai-model-token/{$model->uid}")
+        ->assertStatus(401);
+});
+
+it('rejects a revoke from a user who does not own the model', function () {
+    $model = AiModel::factory()->create();
+
+    $this->deleteJson(
+        "/api/v2/ai-model-token/{$model->uid}",
+        [],
+        authHeader((string) Str::uuid())
+    )->assertStatus(403);
+
+    expect(AiModel::where('uid', $model->uid)->value('token_version'))->toBe(1);
+});
+
+it('invalidates issued tokens when the owner revokes them', function () {
+    $owner = (string) Str::uuid();
+    $model = AiModel::factory()->ownedBy($owner)->create();
+
+    $token = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($owner))
+        ->assertOk()
+        ->json('data.token');
+
+    expect(currentUid($token))->toBe($model->uid);
+
+    $this->deleteJson("/api/v2/ai-model-token/{$model->uid}", [], authHeader($owner))
+        ->assertOk()
+        ->assertJsonPath('data.token_version', 2);
+
+    // 撤销后旧 token 立刻失效,尽管它的 exp 还在 30 天后
+    expect(currentUid($token))->toBeFalse();
+
+    // 重新签发的 token 带新版本号,可用
+    $fresh = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($owner))
+        ->json('data.token');
+    expect(decodeToken($fresh)->ver)->toBe(2);
+    expect(currentUid($fresh))->toBe($model->uid);
+});
+
+it('rejects a model token whose model has been deleted', function () {
+    $owner = (string) Str::uuid();
+    $model = AiModel::factory()->ownedBy($owner)->create();
+
+    $token = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($owner))
+        ->json('data.token');
+
+    AiModel::where('uid', $model->uid)->delete();
+
+    expect(currentUid($token))->toBeFalse();
+});
+
+it('rejects pre-versioning model tokens', function () {
+    // 引入 token_version 之前签出的模型 token:没有 typ/ver,id 恒为 0,无法撤销
+    $model = AiModel::factory()->create();
+    $legacy = JWT::encode([
+        'nbf' => time(),
+        'exp' => time() + 3600,
+        'uid' => $model->uid,
+        'id' => 0,
+    ], config('mint.app.jwt_secrets_key'), 'HS512');
+
+    expect(currentUid($legacy))->toBeFalse();
+});
+
+it('leaves human tokens alone', function () {
+    expect(currentUid(userToken('a-user-uid', 42)))->toBe('a-user-uid');
+});

+ 32 - 0
api-v13/tests/Feature/ChannelUserEditListTest.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+/**
+ * Skill 用 view=user-edit 列出「我能编辑的 channel」供用户交互式选择,
+ * 而不是要求用户手工贴 channel uid(设计决策 3)。
+ */
+it('lists the channels the user can edit', function () {
+    $me = makeStudio('me');
+    $mine = makeChannel($me, 'my channel');
+    makeChannel(makeStudio('someone-else'), 'not mine');
+
+    $rows = $this->getJson('/api/v2/channel?view=user-edit', authHeader($me))
+        ->assertOk()
+        ->json('data.rows');
+
+    $uids = array_column($rows, 'uid');
+
+    expect($uids)->toContain($mine);
+    expect($uids)->toHaveCount(1);
+    // 交互式选择要展示的字段
+    expect($rows[0])->toHaveKeys(['uid', 'name', 'lang']);
+    expect($rows[0]['name'])->toBe('my channel');
+});
+
+it('requires authentication', function () {
+    $this->getJson('/api/v2/channel?view=user-edit')
+        ->assertJsonPath('ok', false);
+});

+ 144 - 0
api-v13/tests/Feature/WriteAsAiModelTest.php

@@ -0,0 +1,144 @@
+<?php
+
+use App\Models\AiModel;
+use App\Models\Sentence;
+use Firebase\JWT\JWT;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+/**
+ * 端到端串起 wikipali-write Skill 的完整写入链路:
+ * 建模型 → 取 model token → 签 channel access token → 以模型身份写句子。
+ *
+ * 这条链路的意义全在最后一个断言上:句子的 editor_uid 必须是模型 uid,
+ * 而不是发起操作的人类用户,否则 AI 署名与审计就是假的。
+ */
+it('writes a sentence attributed to the ai model, not the human operator', function () {
+    $human = makeStudio('tester');
+    $channel = makeChannel($human);
+    $model = AiModel::factory()->ownedBy($human)->create(['name' => 'claude-opus-5']);
+
+    // 1. 人类身份签出 channel 的 access token
+    $accessToken = $this->postJson('/api/v2/access-token', [
+        'payload' => [[
+            'res_type' => 'channel',
+            'res_id' => $channel,
+            'power' => 'edit',
+            // book 必须是整数:UserCanEdit 用 !== 严格比较,"0" 会恒不等
+            'book' => 0,
+        ]],
+    ], authHeader($human))
+        ->assertOk()
+        ->assertJsonPath('data.count', 1)
+        ->json('data.rows.0.token');
+
+    // 2. 人类身份取模型的身份 token
+    $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
+        ->assertOk()
+        ->json('data.token');
+
+    // 3. 以「模型身份」写句子:Authorization 是 modelToken,句内带 accessToken
+    $this->postJson('/api/v2/sentence', [
+        'sentences' => [[
+            'book_id' => 1,
+            'paragraph' => 10,
+            'word_start' => 0,
+            'word_end' => 12,
+            'channel_uid' => $channel,
+            'content' => '这是 AI 写入的译文',
+            'content_type' => 'markdown',
+            'access_token' => $accessToken,
+        ]],
+    ], ['Authorization' => 'Bearer '.$modelToken])
+        ->assertOk()
+        ->assertJsonPath('data.count', 1);
+
+    $saved = Sentence::where('channel_uid', $channel)->first();
+
+    expect($saved)->not->toBeNull();
+    expect($saved->content)->toBe('这是 AI 写入的译文');
+    // 核心断言:署名归模型
+    expect($saved->editor_uid)->toBe($model->uid);
+    expect($saved->editor_uid)->not->toBe($human);
+});
+
+it('refuses the write when the access token is for a different channel', function () {
+    $human = makeStudio('tester');
+    $channel = makeChannel($human, 'mine');
+    $otherChannel = makeChannel(makeStudio('someone-else'), 'not mine');
+    $model = AiModel::factory()->ownedBy($human)->create();
+
+    $accessToken = $this->postJson('/api/v2/access-token', [
+        'payload' => [[
+            'res_type' => 'channel',
+            'res_id' => $channel,
+            'power' => 'edit',
+            'book' => 0,
+        ]],
+    ], authHeader($human))->json('data.rows.0.token');
+
+    $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
+        ->json('data.token');
+
+    // 拿 A channel 的 token 去写 B channel:逐句静默跳过,count 为 0
+    $this->postJson('/api/v2/sentence', [
+        'sentences' => [[
+            'book_id' => 1,
+            'paragraph' => 10,
+            'word_start' => 0,
+            'word_end' => 12,
+            'channel_uid' => $otherChannel,
+            'content' => 'should not land',
+            'access_token' => $accessToken,
+        ]],
+    ], ['Authorization' => 'Bearer '.$modelToken])
+        ->assertOk()
+        ->assertJsonPath('data.count', 0);
+
+    expect(Sentence::count())->toBe(0);
+});
+
+it('rejects an expired access token with 403 instead of 500', function () {
+    $human = makeStudio('tester');
+    $channel = makeChannel($human);
+    $model = AiModel::factory()->ownedBy($human)->create();
+
+    $accessToken = $this->postJson('/api/v2/access-token', [
+        'payload' => [[
+            'res_type' => 'channel',
+            'res_id' => $channel,
+            'power' => 'edit',
+            'book' => 0,
+        ]],
+    ], authHeader($human))->json('data.rows.0.token');
+
+    $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
+        ->json('data.token');
+
+    // 把时钟拨到 7 天有效期之后。
+    // 注意不能用 $this->travel():那只动 Carbon,而 JWT::decode 读的是 PHP 的 time(),
+    // 得改 JWT::$timestamp 这个专供测试的静态覆盖点。
+    // model token(365 天)在 +8 天时仍然有效,所以这里过期的只有 access token。
+    JWT::$timestamp = time() + 8 * 24 * 60 * 60;
+
+    try {
+        $this->postJson('/api/v2/sentence', [
+            'sentences' => [[
+                'book_id' => 1,
+                'paragraph' => 10,
+                'word_start' => 0,
+                'word_end' => 12,
+                'channel_uid' => $channel,
+                'content' => 'expired',
+                'access_token' => $accessToken,
+            ]],
+        ], ['Authorization' => 'Bearer '.$modelToken])
+            ->assertOk()
+            ->assertJsonPath('data.count', 0);
+    } finally {
+        JWT::$timestamp = null;
+    }
+
+    expect(Sentence::count())->toBe(0);
+});

+ 99 - 0
api-v13/tests/Pest.php

@@ -1,6 +1,13 @@
 <?php
 
+use App\Models\Channel;
+use App\Models\UserInfo;
+use App\Services\AuthService;
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;
 use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
 use Tests\TestCase;
 
 /*
@@ -48,3 +55,95 @@ function something()
 {
     // ..
 }
+
+/**
+ * 造一个用户 token。
+ *
+ * AuthService::current() 只解 JWT、不查库,所以测试里不必真的建用户;
+ * payload 结构必须与 AuthService::getUserToken() 保持一致。
+ */
+function userToken(string $userUid, int $userId = 1): string
+{
+    return JWT::encode([
+        'nbf' => time(),
+        'exp' => time() + 3600,
+        'uid' => $userUid,
+        'id' => $userId,
+    ], config('mint.app.jwt_secrets_key'), 'HS512');
+}
+
+/**
+ * 解开一个 token 的 payload。
+ */
+function decodeToken(string $token): object
+{
+    return JWT::decode($token, new Key(config('mint.app.jwt_secrets_key'), 'HS512'));
+}
+
+/**
+ * 把 token 交给 AuthService::current() 判定,返回 user_uid,无效则返回 false。
+ *
+ * 所有端点的鉴权都走这里,故用它来断言「token 是否还有效」。
+ *
+ * @return string|false
+ */
+function currentUid(string $token)
+{
+    $request = Request::create('/', 'GET');
+    $request->headers->set('Authorization', 'Bearer '.$token);
+
+    $user = AuthService::current($request);
+
+    return $user ? $user['user_uid'] : false;
+}
+
+/**
+ * 带用户 token 的请求头。
+ */
+function authHeader(string $userUid): array
+{
+    return ['Authorization' => 'Bearer '.userToken($userUid)];
+}
+
+/**
+ * 建一个用户及其个人 studio,返回 user uid。
+ *
+ * StudioApi::getIdByName() 查的是 user_infos.username,所以 studio 名即用户名。
+ */
+function makeStudio(string $username): string
+{
+    $userId = (string) Str::uuid();
+    (new UserInfo)->forceFill([
+        'userid' => $userId,
+        'username' => $username,
+        'nickname' => $username,
+        'password' => 'x',
+        'email' => $username.'@example.test',
+    ])->save();
+
+    return $userId;
+}
+
+/**
+ * 建一个属于指定用户的 channel,返回 channel uid。
+ *
+ * channels.id 不是自增列,必须显式给值。
+ */
+function makeChannel(string $ownerUid, string $name = 'test channel'): string
+{
+    $uid = (string) Str::uuid();
+    (new Channel)->forceFill([
+        'id' => random_int(1, PHP_INT_MAX),
+        'uid' => $uid,
+        'type' => 'translation',
+        'owner_uid' => $ownerUid,
+        'editor_id' => 0,
+        'name' => $name,
+        'lang' => 'zh-Hans',
+        'status' => 30,
+        'create_time' => time() * 1000,
+        'modify_time' => time() * 1000,
+    ])->save();
+
+    return $uid;
+}

+ 278 - 0
docs/wikipali-research-agent-design.md

@@ -0,0 +1,278 @@
+# WikiPali 研究型 Agent 设计文档
+
+> 目标:让 Claude 这类 agent 用 WikiPali 的语料完成巴利文献研究——检索、取证、引用,最终产出可信的论文级文本。
+>
+> 与写入型 skill(`docs/wikipali-write-skill-design.md`)同属 `wikipali` 插件,共用坐标系、channel 模型与凭据。
+>
+> 状态:需求已定(§1 来自用户的真实工作流),API 盘点完成(§2 均已实测)。**主检索链路无阻塞,可以开工**(§3 修订:原列的三个缺口有两个已证伪)。
+>
+> 日期:2026-08-06
+
+---
+
+## 1. 需求:一次真实的论文写作
+
+用户给的样本任务是《别住在律藏中的案例分析》,成文三部分:**定义与执行流程 → 案例分类列举 → 案例规律总结**。人工做法是 11 步:
+
+| # | 动作 | 本质 |
+|---|---|---|
+| 1 | LLM 给出「别住」的巴利拼写(可能多个:名词、动词),**用词典验证** | 词形确认 |
+| 2 | 全文检索,取前 50 条:标题 + 章节路径 + 巴利段落 | 定位 |
+| 3 | 结果按黑体字加权排序,义注的名词解释自然排前 | 排序语义 |
+| 4 | 据此写「定义与执行流程」 | 产出 |
+| 5 | 同一检索取前 200 条,**分析出处分布** | 分布统计 |
+| 6 | 提取这 200 条的段落内容 | 批量取证 |
+| 7 | 对结果密集的章节,取**整章巴利全文** | 上下文展开 |
+| 8 | 据 6、7 做案例分类 | 归纳 |
+| 9 | 写「案例分类列举」 | 产出 |
+| 10 | 查相关 channel(缅文逐词解析 nissaya、泰文译本等)**核对并补充引用** | 交叉验证 |
+| 11 | 写「案例规律总结」 | 产出 |
+
+这个序列有三个特征,决定了工具形态:
+
+- **漏斗型**:定位(宽)→ 取证(窄)→ 展开(深)。不是「取一堆数据交给模型」,而是逐步收窄。
+- **两次检索、两种用途**:第一次要**排序质量**(前 50 拿定义),第二次要**覆盖面**(前 200 看分布)。同一端点,不同参数。
+- **交叉验证是最后一步不是第一步**:先用巴利原文做出判断,再拿译本核对。工具不该在早期就把多语版本一股脑塞进上下文。
+
+---
+
+## 2. API 盘点(2026-08-06 逐个实测)
+
+基址 `{API}`,全部实测于 `https://www.wikipali.org/api`。**读端一律不需要凭据**——`PaliTextController` / `SearchController` / `SentencesInChapterController` 里 `AuthService::current` 出现 0 次,实测未登录直接返回数据。
+
+| 步骤 | 端点 | 状态 |
+|---|---|---|
+| 1 词形展开 | `GET /v2/case/{词}` | ✅ **主链路第一步**:猜 lemma + 列出全部实际词形 |
+| 1 释义验证 | `GET /v2/dict?word={词}&lang=zh` | ✅ 可用,附形态分析(形 → 根) |
+| 2/5 检索 | `GET /v2/search-pali-wbw?key={词形,词形,…}&bold=&limit=&offset=&book=` | ✅ **主链路第二步** |
+| 5 出处分布 | `GET /v2/search-pali-wbw-books?key={词形,…}` | ✅ 带 `paliTitle` 与 tags |
+| — 词组全文检索 | `GET /v2/search?view=pali&key=`、`/v2/search-book-list` | ❌ **500**,走 gRPC(§3.1,非阻塞) |
+| — 标题检索 | `GET /v2/search?view=title&key=` | ✅ 可用(纯 DB,不走 gRPC) |
+| 6 取段落 | `GET /v2/sentence?view=paragraph&book=&para=1,2,3&channels=` | ✅ 可用 |
+| 7 取整章 | `GET /v2/sentence?view=chapter&book=&para=&channels=` | ✅ 可用(实测 22 句) |
+| 7 目录导航 | `GET /v2/palitext?view=book-toc\|chapter\|children\|paragraph` | ✅ 可用 |
+| 10 找译本 | `GET /v2/channel?view=public`、`sentence?view=paragraph&lang=` | ⚠️ `lang=` 分支待验 |
+
+### 2.0 主检索链路(用户提供,2026-08-06 实测)
+
+**第一步:`GET /v2/case/{被搜索词}`** —— 输入可以是任意变格形,程序推测可能的词典原型,按可能性排序。
+
+```
+GET /v2/case/parivāsa  →  data: { rows: [ {word, count, case: [...] }, ... ], count }
+```
+
+取 `rows[0]`(可能性最高的 lemma),其 `case` 数组就是该词在语料中出现过的**全部实际词形**,每项带 `count` 与 `bold` 计数:
+
+```
+parivāsa (13 形): parivāsaṃ×221(黑7) · parivāso×170(黑5) · parivāse×22 · parivāsā×7 · parivāsesu×7 …
+```
+
+**第二步:`GET /v2/search-pali-wbw?key={把这些词形用逗号连起来}`**
+
+```
+count: 281 段落。rows 每项:
+{ book, paragraph, rank, path[章节路径,含 level], paliTitle, highlight }
+```
+
+实测细节:
+
+- `limit=200` 正常返回 200 行(步骤 5 取前 200 无碍;本例全库也就 281 段);
+- `view` 与 `type` 参数**实测无影响**,可省略(源码 `SearchPaliWbwController::index` 也没读它们);
+- `highlight` 用 `<span class='hl'>` 包命中词,并**保留原文的 `<span class="bld">`**——黑体信息在返回里可见;
+- `rank` = `sum(weight)`,`bold=on|off` 直接按 `style='bld'` 筛。**`bold=on` 让本例命中从 281 降到 13**;
+- 范围限定 `book=<id,id>` 或 `tags=<tag1,tag2;tag3>`(组间 OR、组内 AND)。
+
+**分布**:`GET /v2/search-pali-wbw-books?key={词形,…}` 返回 43 部书,每项带 `paliTitle` 和 **tags**:
+
+| 书 | 命中 | tags |
+|---|---|---|
+| (VN)Cūḷavaggapāḷi | 126 | vinaya, mūla, pāḷi, khandhaka, cūḷavagga |
+| Vinayālaṅkāra-ṭīkā | 40 | ṭīkā, vinaya |
+| (SP) Cūḷavagga-aṭṭhakathā | 28 | vinaya, aṭṭhakathā, samantapāsādikā |
+
+tags 里的 `mūla` / `aṭṭhakathā` / `ṭīkā` 让 agent 能直接区分**本文、义注、复注**——步骤 5 的「分析出处分布」和步骤 8 的分类都要靠它。
+
+**这条链路对步骤 3/4 有个更好的做法**:用户原方案是「取前 50,靠黑体加权让义注的名词解释排前面」。但既然有 `bold=on`,可以直接**只取黑体命中**(本例 13 条),那正是被注释书当作词条标出来的地方——比靠排序精准,而且省 90% 的上下文。
+
+### 2.1 词典 —— 可用,而且比预想的强
+
+`GET /v2/dict?word=parivāsaṃ&lang=zh` 返回的不只是释义,还有**形态分析**:
+
+```
+word: parivāsaṃ → parent: parivāsa,  type: .adj.,  grammar: .m.$.sg.$.acc.,  factors: parivāsa+[aṃ]
+                 → parent: parivāseti, type: .v.,   grammar: .1p.$.sg.$.aor.
+```
+
+即:**给一个变化形,能还原出词根与语法**。步骤 1 的「验证拼写」因此是可靠的。
+
+但注意方向:`dict` 做的是 **形 → 根**,用来确认「我选对了 lemma」。检索需要的 **根 → 全部形** 是 `case` 干的活(§2.0)。两者配合:`case` 给候选 lemma 和词形,`dict` 给释义和语法帮你确认选哪个候选。
+
+### 2.2 全文检索 —— 形状正好对得上,但服务不通
+
+`SearchController::index` 的 `view=pali` 分支走 `PaliSearch::pali_rpc()`,即 gRPC 调 `tulip` 服务(`config('mint.server.rpc.tulip.*')`)。返回经 `SearchResource` 整形为:
+
+```json
+{ "book": 93, "paragraph": 757, "rank": 0.87,
+  "highlight": "……~~parivāsaṃ~~……",   // 命中词用 ~~ 包围
+  "path": [...章节路径...], "paliTitle": "章节标题" }
+```
+
+**这正是步骤 2 要的三样东西**(标题、章节路径、巴利段落),客户端不用二次查询。几个契约细节:
+
+- `key` 用 `;` 分隔多词 = OR;
+- `match` = `case`(默认)/ `complete` / `similar`(去变音符号);
+- 范围限定用 `book=<单个 id>` 或 `tags=<tag1,tag2;tag3>`(tag 组间 OR、组内 AND);
+- 高亮标记是 `~~…~~`(`ts_headline` 的 `StartSel`),客户端要自己解析;
+- ⚠️ `orderby` 参数**只在废弃的 `pali()` 方法里生效**,`pali_rpc()` 不读它——排序由 tulip 决定;
+- ⚠️ `key` 以 `para` 开头或首字母是 `M/P/T/V/O` 会被劫持到**页码检索** `page()`。研究用词若撞上(如 `Para…`)会得到莫名其妙的结果。
+
+排序权重 `{0.1, 1, 0.3, 0.2}` 对应 tsvector 的 D/C/B/A 四档,配合索引里的 `bold1/bold2/bold3` 字段——**这就是「黑体字加权」**,用户步骤 3 依赖的行为在服务端是坐实的。
+
+### 2.3 逐词检索 —— 另一条路,语义不同
+
+`search-pali-wbw` 走 `wbw_templates` 表:`WHERE real IN (逗号分隔词表) GROUP BY book,paragraph ORDER BY sum(weight)`,并支持 `bold=on|off` 直接筛黑体(`style='bld'`)。
+
+它与全文检索是**两套东西**:前者匹配逐词解析的词形,后者匹配段落全文。dashboard 的做法是:关键词含空格 → 全文;单词 → 逐词。
+
+### 2.4 取原文与译文 —— 同一个端点
+
+关键结构事实:**巴利原文本身就是一个 channel**(`_System_Pali_VRI_`,uid `00b577c0-13b9-11ee-a05a-b7307efd9ee6`,`type=original`、`lang=pali`)。`SearchResource` 在没有 highlight 时也是去这个 channel 拼段落文本。
+
+于是「取原文」「取汉译」「取缅文 nissaya」是**同一个调用换 channel**:
+
+```
+GET /v2/sentence?view=chapter&book=93&para=757&channels=<uid[,uid...]>
+GET /v2/sentence?view=paragraph&book=93&para=757,758&channels=<uid>
+```
+
+读端与写端共用同一套坐标 `(book, paragraph, word_start, word_end, channel_uid)`。这意味着 agent 检索到的任何位置,都能直接对应到写入型 skill 能写的位置——**检索与产出天然闭环**。
+
+现成的交叉验证资源(`status=30` 公开):
+
+| channel | lang | type |
+|---|---|---|
+| `Nissaya` / `nissaya` | my | translation / nissaya |
+| `nissaya in En` | en | nissaya |
+| `Norbu AI Translations (Nissaya)` | en-US | translation |
+
+---
+
+## 3. 缺口(2026-08-06 修订:原先列的三个,两个已被证伪)
+
+主检索链路(`case` → `search-pali-wbw`)**完全可用,www 与 next 都有**。下面第一条降级为非阻塞,第二条不成立。
+
+### 3.1 词组全文检索返回 500 —— 非阻塞,但确实坏了
+
+实测(www 与 next 均如此):
+
+```
+GET /v2/search?view=pali&key=parivāsa&limit=3           → HTTP 500
+GET /v2/search?view=pali&key=parivāsa dātabbo&limit=2   → HTTP 500
+GET /v2/search-book-list?view=pali&key=parivāsa         → HTTP 500
+GET /v2/search?view=title&key=parivasa                  → HTTP 200 ✅
+```
+
+只有走 gRPC(`PaliSearch` → `tulip` 服务)的分支挂,纯 DB 的分支正常 → 指向 tulip 不可达或 PHP 的 grpc 扩展缺失。
+
+**为什么不阻塞**:dashboard 只在关键词**含空格**(词组)时才走这条路,单词走 `search-pali-wbw`。研究流程的主链路是后者。所以坏的是「词组/短语检索」这一项能力。
+
+**但它确实是坏的**,且影响真实用户。修法有二:修 tulip 服务;或接上代码里已有的 `SearchController::pali()`——同样逻辑的纯 SQL 版(直接查 `fts_texts` 表 + `ts_rank`),目前没有路由指向它,加一条路由或让 `pali_rpc` 在 gRPC 失败时回落即可。
+
+### 3.2 ~~缺「词根 → 全部词形」的展开~~ —— 已证伪
+
+原判断错在:我只看到 `dict` 能做「形 → 根」,没找到反向的端点,于是以为 agent 会用词典形检索而静默漏掉材料。
+
+实际上 **`GET /v2/case/{词}` 就是反向展开**:给任意形,返回候选 lemma 及每个 lemma 在语料中的全部实际词形(带 count 与 bold 计数)。这条链路是平台既有的,不需要任何服务端改动。
+
+保留这段记录是因为**结论虽错,风险是真的**:按词典形 `parivāsa` 直接查 `search-pali-wbw` 确实返回 0 条且不报错。所以 skill 规程必须写死「**检索前一律先过 `case` 展开词形**,不得直接拿词典形去搜」——否则 agent 会以为自己搜过了。
+
+### 3.3 泰文语料未上传
+
+已确认。公开 channel 里泰文只有 1 个、97 句;实际语料是缅文 68.7 万句 > 中文 20.6 万 > 英文 8.2 万。
+
+对设计的影响:**工具必须能诚实回答「该段落在该语言下没有译文」**,而不是返回空数组让 agent 自己脑补。步骤 10 的「泰文译本(如果有)」在语料到位前应明确报「无」。
+
+---
+
+## 3.4 待办:引用格式规范
+
+当前 `research` skill 用的是临时格式(用户 2026-08-06 同意暂用):
+
+```
+Cūḷavaggapāḷi, Pārivāsikakkhandhaka (VN 216:35)
+Samantapāsādikā, Pārivāsikavattakathā (SP-aṭṭ 141:63)
+```
+
+**⬜ TODO:用户之后会给出正式的引用格式规范**,届时改 `skills/research/SKILL.md` 的「引用格式」一节。这关系到产出能否被同行接受,属于必改项,不是可选优化。
+
+相关线索:库里有 `page_numbers` 表,`type` 分 `M/P/T/V/O`(缅甸版/PTS 等不同版本的页码),正式规范多半要用到其中某一种;`GET /v2/search?view=page&key=<卷.页>&type=<版本>` 是按页码反查段落的现成端点。
+
+## 3.5 待办:channel 的译文来源标识
+
+引用译文必须能区分人译与机译,但**现有数据两个信号都不可靠**(2026-08-06 实测):
+
+| channel | 句子数 | `editor_uid` 命中 `ai_models` |
+|---|---|---|
+| AI-汉译-Nissaya | 11206 | 11205(模型 `[文本生成]-阿里-deepseek-v3`)|
+| Nissaya的AI翻译 | 967 | 0 |
+| Norbu AI Translations (Nissaya) | 191 | 0 |
+
+后两者是**人工用自己账号上传的机器译文**,`editor_uid` 是人类;只有名字里的 "AI" 泄露了来源。反过来,只靠名字也会漏掉命名里不含 AI 的机器译本。
+
+短期:skill 规程用「两个信号任一命中即按机器译文标注,都不命中时不主动断言是人译」。
+
+**⬜ TODO(服务端)**:给 `channels` 加一个来源字段(如 `provenance`: `human` / `machine` / `mixed`),让判定有据。注意写入型 skill 产生的数据天然带模型署名(`editor_uid` = 模型 uid),所以这个问题只存在于存量数据。
+
+---
+
+## 4. 设计要点(端点清单看不出来的那些)
+
+1. **引用可信度是第一约束**。论文场景下编造引文是致命错误。因此:任何返回给 agent 的文本片段,都必须携带可验证坐标(`book/paragraph` + channel + 章节路径),且 skill 规程要写死「没有坐标的内容不得写入论文」。这条决定所有命令的返回格式,事后改是全面返工。
+2. **上下文预算是第二约束**。一部经几十万 token。命令粒度必须支持漏斗:`search`(只回坐标+摘要)→ `get`(按坐标取指定段落)→ `chapter`(展开整章,需显式请求且要报告体量)。**不提供「取整部书」这种命令。**
+3. **channel 即译本/版本**,与写入端共用。「查某语言的译文」= 「查某 channel 在某坐标的句子」。不要为读端发明第二套概念。
+4. **空结果必须显式**。区分「该位置没有该语言的译文」与「查询出错」,两者对 agent 的下一步完全不同。
+5. **两种检索要都暴露**,并说明差别:全文(词组、黑体加权、义注优先)与逐词(确切词形、可筛黑体)。让 agent 知道什么时候用哪个,比藏起来自动选更可靠。
+
+---
+
+## 5. 命令面草案
+
+沿用 `wikipali` 插件既有结构,读端加一个 skill:
+
+```
+plugins/wikipali/
+├── bin/wikipali              # 共享入口(写端也迁过来)
+├── skills/
+│   ├── write/                # 现有
+│   └── research/             # 新增:检索、取证、引用规程
+```
+
+子命令(对应 §1 的 11 步):
+
+| 命令 | 对应步骤 | 说明 |
+|---|---|---|
+| `forms <词>` | 1 | `case` 展开:候选 lemma + 全部实际词形(带 count/bold)。**检索的必经前置** |
+| `word <词>` | 1 | `dict` 释义 + 形态分析(词根、词性、语法),用于确认选对了 lemma |
+| `search <词形…>` | 2、5 | `search-pali-wbw`;`--bold` 只取黑体(定义)、`--book`/`--tags` 限范围 |
+| `dist <词形…>` | 5 | 出处分布,带 tags(`mūla`/`aṭṭhakathā`/`ṭīkā`)便于区分本文与注疏 |
+| `get <坐标…>` | 6 | 按坐标批量取文,可指定 channel |
+| `chapter <book> <para>` | 7 | 展开整章,先报体量再取 |
+| `versions <坐标>` | 10 | 该坐标有哪些语言/译本,明确列出「没有的」 |
+
+一个便利设计:`forms` 的输出可以直接管道进 `search`,或者让 `search` 接受 `--lemma parivāsa` 自动先跑一遍 `case` 再检索。**但不要把展开做成隐式的**——agent 应当看见「我把这 13 个词形搜了」,那是论文方法论的一部分,要能写进正文。
+
+`research` skill 的规程重点不在于怎么调这些命令,而在于**怎么把结果变成可信引用**,以及**什么时候该收窄、什么时候该展开**。
+
+---
+
+## 6. 分阶段
+
+| 阶段 | 内容 | 依赖 |
+|---|---|---|
+| R1 | `forms` / `word` / `search` / `dist` / `get` 五个命令 + `research` skill 规程 | 无(主链路已可用) |
+| R2 | `chapter` / `versions` | R1 |
+| R2 | 词组检索的 500(§3.1):修 tulip 或接 `pali()` 降级路径 | 你定 |
+| R3 | 用《别住在律藏中的案例分析》做**验收**:agent 独立跑完 11 步,人工核对每条引用的坐标真实性 | R2 |
+| R4 | 视情况把读端改造为 MCP tools(检索链式调用更适合 tool 形态),skill 保留规程部分 | R3 |
+
+R3 是这个项目真正的验收标准:**不是「命令都能调通」,而是「产出的论文里每一条引用都能回溯到真实坐标」**。

+ 643 - 0
docs/wikipali-write-skill-design.md

@@ -0,0 +1,643 @@
+# WikiPali 写入型 Skill 设计文档
+
+> 目标:提供一个 Claude Code Skill,通过 Claude Code 以「AI 模型身份」把句子写入 WikiPali 数据库(`SentenceController`),并保持正确的作者署名与权限边界。
+>
+> 分发路径:在本仓库开发调试,成熟后以**整目录复制**方式装到其他项目(§6.7)。不做独立仓库——API 仍需频繁修改,Skill 契约必须与 `api-v13` 同仓演进。
+>
+> 状态:设计已定案;服务端 P0 已完成(§5.1 端点 + §5.2 abdefg);Skill P1 已完成(`plugins/wikipali/`)并在开发机上端到端跑通(2026-08-05);线上四站尚未部署
+> 对应后端:`api-v13`(Laravel 13,路由前缀 `/api/v2`)
+> 决策定案:2026-08-04(见 §9)
+
+---
+
+## 1. 背景与目标
+
+现状下,只有仓库内部的组件(`ai-translate` Python worker、`app/Console/Commands/*`、`app/Services/AIAssistant/*`)能以 AI 身份写入句子库,因为它们能直接调用 `AuthService::getUserToken()` 生成模型身份 token。外部项目无此能力。
+
+本 Skill 要达成:
+
+1. 外部项目只需安装该 Skill,即可获得对 WikiPali 数据库的写入能力;
+2. 写入的句子 `editor_uid` 为 **AI 模型的 uid**(而非操作者本人),保证署名与审计正确;
+3. 权限不被放大:AI 模型只能写入「操作者本人有编辑权的 channel」,且受 `access_token` 中的 book 范围约束;
+4. 凭据管理安全、可复用,不污染用户项目仓库。
+
+### 非目标
+
+- 不提供绕过 channel 权限的写入路径;
+- 不在本期实现 wbw / sentpr / attachment 等其他资源的写入(见 §9 后续规划)。
+
+---
+
+## 2. 现有 API 盘点
+
+以下均已对照源码核实。基址记为 `{API}`,形如 `https://host/api`(参见 `ai-translate/config.orig.toml` 的 `api-url`)。**`{API}` 不是唯一的**:线上四个地址共享同一套数据,差别只在地区(`.org`/`.cc`)与代码版本(`www` 稳定 / `next` 最新)——见 §6.1.2。下文契约以稳定版为准。
+
+### 2.1 登录 —— 可用 ✅
+
+`POST {API}/v2/sign-in`
+
+```json
+{ "username": "<用户名或邮箱>", "password": "<明文密码>" }
+```
+
+返回 `{ "ok": true, "data": "<JWT 字符串>", "message": "" }`。
+
+- 实现:`AuthController::signIn()`(`app/Http/Controllers/AuthController.php:70`)
+- JWT payload:`{nbf, exp, uid: userid, id: 主键 id}`,**有效期 365 天**(`AuthController.php:84`)
+- 校验入口:`AuthService::current()`(`app/Services/AuthService.php:43`),读取 `Authorization: Bearer <token>`
+
+`GET {API}/v2/auth/current`(Bearer)→ `data: {id, nickName, realName, avatar, token, roles}`。
+其中 `realName` 即 `user_info.username`,**后续 `studio_name` 参数要用它**(`AuthController.php:101`)。
+
+### 2.2 查询 / 创建 AI Model —— 部分可用 ⚠️
+
+`GET {API}/v2/ai-model?view=studio&name={studioName}&keyword={modelName}`(Bearer)
+
+- 实现:`AiModelController::index()`(`app/Http/Controllers/AiModelController.php:22`)
+- `view` 仅支持 `all` / `studio` / `usable` / `chat`;`keyword` 是 `like %kw%` **模糊**匹配,客户端需自行做精确 `name` 比对
+- ⚠️ 缺陷:`view` 传入非法值时 `$table` 未定义 → 500(`AiModelController.php:29-45`)
+
+`POST {API}/v2/ai-model`(Bearer)body `{name, studio_name, model?, url?, key?, privacy?, description?, system_prompt?}`
+
+- 实现:`AiModelController::store()`
+- ✅ 已接受全部字段;`privacy` 缺省为 `private`
+- ✅ 同一 studio 内 `name` 重复返回 **409**(客户端据此判定「已存在」)
+- `name` 必填、`studio_name` 必填,违反则 **422**(`StoreAiModelRequest`)
+- 鉴权:`canEdit($user_uid, $studioId)` 要求 `user_uid === studioId`,即**只有个人 studio 可用,group studio 会 403**
+
+`PUT {API}/v2/ai-model/{uid}`(Bearer)
+
+- 路由模型绑定按 `uid`(`AiModel::$primaryKey = 'uid'`)
+- ✅ **增量更新**:只改请求里出现的字段,未提交的保持原值,客户端可以只发一两个字段
+- 显式传 `null` 仍可清空字段(判定用 `has()` 而非 `filled()`)
+- 改名撞上同 studio 内已有名字 → **409**
+
+### 2.3 获取 AI Model 的 user token —— 可用 ✅(本次新增)
+
+`GET {API}/v2/ai-model-token/{uid}`(Bearer = **用户 token**)→ `data: { uid, name, token }`
+
+- 实现:`AiModelTokenController::show()`,见 §5.1
+- 鉴权:仅模型 owner 本人(`canEdit`),否则 403;未登录 401
+- 底层是 `AuthService::getUserToken()`(`app/Services/AuthService.php`):先查 `ai_models.uid`,命中即签模型 token,否则按人类用户签发
+- **有效期 30 天**(人类登录 token 仍是 365 天),payload 带 `typ: "ai-model"` 与 `ver`(版本号)
+- ⚠️ 仍属最高敏感凭据,但已**可撤销**,见 §2.3b
+
+### 2.3b 撤销 AI Model 的全部 token —— 可用 ✅(本次新增)
+
+`DELETE {API}/v2/ai-model-token/{uid}`(Bearer = **用户 token**)→ `data: { uid, name, token_version }`
+
+- 实现:`AiModelTokenController::destroy()`,见 §5.1
+- 鉴权同 `show`:仅 owner 本人,否则 403;未登录 401
+- 语义是「作废该模型已签出的**所有** token」,不能只废其中一张——凭据泄漏时本就该全废
+- 客户端处理:撤销后旧凭据请求一律 401,Skill 应提示重新 `ensure-model` 取 token
+
+### 2.4 签发 channel access token —— 可用 ✅
+
+`POST {API}/v2/access-token`(Bearer = **用户 token**)
+
+```json
+{ "payload": [ { "res_type": "channel", "res_id": "<channel uid>", "power": "edit", "book": 0 } ] }
+```
+
+返回 `data: { rows: [ { payload, token } ], count }`。
+
+- 实现:`AccessTokenController::store()`(`app/Http/Controllers/AccessTokenController.php:33`)
+- 鉴权:`ChannelApi::userCanEdit(user_uid, res_id)`,无权则该条被静默跳过(`continue 2`)→ **rows 可能为空数组,客户端必须判空**
+- 签名密钥:`AccessToken.token`(uuid)**重复两次拼接**,算法 HS512
+- ✅ payload 现在带 `nbf` / `exp`,**有效期 7 天**(`AccessTokenController::TOKEN_TTL`)。返回的 `payload` 里含 `exp`,客户端可据此判断何时需要重签
+- 过期后写句子会得到 403(而非 500)
+
+### 2.5 写入句子 —— 可用 ✅
+
+`POST {API}/v2/sentence`(Bearer = **AI model token**)
+
+```json
+{
+  "sentences": [
+    {
+      "book_id": 1,
+      "paragraph": 10,
+      "word_start": 0,
+      "word_end": 12,
+      "channel_uid": "<channel uid>",
+      "content": "译文",
+      "content_type": "markdown",
+      "access_token": "<§2.4 签出的 JWT>"
+    }
+  ]
+}
+```
+
+返回 `data: { rows: [SentResource...], count }`。
+
+- 实现:`SentenceController::store()`(`app/Http/Controllers/SentenceController.php:303`)
+- 权限判定 `UserCanEdit()`(`SentenceController.php:272`):
+  1. bearer 身份是 channel owner → 放行;
+  2. 否则查协作权限 `ShareApi::getResPower(...) >= 20` → 放行;
+  3. 否则用 `AccessToken.token` 重复两次作为密钥验签 `access_token`,并校验 book 范围。
+- 语义:按 `(book_id, paragraph, word_start, word_end, channel_uid)` 做 `firstOrNew`,**存在即更新,不存在则新建**(天然幂等)
+- 副作用:写入 `sent_histories`、清 Redis 缓存、`Mq::publish('progress', ...)`
+- 另一种调用形态:把 `channel` / `book` / `access_token` 放在顶层,句子数组内不再重复(`SentenceController.php:315-326`)
+- 现成参考实现:`ai-translate/ai_translate/service.py:429`
+
+**⚠️ book 字段类型陷阱**:校验用严格比较
+`if (isset($jwt->book) && $jwt->book !== 0 && $jwt->book !== $book)`,而 `$book` 已被 `(int)` 转换。
+因此签发 access token 时 `book` **必须是整数**(`0` 表示不限 book);写成 `"1"` 字符串会导致 `"1" !== 1` 恒真而被拒绝。
+
+---
+
+## 3. 端到端流程
+
+```
+用户                Skill 脚本                     API
+ |                     |                            |
+ |-- 交互式输入口令 --->|                            |
+ |                     |-- POST /v2/sign-in ------->|
+ |                     |<-- userToken (365d) -------|
+ |                     |-- GET /v2/auth/current --->|   取 realName 作为 studio_name
+ |                     |                            |
+ |                     |-- GET /v2/ai-model?view=studio&name=&keyword= -->
+ |                     |<-- rows(精确匹配 name)---|
+ |                     |   未命中 → POST /v2/ai-model  → PUT /v2/ai-model/{uid}
+ |                     |                            |
+ |                     |-- GET /v2/ai-model-token/{uid} ★新增 -->
+ |                     |<-- modelToken (30d, 可撤销) |
+ |-- 提供 channel_id ->|                            |
+ |                     |-- POST /v2/access-token(Bearer=userToken)-->
+ |                     |<-- accessToken ------------|
+ |                     |                            |
+ |                     |-- POST /v2/sentence(Bearer=modelToken,句内带 accessToken)-->
+ |                     |<-- {rows, count} ----------|
+```
+
+三种 token 的职责必须区分清楚:
+
+| Token | 签发者 | 作用 | 用在哪 |
+|---|---|---|---|
+| userToken | `sign-in` | 代表**人类操作者** | 查/建 ai-model、签 access token |
+| modelToken | 新增端点 | 代表 **AI 模型身份** | 写句子时的 `Authorization` |
+| accessToken | `access-token` | **委托** channel 编辑权给持有者 | 写句子时的 body 字段 |
+
+---
+
+## 4. 可行性结论
+
+**可行。** 五个步骤中四步已有现成 API,剩余一步需新增约 15 行后端代码。
+
+必须做的服务端改动只有 §5.1 一项;其余为质量/安全修补,建议一并处理,因为 Skill 会高频调用这些接口,缺陷会被放大。
+
+### 备选方案(若不想改后端)
+
+用 **userToken 直接写句子**,跳过 model token。代价:
+- `editor_uid` 变成人类用户,**丧失 AI 署名与审计能力**——这与本设计的核心目的冲突;
+- 若操作者是 channel owner,连 access_token 都不需要,流程退化为两步。
+
+可作为 Skill 的降级路径(`--as-self`),但不应是默认行为。
+
+---
+
+## 5. 需要的服务端改动
+
+### 5.1 新增:获取 AI Model 的 user token(P0,阻塞)—— ✅ 已实施
+
+`GET {API}/v2/ai-model-token/{uid}`(Bearer = 用户 token)
+
+路由(`routes/api.php` v2 组内,紧跟 `ai-model` 的 apiResource):
+
+```php
+Route::get('ai-model-token/{ai_model}', [AiModelTokenController::class, 'show']);
+```
+
+独立控制器 `AiModelTokenController::show()`,而非挂在 `AiModelController` 上——签发身份凭据与模型的 CRUD 是两件事,分开后前者的鉴权与日志不会被 CRUD 的改动波及。路由参数名 `{ai_model}` 与 apiResource 生成的一致,隐式模型绑定按 `AiModel::$primaryKey = 'uid'` 解析。
+
+```php
+public function show(Request $request, AiModel $aiModel): JsonResponse
+{
+    $user = AuthService::current($request);
+    if (! $user) {
+        return $this->error(__('auth.failed'), null, 401);
+    }
+    if (! AiModelController::canEdit($user['user_uid'], $aiModel->owner_id)) {
+        return $this->error(__('auth.failed'), null, 403);
+    }
+
+    $token = AuthService::getUserToken($aiModel->uid);
+    if (! $token) {
+        return $this->error('ai model not found', null, 404);
+    }
+
+    OpsLog::debug($user['user_uid'], [
+        'action' => 'ai-model-token.issue',
+        'model_uid' => $aiModel->uid,
+        'model_name' => $aiModel->name,
+    ]);
+
+    return $this->ok([
+        'uid' => $aiModel->uid,
+        'name' => $aiModel->name,
+        'token' => $token,
+    ]);
+}
+```
+
+返回 `data: { uid, name, token }`。
+
+注意错误响应用的是 `$this->error($msg, null, $status)`。仓库里多数旧代码写成 `$this->error(__('auth.failed'), 401, 401)`,把状态码误当成了 `$data` 参数(`Controller::error(string $message, mixed $data, int $status)`),响应体里因此多一个 `"data": 401`。新代码不沿用这个写法。
+
+要点:
+- 鉴权用 `AiModelController::canEdit()`(仅 owner 本人)。**依据 §9 决策 1:模型记录只挂个人 studio**,不支持 group studio,因此无需 `StudioApi::userCanManage()`。与 `show/update/destroy` 口径一致;
+- 该 token 是模型身份凭据,签发与撤销都**记入 ops 日志**(`App\Tools\OpsLog`,action 为 `ai-model-token.issue` / `ai-model-token.revoke`);
+- **有效期 30 天**:`AuthService::AI_MODEL_TOKEN_TTL`。人类登录 token 的 365 天不变(`USER_TOKEN_TTL`),两者分开是因为模型 token 要落到外部客户端的凭据文件里,泄漏面大得多;
+- **撤销机制**(推翻 §9 决策 2):`ai_models.token_version` 自增即作废该模型全部已签出 token。
+  - 签发时 payload 带 `typ: "ai-model"` + `ver`;
+  - `AuthService::current()` 只对带 `typ` 的 token 查一次 `token_version` 比对,人类 token 不额外查库;
+  - 迁移 `2026_08_05_110345_add_token_version_to_ai_models_table`,默认值 1。
+
+**兼容性**:引入版本号之前签出的模型 token(无 `typ`/`ver`,payload 里 `id` 恒为 0)一律失效。名义上是破坏性变更,实际破坏面为零——本轮改动尚未部署,线上没有任何存量模型凭据;仓库内部的 `ai-translate`、`app/Console/Commands/*`、`AiTaskPrepare` 都是每次任务现签现用。人类 token 的 `id` 是 `user_infos.id`(≥1),不受影响。
+
+### 5.2 修补(P1,强烈建议)
+
+| # | 状态 | 位置 | 问题 | 建议 |
+|---|---|---|---|---|
+| a | ✅ 已修 | `AiModelController::show()` (`:103`) | **完全没有鉴权**,任何人可读任意模型 | 已加 `AuthService::current` + `canEdit` 判定(见下方遗留项) |
+| b | ✅ 已修 | `AiModelResource::toArray()` | `parent::toArray()` 把 `key`(第三方 API key)原样返回 | 改为字段白名单;`key` / `system_prompt` 仅 owner 请求时附带 |
+| c | ⬜ 待修 | `AiModelController::index()` | 非法 `view` → `$table` 未定义 → 500 | `default:` 分支返回 400 |
+| d | ✅ 已修 | `AiModelController::store()` | 不接受完整字段;无重名校验 | 已接受 `model/url/key/privacy/description`;同 studio 内重名返回 409 |
+| e | ✅ 已修 | `AiModelController::update()` | 未提供字段被置 null | 改为按 `$request->has()` 增量更新 |
+| f | ✅ 已修 | `AccessTokenController::store()` | 签出的 token **永不过期** | payload 注入 `exp`(7 天);`UserCanEdit` 捕获解码异常 |
+| g | ✅ 已修 | `AiModelController` 的 `Store/UpdateAiModelRequest` | `rules()` 为空,无任何校验 | 已补 `name` 必填、`privacy` 枚举、各字段长度上限 |
+
+(g) 本属 P2,但 (d) 的重名校验依赖 `name` 必填,只好一并做掉。剩下的 (c) 与 Skill 流程无关,留在 P2。
+
+#### (b) 的实施要点
+
+不能简单删掉 `key` / `system_prompt`:dashboard 的模型编辑页(`AiModelEdit.tsx`)靠 `GET /v2/ai-model/{uid}` 回填这两个字段,删了会导致用户一保存就把 key 清空。故按请求者是否 owner 分别返回。
+
+真正的泄漏面其实比 (a) 大得多:`index()` 的 `view=all` / `view=usable` 分支对**任何登录用户**返回全部模型记录,key 就在里面——(a) 只堵住了 `show` 一个口子。
+
+`isRequestedByOwner()` 的结果按请求缓存在 `$request->attributes` 上:`index()` 一次最多返回 1000 行,逐行解一次 JWT 不可接受。
+
+#### (f) 的连带改动
+
+给 access token 加上 `exp` 之后,`SentenceController::UserCanEdit()` 里的 `JWT::decode()` 会在 token 过期时抛 `ExpiredException`。原代码没有 try/catch,**过期 token 会变成 500 而不是 403**。已补捕获,并顺带处理了 `AccessToken` 查不到记录时 `new Key(null)` 抛异常的情况。
+
+#### (a) 的遗留项
+
+当前实现:
+
+```php
+public function show(Request $request, AiModel $aiModel)
+{
+    $user = AuthService::current($request);
+    if (! $user) {
+        return $this->error(__('auth.failed'), 401, 401);
+    }
+    if (! self::canEdit($user['user_uid'], $aiModel->owner_id)) {
+        return $this->error(__('auth.failed'), 403, 403);
+    }
+
+    return $this->ok(new AiModelResource($aiModel));
+}
+```
+
+1. ~~**签名缺 `$request`**~~——已修复:初版方法体用了 `$request` 但参数列表没有它,该端点必然 500;现已补上 `Request $request`(路由模型绑定不受影响,Laravel 按类型而非位置注入)。
+
+2. ~~**鉴权口径**~~——已定:§9 决策 1 选个人 studio,`canEdit()` 就是正确口径,与 `token()` / `update()` / `destroy()` 一致,无需改。副作用是 `privacy = public` 的模型对非 owner 也不可读;这与 `index()` 的 `view=usable`(返回 public 模型)不一致,但由于 (b) 落地后 `index` 不再泄漏敏感字段,且 Skill 只读自己的模型,暂不处理。
+
+3. **对 Skill 的影响**——§6.3 第 3 步 `POST` 之后如需回读,以及任何走 `GET /v2/ai-model/{uid}` 的路径,现在都必须带 userToken;沿用 §6.3 的 `view=studio` 列表比对方式则不受影响。
+
+---
+
+## 6. Skill 设计
+
+### 6.1 目录结构
+
+**开发地点:本仓库。分发方式:Claude Code 插件(marketplace)。**(§9 决策 4、决策 7)
+
+理由:API 尚不完善,Skill 与服务端要同步改(§5 的每一项都会反映到 `references/api.md`)。放在 mint 仓库内,一次提交就能同时改 Laravel 代码和 Skill 契约;独立仓库会让两者版本漂移,且改 API 时无法在同一个 Claude Code 会话里读写后端代码。
+
+放在仓库根的 `plugins/` 下,本身就是一个合法插件:
+
+```
+plugins/wikipali/
+├── .claude-plugin/
+│   └── plugin.json       # 插件清单,version 是唯一的版本来源
+├── README.md             # 面向安装者:装之前它会动你哪些东西
+├── install.sh            # 不走 marketplace 时的后路
+└── skills/
+    └── write/            # → 调用名 wikipali-write:write
+        ├── SKILL.md      # 触发条件 + 流程说明(给模型读)
+        ├── references/
+        │   └── api.md    # 本文 §2 的精简版:端点、字段、陷阱
+        └── scripts/
+            ├── wp_login.py   # 交互式登录,仅此脚本接触密码
+            └── wp.py         # 客户端:endpoint / whoami / ensure-model /
+                              #         revoke / channels / grant / write
+```
+
+实现时比原计划多了两个子命令:`whoami`(一屏看清当前站点、三种 token 及其到期时间——排查「为什么 401」的第一步)与 `revoke`(§2.3b 的撤销端点,安全能力做了就该有入口)。`wp_login.py` 通过 `import wp` 复用 HTTP 与凭据代码,两个文件仍在同一目录内,不违反自包含约束。
+
+几个布局上的决定:
+
+- **用 `skills/write/` 而不是把 SKILL.md 放插件根**。后者也合法(单 skill 插件允许),但调用名会变成 `wikipali-write:wikipali-write`;而且 §9 后续规划里还有读取和 sentpr 两个 skill,`skills/` 布局才能容纳。
+- **`VERSION` 文件已删**。版本号只留 `plugin.json` 的 `version` 一处,两处必然漂移;`install.sh` 改为从 manifest 读。
+- **仓库根留一个 symlink** `.claude/skills/wikipali-write → ../../plugins/wikipali/skills/write`,这样在 mint 里开发时(无论从哪个子目录启动 Claude Code)skill 仍然自动加载。实测普通 skill 的向上查找会跟随 symlink;插件形态则用 `--plugin-dir ./plugins/wikipali` 测。
+- 放在**仓库根**而非 `api-v13/` 下:后者已有 `laravel-best-practices` 等目录级 skill,只在编辑 `api-v13/` 时激活;而本 Skill 是对线上 API 的客户端操作,与当前编辑哪个子目录无关。
+
+### 6.1.1 可分发性约束
+
+「能复制给别的项目用」是硬需求,因此以下几条是**约束而非偏好**:
+
+1. **目录自包含**——不引用 `plugins/wikipali/` 之外的任何路径。SKILL.md 里不能出现 `api-v13/...` 这类仓库内引用;需要的 API 事实全部落在 `references/api.md` 里。
+2. **零安装依赖,只用 Python 标准库**——用 `urllib.request` 而非 `requests`,`json` / `getpass` / `argparse` 均为内置。**不跟随 `ai-translate` 的 venv + `pip install -e` 模式**(`ai-translate/pyproject.toml` 依赖 `pika`/`requests`/`redis`/`openai`):那套在目标项目里要求用户先建虚拟环境,与「复制即用」冲突。代价是要自己处理 `urllib` 的 HTTPError/超时/JSON 编码,比 `requests` 啰嗦,但换来 `python3 scripts/wp.py` 开箱可跑。
+3. **API 地址不硬编码**——见 §6.1.2。复制到别的项目后无需改代码。
+4. **凭据与 Skill 解耦**——凭据在 `~/.wikipali/`(§6.2),多个项目里的 Skill 副本共用同一份登录态,登录一次即可。
+
+依然选 Python(而非 shell)是为了与 `ai-translate` 的请求语义保持一致,便于对照排查。
+
+### 6.1.2 多站点:四个地址,一套数据
+
+线上四个站点**共享同一个数据库和同一把 `jwt_secrets_key`**,区别只有两个维度(2026-08-05 用户确认):
+
+| api_url | 域名 | 代码版本 |
+|---|---|---|
+| `https://www.wikipali.org/api` | .org | 稳定版 |
+| `https://www.wikipali.cc/api` | .cc | 稳定版 |
+| `https://next.wikipali.org/api` | .org | 最新版 |
+| `https://next.wikipali.cc/api` | .cc | 最新版 |
+| `http://127.0.0.1:8000/api` | 开发机 | 工作副本 |
+
+- `.org` / `.cc` —— 地区可达性,用户按网络情况选;
+- `www` / `next` —— **代码版本**,不是数据环境。`next` 跑最新版,出问题可随时降级到 `www`,数据不受影响。
+
+因此 Skill **不需要「按站点分桶」这类结构**——四个地址在数据上是同一个后端:
+
+1. **线上凭据只有一份**。四个地址通用:userToken / modelToken 用同一把密钥签;channel access token 的密钥存在同一张 `access_tokens` 表里;`ai_models` 也是同一张表,模型 uid 在四个地址上都是同一个。§6.2 的凭据文件退化为 `{ online: {...}, local: {...} }` 两桶——`local` 单独一桶是因为开发机是另一个库、另一把密钥。
+2. **任意切换,不需要重新登录、不需要重跑 ensure-model、不需要重签 access token**。换地址只是换一条网络路径 + 换一版服务端代码。
+3. **允许自动 fallback,但要出声**。四个线上地址之间连不通就换下一个是安全的(同一套数据)。顺序:用户选定的 → 同版本的另一域名 → 另一版本的同域名。**唯独不能自动回退到 `local`**,那是另一套库。切换时打一行提示(`www.wikipali.org 连接失败,已改用 www.wikipali.cc`)——静默切换会掩盖「你选的站点挂了」,也会让 §6.1.2-4 的契约差异变得无从排查。
+4. **真正的风险不是写错库,是写到不同版本的代码上**。`next` 与 `www` 的 API 契约可能不一致:新端点、新字段、新校验会先上 `next`,`www` 落后一段时间。所以:
+   - Skill 依赖的新端点(如 §2.3b 的 `DELETE /v2/ai-model-token/{uid}`)在 `www` 上可能还是 404,遇到 404 要提示「当前站点代码版本较旧,请切到 next 或稍后再试」,而不是当成「模型不存在」;
+   - `references/api.md` 记录的契约以 **`www`(稳定版)** 为准,`next` 独有的能力标注出来。Skill 默认连 `www`。
+5. **写入前仍要回显 api_url**,但理由变了:不是怕写错库(写不错),而是出问题时要知道是哪一版代码写的。
+
+#### 用户如何切换(2026-08-05 定案)
+
+地址来源优先级:
+
+| 优先级 | 来源 | 是否改变默认 |
+|---|---|---|
+| 1 | `--api https://next.wikipali.org/api` | **否**,仅本次调用 |
+| 2 | `WIKIPALI_API_URL` 环境变量 | 否,仅当前 shell |
+| 3 | 凭据文件里的 `online.api_url` | 这就是默认,由 `wp.py endpoint` 写入 |
+| 4 | 都没有 → `https://www.wikipali.org/api` | 首次运行的兜底(稳定版) |
+
+**`--api` 一次性覆盖,不写回凭据文件**。否则「上周试了一次 next」会一直粘着,之后每次写入都落在最新版代码上而用户毫无察觉。改默认必须是显式动作,即下面的子命令。长期用 `next` 的人应该改默认,而不是每次带参数。
+
+**`wp.py endpoint` 是唯一改默认的入口**,让「切站点」成为可见、可回显的动作,而不是手工编辑 JSON:
+
+```
+$ python3 scripts/wp.py endpoint
+  1) https://www.wikipali.org/api   稳定版 · .org  ← 当前
+  2) https://www.wikipali.cc/api    稳定版 · .cc
+  3) https://next.wikipali.org/api  最新版 · .org
+  4) https://next.wikipali.cc/api   最新版 · .cc
+  5) http://127.0.0.1:8000/api      开发机
+
+$ python3 scripts/wp.py endpoint next
+  已切换到 https://next.wikipali.org/api(最新版 · .org)
+```
+
+不带参数时列出清单并标出当前选中;带参数时接受序号、简称(`next` / `www` / `local`)或完整 url,写回 `online.api_url`。切到 `local` 则改用 `local` 桶的凭据(§6.2)。
+
+开发机地址不做特殊照顾——`http://` 明文只在 `127.0.0.1` 放行,其余一律要求 `https://`。
+
+上表是**内置的已知站点清单**,与 §6.1.1 第 3 条(地址不硬编码)不冲突:清单只用于 `endpoint` 子命令的展示与 fallback 排序;`--api` / 环境变量给出的任意地址仍然接受,只是不在清单里的地址自成一桶,不与线上凭据互通。
+
+### 6.2 凭据存储
+
+路径:`~/.wikipali/credentials.json`(**不放在用户项目目录内**,避免被误提交),权限 `0600`。
+
+```json
+{
+  "current": "online",
+  "online": {
+    "api_url": "https://www.wikipali.org/api",
+    "user": { "uid": "...", "username": "...", "token": "..." },
+    "model": { "uid": "...", "name": "claude-opus-5", "token": "..." },
+    "access_tokens": {
+      "<channel_uid>": { "token": "...", "book": 0, "granted_at": "2026-08-03T10:00:00Z" }
+    }
+  },
+  "local": {
+    "api_url": "http://127.0.0.1:8000/api",
+    "user": {}, "model": {}, "access_tokens": {}
+  }
+}
+```
+
+只有 `online` / `local` 两桶,理由见 §6.1.2:四个线上地址共享库与密钥,凭据通用。`online.api_url` 只记「上次选的是哪个地址」,换地区或在 www / next 之间切换就是改这一个字段,`user` / `model` / `access_tokens` 全部原样沿用。`local` 单独一桶是因为开发机是另一个库、另一把 `jwt_secrets_key`。
+
+原则:
+- **Claude 永不接触明文密码**。登录由用户自己在**一个真正的终端**里执行 `python3 scripts/wp_login.py`(`getpass` 读取)。
+  ~~或在 Claude Code 中用 `! python .../wp_login.py` 前缀运行~~——2026-08-05 实测推翻:`!` 前缀跑的命令没有交互式终端(`sys.stdin.isatty()` 为假),密码提示无处输入。脚本会明确报错并指路,另提供 `--password-stdin` 供自动化场景从管道读取;密码**不能**经 argv 传(进 `ps` 与 shell history),也不能直接打进对话(进上下文);
+- Skill 读取凭据文件时只取 token,不回显到对话中(日志里 token 一律打码);
+- 任一 token 收到 401 → 提示重新登录,而不是自动重试;
+- 缓存的 `model.token` 有效期只有 30 天,且可能被 owner 主动撤销(§2.3b)。两种情况的表现都是 401,处理一致:重跑 §6.3 第 4 步重取,仍 401 才提示重新登录。
+
+### 6.3 幂等 model 记录
+
+`name` 取当前模型标识(如 `claude-opus-5`),流程:
+
+1. `GET /v2/ai-model?view=studio&name={username}&keyword={modelName}`;
+2. 在 `rows` 中做 `name` **精确**比对;
+3. 命中 → 用其 `uid`;未命中 → `POST` 一次即可带全字段(§5.2d 已落地,不再需要「POST 再 PUT」两步)。`POST` 撞 409 → 回查列表取 uid(模糊匹配漏网或并发);已存在但字段有出入 → `PUT` 增量补;
+4. `GET /v2/ai-model-token/{uid}` 取 modelToken,写入凭据文件缓存。
+
+`--name` 决定署名,故不设默认值:拿不到就报错要求显式指定,避免把句子挂到别的模型名下。
+
+### 6.4 channel 的交互式选择
+
+§9 决策 3:**不要求用户手工提供 channel uid**,由 Skill 列出可编辑 channel 供选择。
+
+接口:`GET /v2/channel?view=user-edit`(Bearer = **用户 token**)
+
+```
+ChannelController::index() 的 'user-edit' 分支:
+  ShareApi::getResList(user_uid, 2) 中 power >= 20 的 res_id
+  ∪ owner_uid == user_uid 的 channel
+```
+
+返回列 `uid / name / summary / type / owner_uid / lang / status / is_system / updated_at / created_at`。
+
+流程:
+1. 用户未指定 channel → `GET /v2/channel?view=user-edit`,按 `updated_at` 倒序展示 `name` + `lang` + `uid` 前 8 位;
+2. 用户从中选择(或用 `name` 模糊匹配后确认);
+3. 选定的 uid 进入 §3 流程的 `access-token` 签发步骤。
+
+要点:
+- 该 view **已经是「可编辑」语义**,与 `access-token` 签发用的 `ChannelApi::userCanEdit()` 是同一套 share 权限(power ≥ 20),所以列表里的 channel 正常都能签出 token。但两者代码路径不同,仍须按 §6.6 判 `count: 0`;
+- 若用户显式给了 uid,跳过列表直接用,但仍应 `GET /v2/channel/{uid}` 回显 name 供 §6.5 确认——避免写错 channel;
+- 列表为空 → 明确提示「当前账号没有任何可编辑的 channel」,而不是继续走签发流程。
+
+### 6.5 写入前的确认
+
+写库属于对外的、不易回滚的操作。Skill 必须在 `POST /v2/sentence` 之前:
+- 展示:**当前 api_url**(§6.1.2:四个线上地址写的是同一个库,但代码版本不同,出问题时要知道是哪一版写的)、目标 channel(uid + name)、book、句子条数、前若干条的 `id` 与 content 摘要;
+- 明确提示「已存在的相同句子将被覆盖」(`firstOrNew` 语义);
+- 取得用户确认后才发送。批量写入建议分批(如每批 50 条)并报告累计 `count`。
+
+### 6.6 错误处理约定
+
+| 现象 | 含义 | 处置 |
+|---|---|---|
+| 401 | token 失效/过期,或把 `local` 的 token 发到了线上(§6.1.2;四个线上地址之间不会出这个问题) | 引导重新登录,勿自动重试 |
+| 403 | 无 channel 编辑权,或非 studio owner | 明确指出缺哪一项权限 |
+| `access-token` 返回 `count: 0` | 用户对该 channel 无编辑权(被静默跳过) | 当作 403 报错,**不可继续写入** |
+| `sentence` 返回 `count` 小于提交条数 | 部分句子鉴权失败被 `continue` 跳过 | 逐条对比返回的 rows,报告被跳过的句子 id |
+| `no date` (200) | 请求缺 `sentences` 字段 | 客户端 bug |
+| 新端点 404(如 §2.3b 的 `DELETE /v2/ai-model-token/{uid}`) | 当前站点跑的是稳定版代码,该端点尚未上线(§6.1.2-4) | 提示切到 `next` 或稍后再试,**不要**当成「资源不存在」 |
+
+注意 `SentenceController::store()` 对**逐句失败是静默跳过**的(`:341`),所以「HTTP 200」不等于「全部写入成功」,必须核对 `count`。
+
+### 6.7 打包与分发(2026-08-05 改为插件,见 §9 决策 7)
+
+**主路径:Claude Code 插件 + 自建 marketplace。**
+
+```
+/plugin marketplace add iapt-platform/wikipali-plugins
+/plugin install wikipali@wikipali
+```
+
+桌面版(Claude Desktop 的 **Code** 标签页)点 `+` → Plugins → Add plugin 装同一个 marketplace。注意插件只对本地/SSH 会话生效,Chat 标签页与云会话不加载插件。
+
+**上游是 `iapt-platform`,`visuddhinanda/*` 是 fork。** 开发在 fork 上做,通过 PR 合回上游(仓库既有的工作流,见 `Merge pull request #2427`)。**面向用户的一切地址都必须指向 `iapt-platform`**——指向 fork 会让用户装到某个人的分支上。
+
+**两个仓库的分工**:
+
+| 仓库 | 内容 | 为什么 |
+|---|---|---|
+| `iapt-platform/wikipali-plugins` | 只有 `.claude-plugin/marketplace.json` + README,8 KB | `/plugin marketplace add` 会**完整克隆** marketplace 仓库,没有稀疏优化 |
+| `iapt-platform/mint` | 插件本体 `plugins/wikipali/` | 与 API 同仓演进(§6.1)。marketplace 用 `git-subdir` 源指过来,Claude Code **稀疏克隆**只取这一个子目录 |
+
+反过来「mint 自己当 marketplace」是不行的:mint 的 packfile 580 MB、HEAD 快照 212 MB,而 Claude Code 的 git 操作超时是 120 秒,且后台自动更新失败时会整仓重新 clone。
+
+**版本与更新**——版本号只有 `plugin.json` 的 `version` 一处。marketplace 条目里可以再加 `sha` 钉到具体提交,那才是真正的「发版」:用户不会静默拿到 mint 上某个未验证的中间提交。改 API 契约时的动作是:改插件 → 提交 → 推 mint → 更新 marketplace.json 的 `sha`/`version` → 用户 `/plugin update`。
+
+**`install.sh` 降级为后路**:不走 marketplace 时,它把插件目录整个复制到 `<target>/.claude/skills/wikipali-write/`,因为带 `.claude-plugin/plugin.json` 的目录会被当作 `<name>@skills-dir` 插件就地加载。代价是不会自动更新。
+
+**先后顺序**:先在本仓库把流程跑通(P1 全部完成),再打包。过早分发会把未定型的 API 契约固化到别人机器上——所以**线上四站部署 + 线上复测通过之前,不要把 marketplace 地址给别人**。
+
+#### 已发布状态(2026-08-06)
+
+`iapt-platform/wikipali-plugins` 已上线,`wikipali-write` 钉在 mint 的 `c46cf6400`。实测:
+
+- `/plugin marketplace add iapt-platform/wikipali-plugins` → `/plugin install wikipali@wikipali` 一次通过;
+- 缓存目录 `~/.claude/plugins/cache/wikipali/wikipali-write/0.1.0/` 只有 **84 KB**——`git-subdir` 的稀疏克隆确实只取了那一个子目录,没有拉 mint 的 580 MB;
+- 在与 mint 无关的目录下启动,skill 以 `wikipali-write:write` 加载,缓存里的 `wp.py` 直接可跑;
+- 常驻上下文成本 ~230 tok(就是 SKILL.md 的 description),调用时 ~2k。
+
+**发版流程**(四步,缺一步用户就拿不到新版):
+
+1. 改插件 → 提交 → **推 mint**;
+2. 有契约变更就 bump `plugins/wikipali/.claude-plugin/plugin.json` 的 `version`;
+3. 更新 `wikipali-plugins` 的 `marketplace.json`:`source.sha` 指向新提交,`version` 跟着改;
+4. 用户 `/plugin update wikipali@wikipali`。
+
+第 3 步是刻意的手工闸门:不钉 sha 的话用户会静默拿到 `development` 上任何一个中间提交,包括没验证过的。
+
+2026-08-05 的实际情况:`install.sh` 已写好并验证(装出的副本能独立运行),但**分发要等到服务端部署 + 端到端实测通过之后**。打包机制本身不依赖 API 契约,先写好没有代价;真正会把未定型契约固化出去的是「复制给别的项目」这一步。
+
+---
+
+## 7. 安全考量
+
+1. **权限不放大**:AI 模型自身不是任何 channel 的 owner/协作者,其全部写权限来自用户签发的 access token,且受 book 范围限制。用户无权的 channel,签发阶段就会失败。
+2. **模型 token 有效期 30 天且可撤销**(§5.1):泄漏时 owner 调 `DELETE /v2/ai-model-token/{uid}` 即可让该模型全部已签出 token 立刻失效,不必轮换全局 `jwt_secrets_key`(那会踢掉所有用户)。撤销是全量的,不能只废一张。`~/.wikipali/credentials.json` 仍须 `0600`、不进日志/不进对话——撤销是止损手段,不是防线。
+3. **access token 有效期 7 天**(§5.2f 已修)。注意签名密钥是 `access_tokens` 表里按 `res_type + res_id` 存的 uuid,`firstOrNew` 只在首次创建,**同一 channel 的密钥不轮换**——所以 7 天只限制单张 token 的窗口,没有「立即吊销」能力。Skill 仍应把它视为高敏感数据,仅存本地、不进日志、不进对话。
+4. **密码零留存**:不写入任何文件,不进入对话上下文。
+5. **审计**:所有写入都会进 `sent_histories`(`SentenceService::saveHistory`),`editor_uid` 为模型 uid,可追溯。
+6. **部署前提**:`token_version` 的校验在 `AuthService::current()` 里,故撤销只被跑了该版本代码的站点认账。本轮改动尚未部署到任何服务器,部署时四个站点一起上即可,不存在版本差窗口。若日后单独灰度某个站点,需记得这条。
+
+---
+
+## 8. 实施计划
+
+| 阶段 | 状态 | 内容 | 依赖 |
+|---|---|---|---|
+| P0 | ✅ | 服务端:新增 `GET /v2/ai-model-token/{uid}`(`AiModelTokenController::show`,用 `canEdit`)+ 测试 | — |
+| P0 | ✅ | 服务端安全修补:§5.2 (a)(b)(f) | — |
+| P0 | ✅ | 服务端:§5.2 (d)(e)(g) —— 从 P2 上提,否则 §6.3 的「POST 建档再 PUT 补字段」会被 (e) 的 null 覆盖打断 | — |
+| P0 | ✅ | 服务端:模型 token TTL 收到 30 天 + `token_version` 撤销机制 + `DELETE /v2/ai-model-token/{uid}`(推翻 §9 决策 2)| — |
+| P1 | ✅ | Skill:`wp_login.py` + 凭据存储 + `auth/current` 校验 | P0 |
+| P1 | ✅ | Skill:`ensure-model`(查/建/补字段/取 token)、`revoke`、`whoami` | P0 |
+| P1 | ✅ | Skill:`channels`(`view=user-edit` 列表 + 交互选择) | P0 |
+| P1 | ✅ | Skill:`grant`(签 access token,缓存,判 `count: 0`) | `channels` |
+| P1 | ✅ | Skill:`write`(分批 + 确认 + count 核对 + 401 自动重签一次) | 以上全部 |
+| P2 | ⬜ | 服务端质量修补:§5.2 (c) | — |
+| P1 | ✅ | Skill:`install.sh` + `VERSION`(打包分发,§6.7) | P1 全部跑通 |
+| P1 | ✅ | 端到端实测:开发机(`local`)上跑通登录 → 写入 → 断言署名 | — |
+| P2 | ⬜ | 线上复测(部署后重跑一次,确认线上无差异) | 服务端部署 |
+| P2 | ⬜ | Skill 扩展:读取能力(`GET /v2/sentence`、`sentences-in-chapter`)与 `sentpr` PR 提交 | P1 |
+
+(d)(e) 上提到 P0 的理由:§6.3 第 3 步在 (d) 落地前必须走「POST 创建 → PUT 补齐字段」两步,而 (e) 未修时那个 PUT 会把未传字段一律置 null,两个缺陷叠加使 ensure-model 无法可靠工作。
+
+测试要求(`api-v13` 使用 Pest):
+- ✅ Feature test 覆盖新端点的 401 / 403 / 200 / 404 四条路径(`tests/Feature/AiModelTokenTest.php`);
+- ✅ `AiModelResourceTest` 断言 key / system_prompt 不外泄、owner 仍可取;
+- ✅ `AiModelCrudTest` 断言 store 全字段、重名 409、update 增量不清空;
+- ✅ `AccessTokenExpiryTest` 断言签出的 token 带 `exp` 且无权时 `count: 0`;
+- ⬜ 端到端 test:登录 → 建模型 → 取 model token → 签 access token → 写句子 → 断言 `editor_uid == 模型 uid`(留待 Skill 落地时补,需要 sentences / pali_texts 等一批表的夹具)。
+
+#### 测试环境
+
+迁移文件含 Postgres 专有语句(`CREATE EXTENSION "uuid-ossp"`、`enum` 列等),**无法在 sqlite 上跑**,所以 `phpunit.xml` 里 `DB_CONNECTION=pgsql`、`DB_DATABASE=mint_test`。
+
+`RefreshDatabase` 会清空目标库,**测试库必须与开发库 `visuddhinanda_20260311` 严格分离**——后者装着完整生产数据集(`sentences` 3.3 GB、`sent_sims` 3.6 GB)。库名写死在 `phpunit.xml` 里正是为了不让它跟着 `.env` 漂移。
+
+数据库需由具备 `CREATEDB` 权限的角色创建(应用角色 `www` 没有该权限):
+
+```bash
+sudo -u postgres createdb -O www mint_test
+```
+
+#### Skill 的验证方式(2026-08-05)
+
+Skill 的验证不走 Pest——它是个纯客户端,测的是「对着服务端的响应形状与坑,客户端做对了没有」。做法是写一个模拟 API 的桩服务(复刻 `sign-in` 失败返回 400、`keyword` 模糊匹配、`access-token` 无权返回 `count: 0`、`sentence` 逐句静默跳过、返回字段名是 `book` 而非 `book_id` 这几处),把全流程跑一遍,验证点:
+
+登录(含密码错)、`ensure-model` 幂等复跑、`channels` 列表、`grant` 缓存命中不重签、`write` 的 dry-run / 分批 / 覆盖警告 / 非交互式无 `-y` 时拒绝写入、部分写入时列出漏掉的句子、`count: 0` 时中止、模型 token 被撤销后自动重签一次再重试、fallback 顺序(同版本另一域名 → 另一版本同域名,且绝不落到 `local`)、`endpoint` 切换与 `--api` 不写回、`install.sh` 装出的副本可独立运行。
+
+桩服务不进仓库:它编码的是「我以为服务端是这样」,留着会变成第二份契约来源,与 `references/api.md` 打架。
+
+#### 开发机上的端到端实测(2026-08-05)
+
+对 `local`(`php artisan serve`,开发库 `visuddhinanda_20260311`)跑了一遍完整链路:用户在真实终端里 `wp_login.py` 登录 → `ensure-model` 建档并取模型 token → `channels` 列出 130 个可编辑 channel → 写 3 条句子到「草稿二」的 `book 1 / paragraph 99901`(事先查过该位置在其所有 channel 里都是空的,只新增不覆盖)。
+
+查库断言的结果:
+
+- 3 条句子的 `editor_uid` = `79bb0934-…`(模型 uid),**不是** `ba5463f3-…`(本人 uid)——署名目标达成;
+- `language` / `status` 继承自 channel(`zh-Hans` / 10),与 `store()` 的逻辑一致;
+- `sent_histories` 每条 1 行,`user_uid` 同为模型 uid,审计链成立;
+- 改一句内容重跑,3 个 `uid` 不变、内容更新、每条历史累积到 2 行——`firstOrNew` 的幂等覆盖语义得到确认;
+- 对一个无编辑权的 channel 跑 `grant`,服务端返回 `count: 0`,客户端按约定中止并报「没有编辑权」。
+
+测试数据(3 条句子 + 6 行历史)已按 uid 精确删除,「草稿二」回到原有的 13 条;`claude-opus-5` 的 `ai_models` 记录保留,它就是日后真实写入要用的模型身份。
+
+**线上仍未验证**:四个线上地址都还没部署 P0(`POST /api/v2/ai-model-token/x` 返回 404 而非 405 —— 已注册的路由用错方法会返回 405,未注册才是 404)。部署后应重跑一次同样的链路。
+
+---
+
+## 9. 决策记录
+
+四个待确认问题已于 2026-08-04 定案:
+
+| # | 问题 | 决策 | 影响 |
+|---|---|---|---|
+| 1 | 模型记录挂个人还是 group studio | **个人 studio** | §5.1 用 `canEdit()`,不引入 `StudioApi::userCanManage`;(a) 的遗留项 2 关闭 |
+| 2 | 是否提供 token 撤销机制 | ~~不做~~ → **2026-08-05 推翻,改为做** | 加 `ai_models.token_version`,模型 token payload 增 `typ`/`ver`,TTL 从 365 天收到 30 天;旧模型 token 全部失效(见 §5.1、§7-2) |
+| 3 | channel uid 如何获取 | **Skill 交互式选择** | 用 `GET /v2/channel?view=user-edit`,见 §6.4 |
+| 4 | Skill 分发形态 | **在本仓库开发,以复制方式分发**;不做独立仓库 | 放仓库根 `plugins/wikipali/`,目录自包含、零依赖,可整体复制到其他项目;见 §6.1、§6.7 |
+| 5 | 多站点(4 个线上 + 开发机)如何处理 | 四个线上地址**共享库与 `jwt_secrets_key`**,凭据只存一份(`online` / `local` 两桶),可任意切换与自动 fallback(2026-08-05 补) | 见 §6.1.2、§6.2。`.org`/`.cc` 是地区,`www`/`next` 是**代码版本**不是数据环境;随之而来的是 API 契约版本差,见 §6.1.2-4 |
+| 6 | 用户怎么切 endpoint | `--api` 一次性覆盖**不写回**;改默认只经 `wp.py endpoint` 子命令;fallback **提示后切换**不静默(2026-08-05 补) | 见 §6.1.2「用户如何切换」。三条都指向同一个原则:当前连的是哪个站点,任何时候都应当是用户明确知道的 |
+| 7 | 怎么发布给别人 | **Claude Code 插件 + 自建 marketplace**:目录文件放独立小仓库 `wikipali-plugins`,插件本体留在 mint,用 `git-subdir` 稀疏克隆(2026-08-05 补,修正决策 4 的「整目录复制」) | 见 §6.7。mint 不能直接当 marketplace——marketplace 是整仓 clone,580 MB 撞 120 秒超时。MCP server 形态排在插件跑通之后 |
+
+决策 2 原本是「不做」,理由是省掉 `token_version` 可以不动 `ai_models` 表结构、不改 `getUserToken` 的 payload。2026-08-05 推翻:趁 Skill 尚未分发、代码尚未部署、外面一份真实凭据都没有的时候补,代价最小;再往后每多一份副本,「已签出 token 全部失效」的破坏面就大一分。

+ 20 - 0
plugins/wikipali/.claude-plugin/plugin.json

@@ -0,0 +1,20 @@
+{
+  "name": "wikipali",
+  "description": "WikiPali 巴利三藏平台的客户端:检索与阅读语料做研究(词形展开、全文检索、出处分布、按坐标取原文与译本),以及以 AI 模型身份写入句子。",
+  "version": "0.2.0",
+  "author": {
+    "name": "visuddhinanda",
+    "url": "https://github.com/visuddhinanda"
+  },
+  "homepage": "https://github.com/iapt-platform/mint/tree/development/plugins/wikipali",
+  "repository": "https://github.com/iapt-platform/mint",
+  "license": "MIT",
+  "keywords": [
+    "wikipali",
+    "pali",
+    "tipitaka",
+    "buddhist-studies",
+    "research",
+    "translation"
+  ]
+}

+ 89 - 0
plugins/wikipali/README.md

@@ -0,0 +1,89 @@
+# wikipali
+
+[WikiPali](https://www.wikipali.org) 巴利三藏平台的 Claude Code 插件。
+
+两个 skill:
+
+- **`research`** —— 检索与阅读语料做研究:词形展开、按词形检索、出处分布(分本文/义注/复注)、按坐标取原文与各家译本。只读,不需要登录。
+- **`write`** —— 以 **AI 模型身份**把句子写入句子库。
+
+写入的句子 `editor_uid` 记为 AI 模型的 uid 而不是操作者本人,署名与审计因此是准确的——谁翻的就是谁翻的。
+
+## 安装
+
+```
+/plugin marketplace add iapt-platform/wikipali-plugins
+/plugin install wikipali@wikipali
+```
+
+桌面版在 **Code** 标签页里点 `+` → **Plugins** → **Add plugin** 也可以装。
+
+不走 marketplace 的话,克隆本仓库后跑 `plugins/wikipali/install.sh --user`。
+
+## 装之前请知道它会做什么
+
+插件能在你的机器上执行代码,装之前你应当知道这一个具体会干什么:
+
+- **读写 `~/.wikipali/credentials.json`**(权限 0600),里面存你的 WikiPali 登录 token、AI 模型身份 token 和 channel access token;
+- **往 wikipali.org 写数据**。写入是覆盖式的:相同位置(book / paragraph / word_start / word_end / channel)的已有句子会被替换。插件在每次写入前会回显目标并要求确认;
+- **只用 Python 标准库**,不装任何依赖,不建虚拟环境。
+
+它**不会**接触你的密码:登录由 `wp_login.py` 完成,密码经 `getpass` 读入内存,不落盘、不进日志、不进对话。这个脚本必须由你本人在真正的终端里执行,Claude 不代跑。
+
+## 用法
+
+装好后直接对 Claude 说「把这些译文写进 WikiPali 的某某 channel」即可,它会自己走完流程。手工调用:
+
+```bash
+wikipali whoami        # 看当前凭据状态
+wikipali-login         # 登录(自己跑)
+wikipali ensure-model --name <模型标识>
+wikipali channels
+wikipali write sents.json --channel <uid> --dry-run
+```
+
+句子文件的形状:
+
+```json
+{
+  "channel_uid": "<channel uid>",
+  "sentences": [
+    { "book_id": 1, "paragraph": 10, "word_start": 0, "word_end": 12,
+      "content": "译文", "content_type": "markdown" }
+  ]
+}
+```
+
+## 站点
+
+线上四个地址(`www` / `next` × `.org` / `.cc`)共享同一个数据库和密钥,凭据通用,可随时切换:
+
+```bash
+wikipali endpoint          # 列出并标出当前
+wikipali endpoint next     # 改默认
+wikipali --api next ...    # 只影响这一次调用
+```
+
+`www` 是稳定版、`next` 是最新版**代码**,不是不同的数据环境。较新的端点在稳定版上返回 404,意思是「该站点代码版本还没到」。
+
+## 权限模型
+
+三种 token,职责不混:
+
+| Token | 代表谁 | 有效期 |
+|---|---|---|
+| 用户 token | 人类操作者 | 365 天 |
+| 模型 token | AI 模型身份,写句子时的 `Authorization` | 30 天,可撤销 |
+| access token | 被委托的 channel 编辑权,写句子时的 body 字段 | 7 天 |
+
+模型自身不是任何 channel 的 owner,它的全部写权限来自你签发的 access token,且受 book 范围约束——**你没有编辑权的 channel,签发阶段就会失败**。凭据泄漏时用 `wikipali revoke` 作废该模型已签出的全部 token。
+
+## 开发
+
+本插件在 [iapt-platform/mint](https://github.com/iapt-platform/mint) 的 `plugins/wikipali/` 下开发,与被调用的 Laravel API(`api-v13/`)同仓演进——API 契约一改,插件在同一个提交里跟上。设计文档在 `docs/wikipali-write-skill-design.md`。
+
+端点细节见 `references/api-read.md` 与 `references/api-write.md`;跨 skill 的通用约定(坐标、引用格式、文献层次、译文来源判定)见 `references/conventions.md`。
+
+## License
+
+MIT

+ 13 - 0
plugins/wikipali/bin/wikipali

@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+"""WikiPali 客户端入口。只用 Python 标准库,直接跑,不要建虚拟环境。"""
+
+import os
+import sys
+
+_LIB = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'lib')
+sys.path.insert(0, _LIB)
+
+from cli import main  # noqa: E402
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 134 - 0
plugins/wikipali/bin/wikipali-login

@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+"""WikiPali 交互式登录——整个插件里唯一接触密码的程序。
+
+密码只经 getpass 读入内存,不落盘、不进日志、不进对话上下文。
+登录成功后只把 JWT 存进 ~/.wikipali/credentials.json(0600)。
+
+用法:
+    wikipali-login                       # 登录当前默认站点
+    wikipali-login --api next            # 只为本次登录换站点
+    wikipali-login --username someone    # 免去输用户名一步
+
+**必须在真正的终端里跑。** Claude Code 的 `!` 前缀没有交互式终端,
+密码提示无处输入;模型也不该代跑此程序。请另开一个 shell 执行。
+
+确实要在自动化环境里登录时,用 --password-stdin 从管道读密码:
+
+    read -rs PW && printf '%s' "$PW" | wikipali-login --username me --password-stdin
+
+注意别把密码写进命令行参数或直接敲进 Claude Code 的会话——argv 会进
+ps / shell history,会话内容会进对话上下文,两者都留痕。
+"""
+
+import argparse
+import getpass
+import os
+import sys
+
+_LIB = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'lib')
+sys.path.insert(0, _LIB)
+
+import client as wp  # noqa: E402
+from client import fmt_ts, make_client, mask, token_expiry  # noqa: E402
+from creds import CREDS_PATH  # noqa: E402
+from cmd_write import iso_now  # noqa: E402
+from errors import ApiError, WpError  # noqa: E402
+
+
+def main(argv=None):
+    parser = argparse.ArgumentParser(prog="wikipali-login", description="登录 WikiPali 并缓存用户 token")
+    parser.add_argument("--api", help="本次登录使用的 API 地址(序号/简称/完整 url)")
+    parser.add_argument("--username", help="用户名或邮箱;省略则交互输入")
+    parser.add_argument(
+        "--password-stdin", action="store_true",
+        help="从 stdin 读密码(供自动化用;别让密码经过 argv 或对话)",
+    )
+    args = parser.parse_args(argv)
+
+    try:
+        client = make_client(args)
+    except WpError as exc:
+        print(f"错误:{exc}", file=sys.stderr)
+        return 1
+
+    print(f"登录站点:{client.api_note()}")
+    if client.bucket_name != "online":
+        print("注意:该站点的凭据与线上四站不通用。")
+
+    interactive = sys.stdin.isatty()
+    if not interactive and not args.password_stdin:
+        # Claude Code 的 `!` 前缀、CI、管道都会走到这里:密码提示无处输入,
+        # 直接说清楚该怎么办,不要让用户对着一个静默的提示符发愣
+        print(
+            "错误:当前不是交互式终端,无法安全地读取密码。\n"
+            "  · 请另开一个真正的终端执行本脚本(Claude Code 的 `!` 前缀不行);\n"
+            "  · 或在自动化环境里用管道:... | wikipali-login --username <名字> --password-stdin",
+            file=sys.stderr,
+        )
+        return 1
+
+    username = args.username
+    if not username:
+        if not interactive:
+            print("错误:--password-stdin 模式必须同时给 --username。", file=sys.stderr)
+            return 1
+        username = input("用户名或邮箱:").strip()
+    if not username:
+        print("错误:用户名为空。", file=sys.stderr)
+        return 1
+
+    if args.password_stdin:
+        password = sys.stdin.readline().rstrip("\n")
+    else:
+        try:
+            password = getpass.getpass("密码(不会被保存):")
+        except (EOFError, KeyboardInterrupt):
+            print("\n已取消。", file=sys.stderr)
+            return 130
+    if not password:
+        print("错误:密码为空。", file=sys.stderr)
+        return 1
+
+    try:
+        token = client.call("POST", "v2/sign-in", body={"username": username, "password": password})
+    except ApiError as exc:
+        # sign-in 失败时服务端返回 400 + 'invalid token',措辞会让人以为是 token 问题
+        if exc.status in (400, 401):
+            print("错误:用户名或密码不正确。", file=sys.stderr)
+        else:
+            print(f"错误:登录失败(HTTP {exc.status}):{exc}", file=sys.stderr)
+        return 1
+    except WpError as exc:
+        print(f"错误:{exc}", file=sys.stderr)
+        return 1
+    finally:
+        del password
+
+    if not isinstance(token, str) or not token:
+        print("错误:服务端没有返回 token。", file=sys.stderr)
+        return 1
+
+    try:
+        current = client.call("GET", "v2/auth/current", token=token)
+    except WpError as exc:
+        print(f"错误:token 拿到了但校验失败:{exc}", file=sys.stderr)
+        return 1
+
+    client.bucket["user"] = {
+        "uid": current.get("id"),
+        "username": current.get("realName"),
+        "nickname": current.get("nickName"),
+        "token": token,
+        "logged_in_at": iso_now(),
+    }
+    client.save()
+
+    exp = token_expiry(token)
+    print(f"登录成功:{current.get('nickName')}(realName={current.get('realName')},用作 studio_name)")
+    print(f"token {mask(token)} 到期 {fmt_ts(exp)},已写入 {CREDS_PATH}(0600)")
+    print("下一步:python3 wp.py ensure-model --name <模型标识>")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 84 - 0
plugins/wikipali/install.sh

@@ -0,0 +1,84 @@
+#!/bin/sh
+# 手工安装:把本插件整目录复制到目标项目或用户级 skills 目录。
+#
+#   ./install.sh ~/work/other-project   # 项目级,只在该项目激活
+#   ./install.sh --user                 # 用户级,所有项目可用
+#   ./install.sh --user --force         # 覆盖已存在的旧副本
+#
+# 首选方式是从 marketplace 装(见 README),那样能自动更新。本脚本是给
+# 不走 marketplace 的场景留的后路:复制过去的目录带 .claude-plugin/
+# manifest,会被当作 <name>@skills-dir 插件就地加载。
+#
+# 只复制本目录自身,不碰仓库里的任何其他文件。副本不会自动更新——
+# API 契约一改,旧副本就静默过期,靠 plugin.json 的 version 判断。
+
+set -eu
+
+SRC=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+NAME=$(basename -- "$SRC")
+FORCE=0
+TARGET=""
+
+usage() {
+    sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'
+    exit "${1:-1}"
+}
+
+while [ $# -gt 0 ]; do
+    case "$1" in
+        --user) TARGET="$HOME/.claude/skills" ;;
+        --force|-f) FORCE=1 ;;
+        -h|--help) usage 0 ;;
+        -*) echo "未知参数:$1" >&2; usage ;;
+        *)
+            if [ ! -d "$1" ]; then
+                echo "错误:目标目录不存在:$1" >&2
+                exit 1
+            fi
+            TARGET=$(CDPATH= cd -- "$1" && pwd)/.claude/skills
+            ;;
+    esac
+    shift
+done
+
+if [ -z "$TARGET" ]; then
+    echo "错误:请给出目标项目目录,或用 --user 装到用户级。" >&2
+    usage
+fi
+
+DEST="$TARGET/$NAME"
+# 版本号只有一处来源:plugin.json。VERSION 文件已废弃,两处版本必然漂移
+read_version() {
+    python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version","(未标版本)"))' \
+        "$1/.claude-plugin/plugin.json" 2>/dev/null || echo "(读不到 plugin.json)"
+}
+VERSION=$(read_version "$SRC")
+
+if [ "$DEST" = "$SRC" ]; then
+    echo "错误:源和目标是同一个目录。" >&2
+    exit 1
+fi
+
+if [ -d "$DEST" ]; then
+    OLD=$(read_version "$DEST")
+    if [ "$FORCE" -ne 1 ]; then
+        echo "目标已存在:$DEST"
+        echo "  已装版本:$OLD"
+        echo "  本次版本:$VERSION"
+        echo "要覆盖请加 --force。"
+        exit 1
+    fi
+    echo "覆盖 $OLD → $VERSION"
+    rm -rf "$DEST"
+fi
+
+mkdir -p "$TARGET"
+cp -R "$SRC" "$DEST"
+find "$DEST" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null || true
+chmod +x "$DEST/bin/wikipali" "$DEST/bin/wikipali-login" "$DEST/install.sh"
+
+echo "已安装 $NAME $VERSION 到 $DEST"
+echo
+echo "下一步(凭据在 ~/.wikipali/,多个副本共用,通常不必重新登录):"
+echo "  $DEST/bin/wikipali whoami"
+echo "(把 $DEST/bin 加进 PATH 可以直接用 wikipali 命令)"

+ 127 - 0
plugins/wikipali/lib/cli.py

@@ -0,0 +1,127 @@
+"""命令行装配。
+
+读侧(forms/word/search/dist/get)不需要凭据;写侧(ensure-model/channels/grant/write)
+需要,且写入前有确认闸门。登录是独立的 wikipali-login,本入口不接触密码。
+"""
+
+import argparse
+import sys
+
+import cmd_read
+import cmd_site
+import cmd_write
+from client import DEFAULT_BATCH
+from errors import WpError
+
+
+def build_parser():
+    parser = argparse.ArgumentParser(
+        prog='wikipali',
+        description='WikiPali 客户端:检索、阅读、以 AI 模型身份写入',
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog='凭据在 ~/.wikipali/credentials.json(0600)。登录请跑 wikipali-login。',
+    )
+    parser.add_argument('--api', help='本次调用使用的 API 地址(序号/简称/完整 url),不写回凭据文件')
+    sub = parser.add_subparsers(dest='command', required=True)
+
+    def add(name, help_text, needs_json=True):
+        p = sub.add_parser(name, help=help_text)
+        if needs_json:
+            p.add_argument('--json', action='store_true', help='输出原始 JSON')
+        return p
+
+    # -- 站点与状态 --------------------------------------------------------
+    p = add('endpoint', '查看 / 切换 API 地址', needs_json=False)
+    p.add_argument('target', nargs='?', help='序号、简称(www/www.cc/next/next.cc/local)或完整 url')
+    p.set_defaults(func=cmd_site.cmd_endpoint)
+
+    p = add('whoami', '显示当前凭据状态', needs_json=False)
+    p.add_argument('--check', action='store_true', help='额外向服务端校验用户 token')
+    p.set_defaults(func=cmd_site.cmd_whoami)
+
+    # -- 读 ----------------------------------------------------------------
+    p = add('forms', '展开词形——一切检索的前置步骤')
+    p.add_argument('word', help='词根或任意变格形')
+    p.add_argument('--limit', type=int, default=3, help='显示几个候选词根,默认 3')
+    p.set_defaults(func=cmd_read.cmd_forms)
+
+    p = add('word', '词典释义与形态分析,用来确认选对了词根')
+    p.add_argument('word')
+    p.add_argument('--lang', default='zh', help='释义语言,默认 zh')
+    p.add_argument('--limit', type=int, default=3, help='最多显示几个词条')
+    p.add_argument('--dicts', type=int, default=3, help='每个词条显示几部词典')
+    p.set_defaults(func=cmd_read.cmd_word)
+
+    p = add('search', '按词形检索段落')
+    p.add_argument('forms', nargs='*', help='逗号或空格分隔的词形;或用 --lemma 自动展开')
+    p.add_argument('--lemma', help='给词根,自动先展开成全部词形再检索')
+    p.add_argument('--bold', action='store_true', help='只要黑体命中(注释书标出的词条)')
+    p.add_argument('--book', help='限定书(用 dist 输出里的 --book 值)')
+    p.add_argument('--tags', help='限定范围,如 vinaya 或 vinaya,mūla;vinaya,aṭṭhakathā')
+    p.add_argument('--limit', type=int, default=50, help='本页条数,默认 50')
+    p.add_argument('--offset', type=int, default=0)
+    p.add_argument('--width', type=int, default=200, help='每条摘要的字符数')
+    p.set_defaults(func=cmd_read.cmd_search)
+
+    p = add('dist', '出处分布:命中散布在哪些书、各多少、什么层次')
+    p.add_argument('forms', nargs='*')
+    p.add_argument('--lemma')
+    p.add_argument('--tags')
+    p.add_argument('--limit', type=int, default=25, help='最多列几部书')
+    p.set_defaults(func=cmd_read.cmd_dist)
+
+    p = add('get', '按坐标取文,如 wikipali get 216:35 216:36')
+    p.add_argument('coords', nargs='+', help='book:paragraph,可给多个')
+    p.add_argument('--channel', action='append',
+                   help='channel uid,可重复;缺省取巴利原文')
+    p.add_argument('--limit', type=int, default=200, help='每次请求最多取几句')
+    p.set_defaults(func=cmd_read.cmd_get)
+
+    # -- 写 ----------------------------------------------------------------
+    p = add('ensure-model', '幂等地建立模型记录并取模型身份 token', needs_json=False)
+    p.add_argument('--name', help='模型标识,如 claude-opus-5(会成为句子作者署名)')
+    p.add_argument('--model', help='底层模型 id')
+    p.add_argument('--url', dest='url', help='模型服务地址')
+    p.add_argument('--description', help='描述')
+    p.add_argument('--privacy', choices=['private', 'public'], default='private')
+    p.set_defaults(func=cmd_write.cmd_ensure_model)
+
+    p = add('revoke', '撤销该模型已签出的全部 token', needs_json=False)
+    p.add_argument('--uid', help='模型 uid,缺省用缓存里的')
+    p.add_argument('-y', '--yes', action='store_true')
+    p.set_defaults(func=cmd_write.cmd_revoke)
+
+    p = add('channels', '列出当前账号可编辑的 channel')
+    p.add_argument('--search', help='按名字过滤')
+    p.set_defaults(func=cmd_write.cmd_channels)
+
+    p = add('grant', '为某个 channel 签发 access token 并缓存', needs_json=False)
+    p.add_argument('channel', nargs='?', help='channel uid / 列表序号 / 名字片段;省略则交互选择')
+    p.add_argument('--book', type=int, default=0, help='限定 book,0 表示不限(默认)')
+    p.add_argument('--force', action='store_true', help='即使缓存未过期也重新签发')
+    p.set_defaults(func=cmd_write.cmd_grant)
+
+    p = add('write', '写入句子', needs_json=False)
+    p.add_argument('file', help='句子 JSON 文件,- 表示从 stdin 读')
+    p.add_argument('--channel', help='目标 channel(uid / 序号 / 名字片段)')
+    p.add_argument('--book', type=int, help='access token 的 book 范围,缺省按句子推断')
+    p.add_argument('--batch', type=int, default=DEFAULT_BATCH, help=f'每批条数,默认 {DEFAULT_BATCH}')
+    p.add_argument('--content-type', default='markdown')
+    p.add_argument('--preview', type=int, default=5, help='确认时预览几条')
+    p.add_argument('--dry-run', action='store_true', help='只做校验与回显,不发请求')
+    p.add_argument('-y', '--yes', action='store_true', help='跳过交互确认(非交互环境必须显式给)')
+    p.set_defaults(func=cmd_write.cmd_write)
+
+    return parser
+
+
+def main(argv=None):
+    args = build_parser().parse_args(argv)
+    try:
+        return args.func(args)
+    except WpError as exc:
+        print(f'错误:{exc}', file=sys.stderr)
+        return 1
+    except KeyboardInterrupt:
+        print('\n已中断。', file=sys.stderr)
+        return 130

+ 178 - 0
plugins/wikipali/lib/client.py

@@ -0,0 +1,178 @@
+"""HTTP 客户端:JSON 请求、线上站点之间出声的 fallback、token 显示辅助。"""
+
+import base64
+import json
+import os
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from datetime import datetime, timezone
+
+from creds import bucket_name_for, get_bucket, load_creds, resolve_api_url, save_creds
+from errors import ApiError, WpError
+from sites import ONLINE_URLS, SITES, site_label
+
+
+TOKEN_REFRESH_MARGIN = 3600
+
+DEFAULT_TIMEOUT = 30
+WRITE_TIMEOUT = 120
+DEFAULT_BATCH = 50
+
+
+def mask(token):
+    if not token:
+        return "(无)"
+    if len(token) <= 16:
+        return token[:4] + "…"
+    return token[:8] + "…" + token[-4:]
+
+
+def jwt_payload(token):
+    """不验签地读出 JWT payload,仅用于显示有效期。"""
+    try:
+        part = token.split(".")[1]
+        part += "=" * (-len(part) % 4)
+        return json.loads(base64.urlsafe_b64decode(part.encode("ascii")))
+    except Exception:
+        return {}
+
+
+def token_expiry(token):
+    exp = jwt_payload(token).get("exp")
+    return int(exp) if isinstance(exp, (int, float)) else None
+
+
+def fmt_ts(ts):
+    if not ts:
+        return "未知"
+    return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
+
+
+def note(msg):
+    print(msg, file=sys.stderr)
+
+
+def http_json(api_url, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
+    """发一个 JSON 请求,返回解析后的响应体(dict)。
+
+    网络层失败抛 urllib 的异常(由 Client 决定是否 fallback);
+    HTTP 层失败抛 ApiError,带上服务端 message。
+    """
+    # 路径里可能有巴利词(parivāsa),urllib 只接受 ASCII,必须先百分号编码
+    url = api_url + "/" + urllib.parse.quote(path.lstrip("/"), safe="/")
+    if query:
+        url += "?" + urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
+    data = None
+    headers = {"Accept": "application/json", "User-Agent": "wikipali-write-skill"}
+    if body is not None:
+        data = json.dumps(body, ensure_ascii=False).encode("utf-8")
+        headers["Content-Type"] = "application/json"
+    if token:
+        headers["Authorization"] = "Bearer " + token
+    req = urllib.request.Request(url, data=data, headers=headers, method=method)
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            raw = resp.read().decode("utf-8", "replace")
+            status = resp.status
+    except urllib.error.HTTPError as exc:
+        raw = exc.read().decode("utf-8", "replace")
+        status = exc.code
+        payload = safe_json(raw)
+        message = payload.get("message") if isinstance(payload, dict) else None
+        raise ApiError(status, message or f"HTTP {status}", url=url, body=payload or raw)
+    payload = safe_json(raw)
+    if not isinstance(payload, dict):
+        raise ApiError(status, f"响应不是 JSON:{raw[:200]}", url=url, body=raw)
+    if not payload.get("ok", False):
+        raise ApiError(status, payload.get("message") or "请求失败", url=url, body=payload)
+    return payload.get("data")
+
+
+def safe_json(raw):
+    try:
+        return json.loads(raw)
+    except ValueError:
+        return None
+
+
+class Client:
+    """按站点收发请求,并在线上地址之间做出声的 fallback。"""
+
+    def __init__(self, api_url, source, creds, allow_fallback=True):
+        self.api_url = api_url
+        self.source = source
+        self.creds = creds
+        self.bucket_name = bucket_name_for(api_url)
+        self.bucket = get_bucket(creds, self.bucket_name, api_url)
+        self.allow_fallback = allow_fallback and api_url in ONLINE_URLS
+
+    # -- 凭据 ---------------------------------------------------------------
+
+    @property
+    def user_token(self):
+        token = (self.bucket.get("user") or {}).get("token")
+        if not token:
+            raise WpError(
+                "尚未登录。请自己执行(Claude Code 里用 ! 前缀):\n"
+                "    ! python3 " + os.path.join(os.path.dirname(os.path.abspath(__file__)), "wp_login.py")
+            )
+        return token
+
+    @property
+    def model(self):
+        model = self.bucket.get("model") or {}
+        if not model.get("token"):
+            raise WpError("尚未取得模型身份 token。请先跑:python3 wp.py ensure-model --name <模型名>")
+        return model
+
+    def save(self):
+        save_creds(self.creds)
+
+    # -- 请求 ---------------------------------------------------------------
+
+    def fallback_order(self):
+        """同版本的另一域名 → 另一版本的同域名 → 其余。绝不含 local。"""
+        cur = next((s for s in SITES if s["url"] == self.api_url), None)
+        if not cur:
+            return []
+        others = [s for s in SITES if s["key"] != "local" and s["url"] != self.api_url]
+        others.sort(
+            key=lambda s: (
+                0 if s["version"] == cur["version"] else 1,
+                0 if s["domain"] == cur["domain"] else 1,
+            )
+        )
+        return [s["url"] for s in others]
+
+    def call(self, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
+        urls = [self.api_url] + (self.fallback_order() if self.allow_fallback else [])
+        last = None
+        for idx, url in enumerate(urls):
+            try:
+                data = http_json(url, method, path, token=token, body=body, query=query, timeout=timeout)
+            except (urllib.error.URLError, TimeoutError, OSError) as exc:
+                # 仅网络层不可达才换站点;HTTP 错误是服务端的明确答复,不该被掩盖
+                last = exc
+                reason = getattr(exc, "reason", exc)
+                if idx + 1 < len(urls):
+                    note(f"⚠ {url} 连接失败({reason}),改用 {urls[idx + 1]}")
+                continue
+            if url != self.api_url:
+                # fallback 成功后本次会话都用它,但不写回凭据文件
+                note(f"⚠ 本次请求实际发往 {url}({site_label(url)})")
+                self.api_url = url
+            return data
+        raise WpError(f"所有可用站点都连不上,最后一次错误:{last}")
+
+    def api_note(self):
+        src = {"cli": "--api", "env": "环境变量", "creds": "凭据文件", "default": "内置默认"}[self.source]
+        return f"{self.api_url}({site_label(self.api_url)},来源:{src})"
+
+
+def make_client(args, allow_fallback=True):
+    creds = load_creds()
+    api_url, source = resolve_api_url(getattr(args, "api", None), creds)
+    return Client(api_url, source, creds, allow_fallback=allow_fallback)

+ 284 - 0
plugins/wikipali/lib/cmd_read.py

@@ -0,0 +1,284 @@
+"""检索与阅读的子命令:forms / word / search / dist / get。
+
+全部只读,不需要凭据。
+"""
+
+import html as html_mod
+import json
+import re
+import sys
+
+from client import make_client, note
+from coords import fmt_coord, fmt_path, parse_coords, text_layer
+from errors import ApiError, WpError, explain_api_error
+
+# 巴利原文本身就是一个 channel(_System_Pali_VRI_)。取原文、取译文、取逐词解析
+# 是同一个调用换 channel。
+PALI_CHANNEL = '00b577c0-13b9-11ee-a05a-b7307efd9ee6'
+
+# 服务端的 sentence?view=paragraph 不带 channels 会 500,所以永远要给一个默认值。
+READ_TIMEOUT = 60
+
+
+def strip_markup(raw, hl='【】', bold='**'):
+    """把服务端返回的 HTML 压成纯文本,保留命中高亮与黑体两种信息。
+
+    命中词用 <span class='hl'> 包,黑体是原文的 <span class="bld">——后者是注释书
+    标出词条的地方,对判断「这段是不是定义」很关键,不能丢。
+    """
+    if not raw:
+        return ''
+    text = raw
+    text = re.sub(r"<span class='hl'>(.*?)</span>", hl[0] + r'\1' + hl[1], text, flags=re.S)
+    text = re.sub(r'<span class="bld">(.*?)</span>', bold + r'\1' + bold, text, flags=re.S)
+    text = re.sub(r"<MdTpl[^>]*></MdTpl>", '', text)
+    text = re.sub(r'<[^>]+>', '', text)
+    text = html_mod.unescape(text)
+    return re.sub(r'\s+', ' ', text).strip()
+
+
+def snippet(text, width, around=None):
+    """截断文本;给了 around 就尽量把它所在的位置露出来。"""
+    if len(text) <= width:
+        return text
+    if around:
+        pos = text.find(around)
+        if pos > width // 2:
+            start = pos - width // 3
+            return '…' + text[start:start + width] + '…'
+    return text[:width] + '…'
+
+
+def emit(args, payload, render):
+    if getattr(args, 'json', False):
+        print(json.dumps(payload, ensure_ascii=False, indent=2))
+    else:
+        render()
+
+
+# ---------------------------------------------------------------------------
+# forms —— 词形展开,一切检索的前置
+# ---------------------------------------------------------------------------
+
+
+def fetch_forms(client, word):
+    try:
+        data = client.call('GET', f'v2/case/{word}', timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        raise explain_api_error(exc, f'展开词形 {word}')
+    return (data or {}).get('rows') or []
+
+
+def cmd_forms(args):
+    client = make_client(args)
+    rows = fetch_forms(client, args.word)
+    if not rows:
+        raise WpError(
+            f'「{args.word}」在语料里找不到任何词形。检查拼写(变音符号是否正确),'
+            '或换一个可能的词根再试。'
+        )
+
+    def render():
+        for idx, row in enumerate(rows[: args.limit], 1):
+            forms = row.get('case') or []
+            total = sum(int(f.get('count') or 0) for f in forms)
+            bold = sum(int(f.get('bold') or 0) for f in forms)
+            mark = '  ← 可能性最高' if idx == 1 else ''
+            print(f'[{idx}] {row.get("word")}  {len(forms)} 形 / 共 {total} 次(黑体 {bold}){mark}')
+            for f in sorted(forms, key=lambda x: -int(x.get('count') or 0)):
+                print(f'      {f.get("word"):<20} {f.get("count"):>5} 次   黑体 {f.get("bold")}')
+        print()
+        print('检索用(第一候选的全部词形):')
+        print('  ' + forms_arg(rows[0]))
+        if len(rows) > 1:
+            print('注意:还有其他候选词根。若目标概念同时有名词与动词两条线,两条都要展开。')
+
+    emit(args, rows, render)
+    return 0
+
+
+def forms_arg(row):
+    """把一个候选的全部词形拼成 search 要的逗号串。"""
+    return ','.join(f.get('word') for f in (row.get('case') or []) if f.get('word'))
+
+
+# ---------------------------------------------------------------------------
+# word —— 词典释义与形态分析,用来确认选对了词根
+# ---------------------------------------------------------------------------
+
+
+def cmd_word(args):
+    client = make_client(args)
+    try:
+        data = client.call('GET', 'v2/dict', query={'word': args.word, 'lang': args.lang},
+                           timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        raise explain_api_error(exc, f'查词典 {args.word}')
+    groups = (data or {}).get('words') or []
+    if not groups:
+        raise WpError(f'词典里没有「{args.word}」。')
+
+    def render():
+        for grp in groups:
+            for w in (grp.get('words') or [])[: args.limit]:
+                print(f'■ {w.get("word")}')
+                for g in (w.get('grammar') or [])[:6]:
+                    print(f'    ← {g.get("parent")}  {g.get("type")} {g.get("grammar")}'
+                          f'  ({g.get("factors")})')
+                for d in (w.get('dict') or [])[: args.dicts]:
+                    # 释义在 note;description 是词典本身的介绍,不是词条内容
+                    meaning = strip_markup(d.get('note') or '')
+                    if not meaning:
+                        continue
+                    print(f'    〔{d.get("shortname")}·{d.get("lang")}〕{snippet(meaning, 220)}')
+                print()
+
+    emit(args, groups, render)
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# search —— 按词形检索段落
+# ---------------------------------------------------------------------------
+
+
+def resolve_key(client, args):
+    """确定检索用的词形串。--lemma 会先跑一次 forms,并把展开结果打出来。"""
+    if args.lemma:
+        rows = fetch_forms(client, args.lemma)
+        if not rows:
+            raise WpError(f'「{args.lemma}」展不出任何词形。')
+        key = forms_arg(rows[0])
+        note(f'⚠ 已把词根「{args.lemma}」展开为 {len(key.split(","))} 个词形:{key}')
+        return key
+    key = ','.join(part.strip() for item in args.forms for part in item.split(',') if part.strip())
+    if not key:
+        raise WpError('没有给出词形。用 --lemma <词根> 自动展开,或直接给逗号分隔的词形。')
+    return key
+
+
+def cmd_search(args):
+    client = make_client(args)
+    key = resolve_key(client, args)
+    query = {'key': key, 'limit': args.limit, 'offset': args.offset}
+    if args.bold:
+        query['bold'] = 'on'
+    if args.book:
+        query['book'] = args.book
+    if args.tags:
+        query['tags'] = args.tags
+    try:
+        data = client.call('GET', 'v2/search-pali-wbw', query=query, timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        raise explain_api_error(exc, '检索')
+    rows = (data or {}).get('rows') or []
+    total = (data or {}).get('count', 0)
+
+    def render():
+        scope = []
+        if args.bold:
+            scope.append('仅黑体')
+        if args.book:
+            scope.append(f'book={args.book}')
+        if args.tags:
+            scope.append(f'tags={args.tags}')
+        print(f'命中 {total} 段,本页 {len(rows)}(offset {args.offset})'
+              + (f'  [{" ".join(scope)}]' if scope else ''))
+        if not rows:
+            print('\n0 条。依次怀疑:词形没展开(用 --lemma)→ 词根选错 → 范围限太窄。')
+            return
+        print()
+        for idx, r in enumerate(rows, 1 + args.offset):
+            coord = fmt_coord(r.get('book'), r.get('paragraph'))
+            print(f'[{idx}] {coord}  {fmt_path(r.get("path"))}   rank {r.get("rank")}')
+            print(f'     {snippet(strip_markup(r.get("highlight")), args.width, "【")}')
+        print(f'\n引用时用坐标 book:paragraph,取原文用:wikipali get {rows[0].get("book")}:'
+              f'{rows[0].get("paragraph")}')
+
+    emit(args, {'count': total, 'rows': rows}, render)
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# dist —— 出处分布
+# ---------------------------------------------------------------------------
+
+
+def cmd_dist(args):
+    client = make_client(args)
+    key = resolve_key(client, args)
+    query = {'key': key}
+    if args.tags:
+        query['tags'] = args.tags
+    try:
+        data = client.call('GET', 'v2/search-pali-wbw-books', query=query, timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        raise explain_api_error(exc, '统计出处分布')
+    rows = (data or {}).get('rows') or []
+
+    def render():
+        total = sum(int(r.get('count') or 0) for r in rows)
+        print(f'{len(rows)} 部书,共 {total} 次词命中\n'
+              '(注意:这里数的是词次,不是段落数。段落数用 search 的 count,'
+              '两者不相等——同一段里出现多次只算一段。)\n')
+        by_layer = {}
+        for r in sorted(rows, key=lambda x: -int(x.get('count') or 0))[: args.limit]:
+            layer = text_layer(r.get('tags'))
+            by_layer[layer] = by_layer.get(layer, 0) + int(r.get('count') or 0)
+            tags = ' '.join(t.get('name') for t in (r.get('tags') or []) if t.get('name'))
+            print(f'{r.get("count"):>5}  {str(r.get("paliTitle"))[:38]:<40} '
+                  f'--book {r.get("pcdBookId")}   [{tags}]')
+        print('\n按文献层次:', end='')
+        for layer in ('mūla', 'aṭṭhakathā', 'ṭīkā', ''):
+            if layer in by_layer:
+                print(f'  {layer or "未标层次"} {by_layer[layer]}', end='')
+        print('\n引用时必须标明层次——把义注的解释当成本文的说法是学术错误。')
+
+    emit(args, {'rows': rows}, render)
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# get —— 按坐标取原文/译文
+# ---------------------------------------------------------------------------
+
+
+def cmd_get(args):
+    client = make_client(args)
+    grouped = parse_coords(args.coords)
+    channels = ','.join(args.channel) if args.channel else PALI_CHANNEL
+
+    collected = []
+    for book, paras in grouped.items():
+        # 服务端不带 channels 会 500,所以 channels 永远要给
+        query = {'view': 'paragraph', 'book': book, 'para': ','.join(str(p) for p in paras),
+                 'channels': channels, 'limit': args.limit}
+        try:
+            data = client.call('GET', 'v2/sentence', query=query, timeout=READ_TIMEOUT)
+        except ApiError as exc:
+            raise explain_api_error(exc, f'取 {book} 的段落')
+        collected.extend((data or {}).get('rows') or [])
+
+    def render():
+        if not collected:
+            print('这些坐标在指定 channel 下没有内容。')
+            print('注意:这是「该 channel 在此处没有文本」,不是「查询失败」——'
+                  '如实报告,不要拿相邻段落或别的译本凑。')
+            return
+        current = None
+        for r in collected:
+            ch = (r.get('channel') or {})
+            head = (r.get('book'), r.get('paragraph'), ch.get('uid'))
+            if head != current:
+                current = head
+                editor = (r.get('editor') or {})
+                who = editor.get('nickName') or editor.get('name') or ''
+                print(f'\n=== {fmt_coord(r.get("book"), r.get("paragraph"))}  '
+                      f'{ch.get("name")}({ch.get("lang")})'
+                      + (f'  作者:{who}' if who else '') + ' ===')
+            text = strip_markup(r.get('content'))
+            print(f'  [{r.get("word_start")}-{r.get("word_end")}] {text}')
+        print(f'\n共 {len(collected)} 句。')
+
+    emit(args, collected, render)
+    return 0

+ 82 - 0
plugins/wikipali/lib/cmd_site.py

@@ -0,0 +1,82 @@
+"""与站点和凭据状态有关的子命令:endpoint / whoami。"""
+
+import sys
+import time
+
+from client import fmt_ts, make_client, mask, note, token_expiry
+from creds import bucket_name_for, get_bucket, resolve_api_url, save_creds, load_creds, CREDS_PATH
+from errors import ApiError, WpError, explain_api_error
+from sites import SITES, expand_site_alias, normalize_api_url, site_label
+
+
+def cmd_endpoint(args):
+    creds = load_creds()
+    current_url, source = resolve_api_url(getattr(args, "api", None), creds)
+
+    if not args.target:
+        for idx, site in enumerate(SITES, 1):
+            mark = "  ← 当前" if site["url"] == current_url else ""
+            print(f"  {idx}) {site['url']:<32} {site['version']} · {site['domain']}{mark}")
+        if source in ("cli", "env"):
+            src = "--api" if source == "cli" else "WIKIPALI_API_URL"
+            note(f"注意:当前地址来自 {src},是一次性覆盖,未写入凭据文件。")
+        if current_url not in [s["url"] for s in SITES]:
+            print(f"  *) {current_url:<32} 自定义地址  ← 当前")
+        print("\n切换:python3 wp.py endpoint <序号|www|www.cc|next|next.cc|local|完整url>")
+        return 0
+
+    url = normalize_api_url(expand_site_alias(args.target))
+    name = bucket_name_for(url)
+    bucket = get_bucket(creds, name, url)
+    bucket["api_url"] = url
+    creds["current"] = name
+    save_creds(creds)
+    print(f"已切换到 {url}({site_label(url)})")
+    if name != "online":
+        note("提示:该地址与线上四站不共用数据库/密钥,凭据是独立的一桶,可能需要重新登录。")
+    return 0
+
+
+def cmd_whoami(args):
+    client = make_client(args)
+    print(f"API      : {client.api_note()}")
+    print(f"凭据文件 : {CREDS_PATH}(桶:{client.bucket_name})")
+
+    user = client.bucket.get("user") or {}
+    if user.get("token"):
+        exp = token_expiry(user["token"])
+        expired = exp is not None and exp < time.time()
+        print(f"用户     : {user.get('username', '?')}  uid={user.get('uid', '?')}")
+        print(f"           token {mask(user['token'])}  到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
+    else:
+        print("用户     : 未登录(! python3 scripts/wp_login.py)")
+
+    model = client.bucket.get("model") or {}
+    if model.get("token"):
+        exp = token_expiry(model["token"])
+        expired = exp is not None and exp < time.time()
+        print(f"模型     : {model.get('name', '?')}  uid={model.get('uid', '?')}")
+        print(f"           token {mask(model['token'])}  到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
+    else:
+        print("模型     : 未建立(python3 wp.py ensure-model --name <模型名>)")
+
+    tokens = client.bucket.get("access_tokens") or {}
+    if tokens:
+        print("access token:")
+        for uid, item in tokens.items():
+            exp = item.get("exp") or token_expiry(item.get("token", ""))
+            expired = exp is not None and exp < time.time()
+            book = item.get("book", 0)
+            scope = "全部 book" if book == 0 else f"book {book}"
+            name = item.get("channel_name") or ""
+            print(f"  {uid[:8]}… {name:<24} {scope:<10} 到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
+    else:
+        print("access token:无(python3 wp.py grant <channel>)")
+
+    if args.check:
+        try:
+            data = client.call("GET", "v2/auth/current", token=client.user_token)
+        except ApiError as exc:
+            raise explain_api_error(exc, "校验用户 token")
+        print(f"\n服务端确认:{data.get('nickName')} / realName={data.get('realName')}(studio_name 用它)")
+    return 0

+ 465 - 0
plugins/wikipali/lib/cmd_write.py

@@ -0,0 +1,465 @@
+"""写入路径的子命令:ensure-model / revoke / channels / grant / write。"""
+
+import json
+import sys
+import time
+from datetime import datetime, timezone
+
+from client import (WRITE_TIMEOUT, TOKEN_REFRESH_MARGIN, DEFAULT_BATCH,
+                    fmt_ts, make_client, mask, note, token_expiry)
+from errors import ApiError, WpError, explain_api_error
+
+
+def cmd_ensure_model(args):
+    client = make_client(args)
+    token = client.user_token
+
+    name = args.name or (client.bucket.get("model") or {}).get("name") or os.environ.get("WIKIPALI_MODEL_NAME")
+    if not name:
+        raise WpError(
+            "必须指定模型名:--name <模型标识>(如 claude-opus-5)。\n"
+            "该名字会成为句子的作者署名,不要用别的模型的名字。"
+        )
+
+    try:
+        current = client.call("GET", "v2/auth/current", token=token)
+    except ApiError as exc:
+        raise explain_api_error(exc, "取当前用户信息")
+    studio_name = current.get("realName")
+    if not studio_name:
+        raise WpError("服务端没有返回 realName,无法确定 studio_name。")
+
+    # 1) 按 studio + keyword 查,keyword 是模糊匹配,客户端自己做精确比对
+    try:
+        listed = client.call(
+            "GET", "v2/ai-model", token=token,
+            query={"view": "studio", "name": studio_name, "keyword": name},
+        )
+    except ApiError as exc:
+        raise explain_api_error(exc, "查询模型列表")
+    rows = (listed or {}).get("rows") or []
+    found = next((r for r in rows if r.get("name") == name), None)
+
+    if found:
+        print(f"已存在模型记录:{name}  uid={found['uid']}")
+    else:
+        body = {"name": name, "studio_name": studio_name, "privacy": args.privacy}
+        for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
+            if value is not None:
+                body[field] = value
+        try:
+            found = client.call("POST", "v2/ai-model", token=token, body=body)
+            print(f"已创建模型记录:{name}  uid={found['uid']}")
+        except ApiError as exc:
+            if exc.status != 409:
+                raise explain_api_error(exc, "创建模型记录")
+            # 并发或模糊匹配漏网:重查一次
+            listed = client.call(
+                "GET", "v2/ai-model", token=token,
+                query={"view": "studio", "name": studio_name, "keyword": name},
+            )
+            rows = (listed or {}).get("rows") or []
+            found = next((r for r in rows if r.get("name") == name), None)
+            if not found:
+                raise WpError(f"服务端说 {name} 已存在(409),但列表里查不到,无法继续。")
+            print(f"已存在模型记录:{name}  uid={found['uid']}")
+
+    # 2) 增量补字段(update 是增量的,未提交的字段保持原值)
+    patch = {}
+    for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
+        if value is not None and found.get(field) != value:
+            patch[field] = value
+    if args.privacy and found.get("privacy") != args.privacy:
+        patch["privacy"] = args.privacy
+    if patch:
+        try:
+            found = client.call("PUT", f"v2/ai-model/{found['uid']}", token=token, body=patch)
+            print(f"已更新字段:{', '.join(patch)}")
+        except ApiError as exc:
+            raise explain_api_error(exc, "更新模型记录")
+
+    # 3) 取模型身份 token
+    try:
+        issued = client.call("GET", f"v2/ai-model-token/{found['uid']}", token=token)
+    except ApiError as exc:
+        raise explain_api_error(exc, "签发模型身份 token")
+
+    client.bucket["model"] = {
+        "uid": issued["uid"],
+        "name": issued["name"],
+        "token": issued["token"],
+        "issued_at": iso_now(),
+    }
+    client.save()
+    exp = token_expiry(issued["token"])
+    print(f"模型身份 token 已缓存:{mask(issued['token'])}  到期 {fmt_ts(exp)}")
+    print(f"写入的句子将署名为该模型(editor_uid={issued['uid']})。")
+    return 0
+
+
+def cmd_revoke(args):
+    client = make_client(args)
+    model = client.bucket.get("model") or {}
+    uid = args.uid or model.get("uid")
+    if not uid:
+        raise WpError("没有可撤销的模型:请给 --uid <模型 uid>,或先跑 ensure-model。")
+    if not args.yes and not confirm(f"将撤销模型 {model.get('name', uid)} 已签出的全部 token,继续?"):
+        print("已取消。")
+        return 1
+    try:
+        data = client.call("DELETE", f"v2/ai-model-token/{uid}", token=client.user_token)
+    except ApiError as exc:
+        raise explain_api_error(exc, "撤销模型 token")
+    if model.get("uid") == uid:
+        client.bucket["model"] = {"uid": uid, "name": model.get("name")}
+        client.save()
+    print(f"已撤销 {data.get('name')} 的全部 token(token_version={data.get('token_version')})。")
+    print("本地缓存的模型 token 已清除,需要写入时请重跑 ensure-model。")
+    return 0
+
+
+def fetch_channels(client, search=None):
+    try:
+        data = client.call(
+            "GET", "v2/channel", token=client.user_token,
+            query={"view": "user-edit", "order": "updated_at", "dir": "desc", "limit": 200, "search": search},
+        )
+    except ApiError as exc:
+        raise explain_api_error(exc, "获取可编辑 channel 列表")
+    return (data or {}).get("rows") or []
+
+
+def cmd_channels(args):
+    client = make_client(args)
+    rows = fetch_channels(client, args.search)
+    if args.json:
+        print(json.dumps(rows, ensure_ascii=False, indent=2))
+        return 0
+    if not rows:
+        print("当前账号没有任何可编辑的 channel。")
+        return 1
+    print(f"可编辑 channel({len(rows)} 个,按更新时间倒序):")
+    for idx, ch in enumerate(rows, 1):
+        print(
+            f"  {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} "
+            f"{ch.get('uid', '')[:8]}…  {ch.get('role', '')}"
+        )
+    return 0
+
+
+def pick_channel(client, given, interactive=True):
+    """返回 (uid, name)。given 可以是 uid、序号或名字片段;为空则交互选择。"""
+    rows = fetch_channels(client)
+    if not rows:
+        raise WpError("当前账号没有任何可编辑的 channel,无法继续。")
+
+    if given:
+        for ch in rows:
+            if ch.get("uid") == given:
+                return ch["uid"], ch.get("name")
+        if given.isdigit() and 1 <= int(given) <= len(rows):
+            ch = rows[int(given) - 1]
+            return ch["uid"], ch.get("name")
+        matched = [c for c in rows if given.lower() in (c.get("name") or "").lower()]
+        if len(matched) == 1:
+            return matched[0]["uid"], matched[0].get("name")
+        if len(matched) > 1:
+            names = ", ".join(c.get("name", "") for c in matched[:5])
+            raise WpError(f"「{given}」匹配到多个 channel:{names}…… 请给完整 uid。")
+        # 不在可编辑列表里的 uid:直接用,但回显不出名字
+        if len(given) >= 32:
+            note(f"⚠ {given} 不在可编辑列表中,仍按 uid 使用——签发 access token 时可能返回 count: 0。")
+            return given, None
+        raise WpError(f"找不到 channel:{given}")
+
+    if not (interactive and sys.stdin.isatty()):
+        raise WpError("未指定 channel,且当前不是交互式终端。请先跑 `wp.py channels` 再用 --channel 指定。")
+
+    print("可编辑 channel:")
+    for idx, ch in enumerate(rows, 1):
+        print(f"  {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} {ch.get('uid', '')[:8]}…")
+    raw = input("选择序号:").strip()
+    if not raw.isdigit() or not (1 <= int(raw) <= len(rows)):
+        raise WpError("输入无效。")
+    ch = rows[int(raw) - 1]
+    return ch["uid"], ch.get("name")
+
+
+def cached_access_token(client, channel_uid, book):
+    item = (client.bucket.get("access_tokens") or {}).get(channel_uid)
+    if not item or not item.get("token"):
+        return None
+    # book 0 是「不限 book」,能覆盖任何请求;否则必须完全一致
+    if item.get("book", 0) != 0 and item.get("book") != book:
+        return None
+    exp = item.get("exp") or token_expiry(item["token"])
+    if exp and exp - time.time() < TOKEN_REFRESH_MARGIN:
+        return None
+    return item
+
+
+def grant_access_token(client, channel_uid, channel_name, book, force=False):
+    if not force:
+        cached = cached_access_token(client, channel_uid, book)
+        if cached:
+            return cached
+
+    # book 必须是整数:服务端用 !== 严格比较,"1" !== 1 恒真会导致鉴权失败
+    payload = [{"res_type": "channel", "res_id": channel_uid, "power": "edit", "book": int(book)}]
+    try:
+        data = client.call("POST", "v2/access-token", token=client.user_token, body={"payload": payload})
+    except ApiError as exc:
+        raise explain_api_error(exc, "签发 access token")
+    rows = (data or {}).get("rows") or []
+    if not rows:
+        # 无权时服务端静默跳过该条,rows 为空——等同 403,绝不能继续写
+        raise WpError(
+            f"签发 access token 返回 count: 0,说明当前账号对 channel {channel_uid} 没有编辑权。\n"
+            "不要继续写入。请确认选对了 channel,或让 owner 授予 ≥ editor 权限。"
+        )
+    row = rows[0]
+    item = {
+        "token": row["token"],
+        "book": int(book),
+        "exp": (row.get("payload") or {}).get("exp"),
+        "granted_at": iso_now(),
+    }
+    if channel_name:
+        item["channel_name"] = channel_name
+    client.bucket.setdefault("access_tokens", {})[channel_uid] = item
+    client.save()
+    return item
+
+
+def cmd_grant(args):
+    client = make_client(args)
+    uid, name = pick_channel(client, args.channel)
+    item = grant_access_token(client, uid, name, args.book, force=args.force)
+    scope = "全部 book" if item["book"] == 0 else f"book {item['book']}"
+    print(f"channel : {name or '(未知)'}  {uid}")
+    print(f"范围    : {scope}")
+    print(f"token   : {mask(item['token'])}  到期 {fmt_ts(item.get('exp'))}")
+    return 0
+
+
+SENT_REQUIRED = ("book_id", "paragraph", "word_start", "word_end", "content")
+
+
+def load_sentences(args):
+    if args.file == "-":
+        raw = sys.stdin.read()
+    else:
+        try:
+            with open(args.file, "r", encoding="utf-8") as fh:
+                raw = fh.read()
+        except OSError as exc:
+            raise WpError(f"读不了输入文件:{exc}")
+    try:
+        data = json.loads(raw)
+    except ValueError as exc:
+        raise WpError(f"输入不是合法 JSON:{exc}")
+
+    default_channel = None
+    if isinstance(data, dict):
+        default_channel = data.get("channel_uid") or data.get("channel")
+        data = data.get("sentences")
+    if not isinstance(data, list) or not data:
+        raise WpError('输入必须是句子数组,或 {"channel_uid": ..., "sentences": [...]},且非空。')
+    return data, default_channel
+
+
+def normalize_sentences(rows, channel_uid, default_content_type):
+    out = []
+    for idx, row in enumerate(rows):
+        if not isinstance(row, dict):
+            raise WpError(f"第 {idx + 1} 条不是对象。")
+        missing = [f for f in SENT_REQUIRED if row.get(f) is None]
+        if missing:
+            raise WpError(f"第 {idx + 1} 条缺字段:{', '.join(missing)}")
+        try:
+            sent = {
+                "book_id": int(row["book_id"]),
+                "paragraph": int(row["paragraph"]),
+                "word_start": int(row["word_start"]),
+                "word_end": int(row["word_end"]),
+                "content": str(row["content"]),
+                "content_type": row.get("content_type") or default_content_type,
+                "channel_uid": row.get("channel_uid") or channel_uid,
+            }
+        except (TypeError, ValueError) as exc:
+            raise WpError(f"第 {idx + 1} 条字段类型不对:{exc}")
+        if not sent["channel_uid"]:
+            raise WpError(f"第 {idx + 1} 条没有 channel_uid,且未通过 --channel 指定。")
+        out.append(sent)
+    return out
+
+
+def sent_key(sent):
+    return (
+        int(sent["book_id"]),
+        int(sent["paragraph"]),
+        int(sent["word_start"]),
+        int(sent["word_end"]),
+        sent["channel_uid"],
+    )
+
+
+def row_key(row):
+    channel = row.get("channel") or {}
+    return (
+        int(row.get("book", -1)),
+        int(row.get("paragraph", -1)),
+        int(row.get("word_start", -1)),
+        int(row.get("word_end", -1)),
+        channel.get("uid"),
+    )
+
+
+def confirm(question):
+    if not sys.stdin.isatty():
+        return False
+    answer = input(f"{question} [y/N] ").strip().lower()
+    return answer in ("y", "yes")
+
+
+def iso_now():
+    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def cmd_write(args):
+    client = make_client(args)
+    # 先确认凭据齐备再解析输入:缺 token 时不该让用户看半张回显
+    client.user_token
+    model = client.model
+    rows, file_channel = load_sentences(args)
+
+    channel_hint = args.channel or file_channel
+    uid = name = None
+    if channel_hint or not any(r.get("channel_uid") for r in rows if isinstance(r, dict)):
+        uid, name = pick_channel(client, channel_hint)
+    sentences = normalize_sentences(rows, uid, args.content_type)
+
+    channels = sorted({s["channel_uid"] for s in sentences})
+    books = sorted({s["book_id"] for s in sentences})
+    names = {uid: name} if uid else {}
+    for cuid in channels:
+        if cuid not in names:
+            names[cuid] = channel_display_name(client, cuid)
+
+    # 写入前的确认:出问题时必须知道是哪一版代码、写进了哪个 channel
+    print("=" * 72)
+    print(f"API      : {client.api_note()}")
+    for cuid in channels:
+        print(f"channel  : {names.get(cuid) or '(未知)'}  {cuid}")
+    print(f"book     : {', '.join(str(b) for b in books)}")
+    print(f"模型身份 : {model.get('name')}  uid={model.get('uid')}")
+    print(f"句子数   : {len(sentences)}(每批 {args.batch})")
+    print("-" * 72)
+    for sent in sentences[: args.preview]:
+        summary = sent["content"].replace("\n", " ")
+        if len(summary) > 50:
+            summary = summary[:50] + "…"
+        print(f"  {sent['book_id']}-{sent['paragraph']}-{sent['word_start']}-{sent['word_end']}  {summary}")
+    if len(sentences) > args.preview:
+        print(f"  …… 其余 {len(sentences) - args.preview} 条")
+    print("-" * 72)
+    print("⚠ 相同位置(book/paragraph/word_start/word_end/channel)的已有句子将被覆盖。")
+    print("=" * 72)
+
+    if args.dry_run:
+        print("--dry-run:未发送任何请求。")
+        return 0
+    if not args.yes and not confirm("确认写入?"):
+        print("已取消,未写入任何内容。")
+        return 1
+
+    # 每个 channel 一张 access token(缓存命中就不重签)
+    tokens = {}
+    for cuid in channels:
+        book_scope = 0 if len(books) > 1 else books[0]
+        if args.book is not None:
+            book_scope = args.book
+        item = grant_access_token(client, cuid, names.get(cuid), book_scope)
+        tokens[cuid] = item["token"]
+
+    written = {}
+    failed = []
+    model_token = model["token"]
+    for start in range(0, len(sentences), args.batch):
+        batch = sentences[start : start + args.batch]
+        body = {
+            "sentences": [
+                {
+                    "book_id": s["book_id"],
+                    "paragraph": s["paragraph"],
+                    "word_start": s["word_start"],
+                    "word_end": s["word_end"],
+                    "channel_uid": s["channel_uid"],
+                    "content": s["content"],
+                    "content_type": s["content_type"],
+                    "access_token": tokens[s["channel_uid"]],
+                }
+                for s in batch
+            ]
+        }
+        try:
+            data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
+        except ApiError as exc:
+            if exc.status != 401:
+                raise explain_api_error(exc, "写入句子")
+            # 模型 token 过期或被撤销:重取一次再试,仍失败才提示重新登录
+            model_token = refresh_model_token(client)
+            try:
+                data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
+            except ApiError as retry_exc:
+                raise explain_api_error(retry_exc, "写入句子(已重签模型 token 后重试)")
+        returned = (data or {}).get("rows") or []
+        for row in returned:
+            written[row_key(row)] = row
+        got = len(returned)
+        print(f"批次 {start // args.batch + 1}: 提交 {len(batch)},服务端确认 {got}")
+        if got < len(batch):
+            # HTTP 200 不等于全部写入:逐句鉴权失败是静默 continue 掉的
+            for s in batch:
+                if sent_key(s) not in written:
+                    failed.append(s)
+
+    print("-" * 72)
+    print(f"合计提交 {len(sentences)} 条,确认写入 {len(written)} 条。")
+    sample = next(iter(written.values()), None)
+    if sample:
+        editor = (sample.get("editor") or {}).get("nickName") or (sample.get("editor") or {}).get("name")
+        print(f"署名核对:第一条的 editor = {editor}")
+    if failed:
+        print(f"⚠ 有 {len(failed)} 条未写入(服务端逐句鉴权失败会静默跳过):")
+        for s in failed[:10]:
+            print(f"  {s['book_id']}-{s['paragraph']}-{s['word_start']}-{s['word_end']}  channel={s['channel_uid'][:8]}…")
+        if len(failed) > 10:
+            print(f"  …… 其余 {len(failed) - 10} 条")
+        return 1
+    return 0
+
+
+def channel_display_name(client, uid):
+    try:
+        data = client.call("GET", f"v2/channel/{uid}", token=client.user_token)
+    except (ApiError, WpError):
+        return None
+    if isinstance(data, dict):
+        return data.get("name")
+    return None
+
+
+def refresh_model_token(client):
+    note("⚠ 模型 token 被拒(过期或已撤销),正在重新签发……")
+    model = client.bucket.get("model") or {}
+    if not model.get("uid"):
+        raise WpError("缓存里没有模型 uid,无法重签。请跑:python3 wp.py ensure-model --name <模型名>")
+    try:
+        issued = client.call("GET", f"v2/ai-model-token/{model['uid']}", token=client.user_token)
+    except ApiError as exc:
+        raise explain_api_error(exc, "重新签发模型 token")
+    model.update({"uid": issued["uid"], "name": issued["name"], "token": issued["token"], "issued_at": iso_now()})
+    client.bucket["model"] = model
+    client.save()
+    return issued["token"]

+ 61 - 0
plugins/wikipali/lib/coords.py

@@ -0,0 +1,61 @@
+"""坐标与引用。
+
+WikiPali 的最小可引用单位是 (book, paragraph),句子再细分到 word_start/word_end。
+一切给用户看的内容都必须带得回坐标——这是研究型产出可信度的地基。
+
+坐标的书写形式统一为 `book:paragraph`,例如 `216:35`。
+"""
+
+import re
+
+from errors import WpError
+
+COORD_RE = re.compile(r'^\s*(\d+)\s*[:\-_]\s*(\d+)\s*$')
+
+
+def parse_coord(text):
+    """把 '216:35' 解析成 (216, 35)。也接受 216-35 / 216_35。"""
+    m = COORD_RE.match(str(text))
+    if not m:
+        raise WpError(f"坐标格式不对:{text}(应为 book:paragraph,如 216:35)")
+    return int(m.group(1)), int(m.group(2))
+
+
+def parse_coords(items):
+    """解析一串坐标,按 book 分组,返回 {book: [paragraph, ...]}(去重、保序)。"""
+    grouped = {}
+    for item in items:
+        for part in str(item).split(','):
+            if not part.strip():
+                continue
+            book, para = parse_coord(part)
+            paras = grouped.setdefault(book, [])
+            if para not in paras:
+                paras.append(para)
+    return grouped
+
+
+def fmt_coord(book, paragraph):
+    return f"{book}:{paragraph}"
+
+
+def fmt_path(path, sep=' › ', max_items=4):
+    """把检索结果的 path 数组压成一行章节路径。"""
+    if not path:
+        return ''
+    titles = [p.get('title', '') for p in path if isinstance(p, dict) and p.get('title')]
+    if len(titles) > max_items:
+        titles = [titles[0], '…'] + titles[-(max_items - 2):]
+    return sep.join(titles)
+
+
+def text_layer(tags):
+    """按 tags 判断文献层次:本文 / 义注 / 复注。引用时必须标明,混用是学术错误。"""
+    names = {t.get('name') for t in (tags or []) if isinstance(t, dict)}
+    if 'ṭīkā' in names:
+        return 'ṭīkā'
+    if 'aṭṭhakathā' in names:
+        return 'aṭṭhakathā'
+    if 'mūla' in names:
+        return 'mūla'
+    return ''

+ 80 - 0
plugins/wikipali/lib/creds.py

@@ -0,0 +1,80 @@
+"""凭据文件:~/.wikipali/credentials.json(0600)。
+
+只存 token,不存密码。线上四地址共用 online 桶,开发机 local,其余自成一桶。
+"""
+
+import json
+import os
+import stat
+
+from errors import WpError
+from sites import DEFAULT_API_URL, LOCAL_URL, ONLINE_URLS, expand_site_alias, normalize_api_url
+
+
+CREDS_DIR = os.path.join(os.path.expanduser("~"), ".wikipali")
+CREDS_PATH = os.path.join(CREDS_DIR, "credentials.json")
+
+
+def load_creds():
+    if not os.path.exists(CREDS_PATH):
+        return {"current": "online"}
+    try:
+        with open(CREDS_PATH, "r", encoding="utf-8") as fh:
+            data = json.load(fh)
+    except (OSError, ValueError) as exc:
+        raise WpError(f"凭据文件无法读取({CREDS_PATH}):{exc}")
+    if not isinstance(data, dict):
+        raise WpError(f"凭据文件格式不对({CREDS_PATH}),应为 JSON 对象")
+    data.setdefault("current", "online")
+    return data
+
+
+def save_creds(creds):
+    os.makedirs(CREDS_DIR, mode=0o700, exist_ok=True)
+    tmp = CREDS_PATH + ".tmp"
+    flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
+    fd = os.open(tmp, flags, 0o600)
+    try:
+        with os.fdopen(fd, "w", encoding="utf-8") as fh:
+            json.dump(creds, fh, ensure_ascii=False, indent=2)
+            fh.write("\n")
+    except Exception:
+        os.unlink(tmp)
+        raise
+    os.replace(tmp, CREDS_PATH)
+    os.chmod(CREDS_PATH, stat.S_IRUSR | stat.S_IWUSR)
+
+
+def bucket_name_for(api_url):
+    """凭据桶名。线上四地址共用 online 桶;开发机 local;其余地址自成一桶。"""
+    if api_url in ONLINE_URLS:
+        return "online"
+    if api_url == LOCAL_URL:
+        return "local"
+    return "site:" + api_url
+
+
+def get_bucket(creds, name, api_url=None):
+    bucket = creds.setdefault(name, {})
+    bucket.setdefault("api_url", api_url or (DEFAULT_API_URL if name == "online" else LOCAL_URL))
+    bucket.setdefault("user", {})
+    bucket.setdefault("model", {})
+    bucket.setdefault("access_tokens", {})
+    return bucket
+
+
+def resolve_api_url(cli_api, creds):
+    """地址来源优先级:--api > 环境变量 > 凭据文件 > 内置默认。
+
+    前两者是一次性覆盖,不写回凭据文件——否则「上周试了一次 next」会一直粘着。
+    """
+    if cli_api:
+        return normalize_api_url(expand_site_alias(cli_api)), "cli"
+    env = os.environ.get("WIKIPALI_API_URL")
+    if env:
+        return normalize_api_url(expand_site_alias(env)), "env"
+    current = creds.get("current", "online")
+    bucket = creds.get(current)
+    if isinstance(bucket, dict) and bucket.get("api_url"):
+        return normalize_api_url(bucket["api_url"]), "creds"
+    return DEFAULT_API_URL, "default"

+ 37 - 0
plugins/wikipali/lib/errors.py

@@ -0,0 +1,37 @@
+"""面向用户的错误类型,以及把 HTTP 状态翻译成人话。"""
+
+
+class WpError(Exception):
+    """面向用户的错误:main() 捕获后只打印 message,不打印堆栈。"""
+
+
+class ApiError(WpError):
+    def __init__(self, status, message, url=None, body=None):
+        self.status = status
+        self.url = url
+        self.body = body
+        super().__init__(message)
+
+
+def explain_api_error(exc, what):
+    """把 HTTP 状态翻译成对操作者有意义的话(见 references/api.md 的错误约定)。"""
+    if exc.status == 401:
+        return WpError(
+            f"{what}:401 凭据失效或已被撤销。\n"
+            "  · 用户 token 失效 → 重新登录:! python3 scripts/wp_login.py\n"
+            "  · 模型 token 失效或被撤销 → 重跑:python3 wp.py ensure-model\n"
+            "  不要自动重试。"
+        )
+    if exc.status == 403:
+        return WpError(f"{what}:403 无权限(不是 channel 的 owner/协作者,或不是模型 owner 本人)。")
+    if exc.status == 404:
+        return WpError(
+            f"{what}:404。若这是较新的端点,可能是当前站点跑的是稳定版代码、端点尚未上线;\n"
+            "  可切到最新版试试:python3 wp.py endpoint next\n"
+            "  否则才是资源真的不存在。"
+        )
+    if exc.status == 409:
+        return WpError(f"{what}:409 同名记录已存在。")
+    if exc.status == 422:
+        return WpError(f"{what}:422 参数校验失败——{exc}")
+    return WpError(f"{what}:HTTP {exc.status} {exc}")

+ 59 - 0
plugins/wikipali/lib/sites.py

@@ -0,0 +1,59 @@
+"""站点清单与地址解析。
+
+四个线上地址共享同一个数据库和同一把 jwt 密钥,凭据完全通用;
+.org / .cc 是地区可达性,www / next 是代码版本(不是数据环境)。
+开发机是另一个库、另一把密钥,故单独一桶,且永不作为自动 fallback 目标。
+"""
+
+import urllib.parse
+
+from errors import WpError
+
+
+SITES = [
+    {"key": "www", "url": "https://www.wikipali.org/api", "version": "稳定版", "domain": ".org"},
+    {"key": "www.cc", "url": "https://www.wikipali.cc/api", "version": "稳定版", "domain": ".cc"},
+    {"key": "next", "url": "https://next.wikipali.org/api", "version": "最新版", "domain": ".org"},
+    {"key": "next.cc", "url": "https://next.wikipali.cc/api", "version": "最新版", "domain": ".cc"},
+    {"key": "local", "url": "http://127.0.0.1:8000/api", "version": "开发机", "domain": "本机"},
+]
+
+ONLINE_URLS = [s["url"] for s in SITES if s["key"] != "local"]
+LOCAL_URL = SITES[-1]["url"]
+DEFAULT_API_URL = SITES[0]["url"]
+
+
+def normalize_api_url(url):
+    url = url.rstrip("/")
+    parsed = urllib.parse.urlparse(url)
+    if parsed.scheme not in ("http", "https"):
+        raise WpError(f"API 地址必须以 http:// 或 https:// 开头:{url}")
+    host = (parsed.hostname or "").lower()
+    if parsed.scheme == "http" and host not in ("127.0.0.1", "localhost", "::1"):
+        raise WpError(f"只有 127.0.0.1 / localhost 允许用 http://,其余必须 https://:{url}")
+    return url
+
+
+def expand_site_alias(value):
+    """把序号 / 简称展开成完整 url;已是 url 则原样返回。"""
+    value = value.strip()
+    if value.isdigit():
+        idx = int(value) - 1
+        if 0 <= idx < len(SITES):
+            return SITES[idx]["url"]
+        raise WpError(f"站点序号超出范围:{value}(可选 1-{len(SITES)})")
+    for site in SITES:
+        if value == site["key"]:
+            return site["url"]
+    if "://" in value:
+        return value
+    raise WpError(
+        f"无法识别的站点:{value}。可用简称:" + " / ".join(s["key"] for s in SITES) + ",或直接给完整 url"
+    )
+
+
+def site_label(api_url):
+    for site in SITES:
+        if site["url"] == api_url:
+            return f"{site['version']} · {site['domain']}"
+    return "自定义地址"

+ 102 - 0
plugins/wikipali/references/api-read.md

@@ -0,0 +1,102 @@
+# WikiPali API 参考(检索与阅读)
+
+读端**全部不需要凭据**。响应统一是 `{ ok, data, message }`。
+坐标、引用、层次等通用规矩见 `conventions.md`。
+
+## 1. 词形展开 —— `GET /v2/case/{词}`
+
+输入任意变格形或词根,返回候选词典原型,按可能性排序。
+
+```
+data: { rows: [ { word, count, case: [ {word, count, bold}, ... ] } ], count }
+```
+
+`rows[0]` 是可能性最高的候选;`case` 是**该词根在语料中实际出现过的全部词形**,
+每项带出现次数与其中的黑体次数。
+
+**这是一切检索的前置**:`wbw_templates` 索引的是变格形,拿词典形直接查会返回 0 条
+且不报错。
+
+## 2. 词典 —— `GET /v2/dict?word={词}&lang={zh|en|jp|…}`
+
+```
+data.words[].words[] = {
+  word, anchor, factors, parents,
+  grammar: [ {word, type, grammar, parent, factors, confidence} ],   ← 形态分析:形 → 根
+  dict:    [ {shortname, dictname, lang, note, dict_id} ]            ← 释义在 note
+}
+```
+
+⚠ **释义在 `note` 字段**,`description` 是词典本身的介绍("词数 7735"之类),不是词条
+内容。`note` 里可能夹着 `<MdTpl …></MdTpl>` 模板标记,要清掉。
+
+`case`/`grammar` 的方向是 **形 → 根**,用来确认"我选对了词根";**根 → 全部形**是
+`/v2/case` 的活。
+
+## 3. 检索 —— `GET /v2/search-pali-wbw`
+
+| 参数 | 说明 |
+|---|---|
+| `key` | **逗号分隔的词形列表**(不是词根)。多个词形之间是 OR |
+| `bold` | `on` 只要黑体命中、`off` 只要非黑体。黑体是注释书标出词条的地方 |
+| `book` | 限定书,值用 `search-pali-wbw-books` 返回的 `pcdBookId` |
+| `tags` | 范围限定,`tag1,tag2;tag3` —— 组间 OR、组内 AND |
+| `limit` / `offset` | 分页。`limit=200` 实测正常 |
+| ~~`view`~~ / ~~`type`~~ | **实测无影响**,源码也没读,可省略 |
+
+```
+data: { count: 命中段落数, rows: [ { book, paragraph, rank, path, paliTitle, highlight } ] }
+```
+
+- `rank` = `sum(weight)`,黑体权重更高;
+- `path` 是章节路径数组,每项 `{book, paragraph, title, level}`;
+- `highlight` 里命中词包在 `<span class='hl'>`,**并保留原文的 `<span class="bld">`**——
+  后者是黑体,判断"这段是不是定义"要靠它,清洗 HTML 时不能一并丢掉。
+
+## 4. 出处分布 —— `GET /v2/search-pali-wbw-books?key={词形,…}`
+
+```
+data.rows[] = { pcdBookId, count, book, paragraph, paliTitle, tags: [{name}] }
+```
+
+⚠ **`count` 数的是词次,不是段落数**。同一段里出现多次算多次。段落数要看
+`search-pali-wbw` 的 `count`。实测 `parivāsa`:词次 449、段落 281。方法论陈述里
+写错这两个数是硬伤。
+
+`tags` 含 `mūla` / `aṭṭhakathā` / `ṭīkā`,用来区分本文、义注、复注。
+
+## 5. 取文 —— `GET /v2/sentence`
+
+```
+view=paragraph&book={book}&para={p1,p2,…}&channels={uid,…}   按段落取
+view=chapter&book={book}&para={章节 para}&channels={uid,…}    取整章
+```
+
+⚠ **`channels` 参数是必需的**。不带 `channels` 会 **500**(实测;`lang` / `channel_type`
+单独用同样 500,它们只能与 `channels` 并用)。所以客户端必须永远给一个默认 channel。
+
+巴利原文的 channel:`_System_Pali_VRI_`,uid `00b577c0-13b9-11ee-a05a-b7307efd9ee6`。
+
+返回的每行是一个句子(不是整段):`{id, content, content_type, html, book, paragraph,
+word_start, word_end, editor, channel, updated_at}`。按 `word_start` 排序拼起来才是整段。
+
+⚠ 返回里字段名是 `book`(不是 `book_id`)。
+
+## 6. 目录 —— `GET /v2/palitext`
+
+`view=book-toc` / `chapter` / `chapter_children` / `children` / `paragraph`,
+参数 `book` / `para` / `series`。
+
+## 已知故障
+
+| 端点 | 现象 |
+|---|---|
+| `GET /v2/search?view=pali` | **500**。走 gRPC 的 `tulip` 服务,线上不可达 |
+| `GET /v2/search-book-list` | **500**,同上 |
+
+这两个是**词组/短语**检索(多词按全文匹配)。单词检索走 `search-pali-wbw`,不受影响。
+遇到需要短语检索时,把短语拆成词分别展开词形再检索。
+
+`GET /v2/search?view=title`(按标题)与 `view=page`(按页码)不走 gRPC,可用。
+
+⚠ `search` 的 `key` 以 `para` 开头、或首字母是 `M/P/T/V/O` 时会被劫持到页码检索分支。

+ 158 - 0
plugins/wikipali/references/api-write.md

@@ -0,0 +1,158 @@
+# WikiPali API 参考(写入路径)
+
+本文只记 Skill 用到的端点。契约以**稳定版**(`www.*`)为准;标注「最新版」的能力可能在稳定版上还是 404。
+
+基址记为 `{API}`,形如 `https://www.wikipali.org/api`。所有响应统一形如:
+
+```json
+{ "ok": true, "data": <任意>, "message": "" }
+```
+
+`ok: false` 时 `message` 是原因,HTTP 状态码同时表达语义。**HTTP 200 不等于全部成功**——见文末「静默跳过」。
+
+## 三种 token,职责不能混
+
+| Token | 从哪来 | 代表谁 | 用在哪 | 有效期 |
+|---|---|---|---|---|
+| userToken | `POST /v2/sign-in` | 人类操作者 | 查/建 ai-model、签 access token、列 channel | 365 天 |
+| modelToken | `GET /v2/ai-model-token/{uid}` | AI 模型身份 | **写句子时的 `Authorization`** | 30 天,可撤销 |
+| accessToken | `POST /v2/access-token` | 被委托的 channel 编辑权 | **写句子时的 body 字段** | 7 天 |
+
+写句子时两者同时出现:`Authorization: Bearer <modelToken>`,句子对象里带 `access_token: <accessToken>`。用错会让 `editor_uid` 落成人类用户,署名与审计就废了。
+
+---
+
+## 1. 登录
+
+`POST {API}/v2/sign-in` body `{ "username": "<用户名或邮箱>", "password": "<明文>" }`
+
+- `data` 是 JWT 字符串本身(不是对象)。
+- 失败返回 **HTTP 400**,`message` 是 `invalid token`——措辞误导,实际含义是用户名或密码不对。
+
+`GET {API}/v2/auth/current`(Bearer = userToken)
+
+- `data: { id, nickName, realName, avatar, token, roles }`
+- **`realName` 就是后面 `studio_name` 参数要用的值**,不是 `nickName`。
+
+## 2. AI Model
+
+`GET {API}/v2/ai-model?view=studio&name={studio_name}&keyword={模型名}`(Bearer = userToken)
+
+- `data: { rows: [...], count }`;`keyword` 是 `like %kw%` **模糊**匹配,客户端必须自己做 `name` 精确比对。
+- `view` 只接受 `all` / `studio` / `usable` / `chat`。**传别的值会 500**(服务端 switch 缺 default 分支,已知缺陷)。
+- 行里的 `key` / `system_prompt` 只在请求者是 owner 本人时才返回。
+
+`POST {API}/v2/ai-model`(Bearer = userToken) body `{name, studio_name, model?, url?, key?, privacy?, description?, system_prompt?}`
+
+- 一次 POST 即可带全字段,不需要再 PUT 补。
+- 同一 studio 内 `name` 重复 → **409**,客户端据此判定「已存在」。
+- `name` / `studio_name` 缺失 → 422。
+- 鉴权要求 `studio_name` 就是操作者本人的 studio(个人 studio),group studio 一律 403。
+
+`PUT {API}/v2/ai-model/{uid}`(Bearer = userToken)
+
+- **增量更新**:只改请求里出现的字段,未提交的保持原值;显式传 `null` 才会清空。
+- 改名撞上同 studio 内已有名字 → 409。
+
+## 3. 模型身份 token
+
+`GET {API}/v2/ai-model-token/{uid}`(Bearer = userToken)→ `data: { uid, name, token }`
+
+- 只有模型 owner 本人能调;未登录 401,非 owner 403,模型不存在 404。
+- 签出的 token 有效期 30 天,payload 带 `typ: "ai-model"` 与 `ver`。
+
+`DELETE {API}/v2/ai-model-token/{uid}`(Bearer = userToken)→ `data: { uid, name, token_version }`
+
+- 语义是**作废该模型已签出的全部 token**,无法只废一张。撤销后旧凭据一律 401。
+- 这两个端点较新,稳定版站点上可能还没有 → 404 时先怀疑「站点代码版本旧」,而不是「模型不存在」。
+
+## 4. 可编辑 channel 列表
+
+`GET {API}/v2/channel?view=user-edit`(Bearer = userToken)→ `data: { rows: [...], count }`
+
+- 语义:owner 是自己的 channel ∪ 协作权限 power ≥ 20 的 channel。
+- 行内含 `uid / name / summary / type / owner_uid / lang / status / updated_at / created_at / role / studio`。
+- 支持 `order` / `dir` / `limit`(默认 200)/ `offset` / `search`。
+
+`GET {API}/v2/channel/{uid}` 可用于回显单个 channel 的名字。
+
+## 5. 签发 channel access token
+
+`POST {API}/v2/access-token`(Bearer = userToken)
+
+```json
+{ "payload": [ { "res_type": "channel", "res_id": "<channel uid>", "power": "edit", "book": 0 } ] }
+```
+
+→ `data: { rows: [ { payload, token } ], count }`
+
+- **无权时该条被静默跳过**,返回 `count: 0` 和空 rows,HTTP 仍是 200。必须判空,等同 403 处理,**不可继续写入**。
+- 返回的 `payload` 里含 `nbf` / `exp`,据此判断何时重签。
+- ⚠ **`book` 必须是整数**。服务端校验用 `$jwt->book !== $book` 严格比较,而 `$book` 已被转成 int,写成 `"1"` 会让 `"1" !== 1` 恒真而永远鉴权失败。`0` 表示不限 book。
+
+## 6. 写入句子
+
+`POST {API}/v2/sentence`(Bearer = **modelToken**)
+
+```json
+{
+  "sentences": [
+    {
+      "book_id": 1,
+      "paragraph": 10,
+      "word_start": 0,
+      "word_end": 12,
+      "channel_uid": "<channel uid>",
+      "content": "译文",
+      "content_type": "markdown",
+      "access_token": "<第 5 步签出的 JWT>"
+    }
+  ]
+}
+```
+
+→ `data: { rows: [ ... ], count }`
+
+- 语义:按 `(book_id, paragraph, word_start, word_end, channel_uid)` 做 `firstOrNew`——**存在即覆盖,不存在则新建**。天然幂等,但也意味着会静默覆盖别人写的同位置句子,写前必须向用户确认。
+- 副作用:写 `sent_histories`(可追溯)、清缓存、发进度消息。
+- 返回的 rows 用的是另一套字段名:`book`(不是 `book_id`)、`paragraph`、`word_start`、`word_end`、`channel.uid`、`editor`。核对写入结果要按这套字段匹配。
+- 缺 `sentences` 字段时返回 **HTTP 200** 且 `message: "no date"`——这是客户端 bug,不是成功。
+
+### 静默跳过
+
+`store()` 对**逐句**鉴权失败是 `continue` 掉的,不报错。所以:
+
+> 提交 N 条、HTTP 200、`count` 却小于 N,意味着有句子没写进去。
+
+必须把返回的 rows 与提交的句子逐条比对,把差集报给用户。
+
+---
+
+## 错误约定
+
+| 现象 | 含义 | 处置 |
+|---|---|---|
+| 401 | token 失效/过期/被撤销 | 提示重新登录或重取模型 token,**不要自动重试** |
+| 403 | 无 channel 编辑权,或不是模型 owner | 指出缺哪一项权限 |
+| 404(较新端点) | 站点跑的是旧版代码 | 提示切到最新版站点,别当成「资源不存在」 |
+| 409 | 同 studio 内模型重名 | 当作「已存在」,回查列表取 uid |
+| 422 | 参数校验失败 | 看 message |
+| `access-token` 返回 `count: 0` | 对该 channel 无编辑权(静默跳过) | 当作 403,**中止写入** |
+| `sentence` 的 `count` < 提交条数 | 部分句子鉴权失败被跳过 | 逐条比对并报告 |
+| `message: "no date"` + 200 | 请求缺 `sentences` | 客户端 bug |
+
+## 站点
+
+四个线上地址**共享同一个数据库和同一把 JWT 密钥**,凭据完全通用,可随时切换:
+
+| 地址 | 地区 | 代码版本 |
+|---|---|---|
+| `https://www.wikipali.org/api` | .org | 稳定版 |
+| `https://www.wikipali.cc/api` | .cc | 稳定版 |
+| `https://next.wikipali.org/api` | .org | 最新版 |
+| `https://next.wikipali.cc/api` | .cc | 最新版 |
+| `http://127.0.0.1:8000/api` | 开发机 | 另一个库、另一把密钥 |
+
+- `.org` / `.cc` 是地区可达性;`www` / `next` 是**代码版本,不是数据环境**。
+- 线上四个之间可以自动 fallback(同一套数据),**但绝不能自动退到 127.0.0.1**。
+- 真正的风险不是写错库(写不错),而是写到不同版本的代码上,所以写入前要回显当前 api_url。

+ 81 - 0
plugins/wikipali/references/conventions.md

@@ -0,0 +1,81 @@
+# 通用约定
+
+**本文件的规则对本插件的所有 skill 一律有效**(研究、写入,以及以后加的任何流程)。
+单个 skill 只写自己流程特有的部分,共同的规矩都在这里——改一处,全部生效。
+
+## 坐标
+
+WikiPali 的最小可引用单位是 `book:paragraph`,例如 `216:35`。句子在段内再细分
+`word_start`–`word_end`。完整定位一条内容需要三样:
+
+```
+book : paragraph  +  word_start-word_end  +  channel_uid
+```
+
+`channel` 是**译本/版本**的载体:巴利原文、缅文逐词解析、各家汉译,都是同一坐标下
+的不同 channel。所以"取原文"和"取某语言译文"是同一个操作换 channel。
+
+读端与写端共用这一套坐标——检索到的位置,就是能写入的位置。
+
+## 引用格式
+
+> ⚠ **临时格式**,正式规范待定(见 `docs/wikipali-research-agent-design.md` §3.4)。
+> 规范给出后只改本节,所有 skill 自动跟上。
+
+```
+Cūḷavaggapāḷi, Pārivāsikakkhandhaka (VN 216:35)          ← 本文
+Samantapāsādikā, Pārivāsikavattakathā (SP-aṭṭ 141:63)     ← 义注,已标层次
+Nissaya(缅文,channel: nissaya)(216:35)                  ← 译文,标明语言与来源
+AI-汉译-Nissaya(**AI 生成**,deepseek-v3)(216:35)         ← 机器译文必须标注
+```
+
+书名与章节路径直接取检索结果的 `paliTitle` 与 `path` 字段,**不要自己拼**。
+
+## 文献层次必须标明
+
+`mūla`(本文)、`aṭṭhakathā`(义注)、`ṭīkā`(复注)是不同层次的权威。层次信息来自
+`dist` 输出里的 tags。
+
+**把义注的解释当成经律本身的说法是学术错误,不是措辞问题。** 引用时必须让读者看出
+这句话出自哪一层。
+
+## 译文来源的判定
+
+引用译文前必须判断人译还是机译。两个信号,**任一命中就按机器译文标注**:
+
+1. **作者是 AI 模型**——`get` 返回里的作者若是模型而非人类用户,该译文确定是机器
+   生成的,标注时连模型名一起写;
+2. **channel 名字含 "AI" 等字样**——库里存在人工用自己账号上传的机器译文(如
+   `Nissaya的AI翻译`、`Norbu AI Translations`),此时信号 1 不成立。
+
+两个都不命中时**不要主动断言"这是人译"**——只如实标出 channel 名与作者。
+
+## 空结果要诚实
+
+区分三件事,对用户的下一步完全不同:
+
+| 现象 | 含义 |
+|---|---|
+| 检索 0 条 | 多半是词形没展开(见下),不是"没有材料" |
+| 某坐标取不到某 channel 的内容 | 该译本在此处没有文本。**如实说,不要拿相邻段落或别的译本凑** |
+| 请求报错 | 工具或服务的问题,不是语料的问题 |
+
+## 检索前必须展开词形
+
+语料索引的是**变格形**(`parivāsaṃ` / `parivāso` / …),不是词典形(`parivāsa`)。
+拿词典形直接检索会**返回 0 条且不报错**——看起来像"搜过了,没有"。
+
+所以任何检索都必须先 `wikipali forms <词>`(或给 `search --lemma`)。
+
+## 站点
+
+线上四个地址(`www`/`next` × `.org`/`.cc`)共享同一个数据库和密钥,凭据通用。
+`www` 是稳定版、`next` 是最新版**代码**,不是不同的数据环境。较新的端点在稳定版上
+返回 404,意思是"该站点代码版本还没到",不是"资源不存在"。
+
+`wikipali endpoint` 查看与切换,`--api` 只影响单次调用。
+
+## 凭据
+
+`~/.wikipali/credentials.json`(0600)。**任何 skill 都不得打印 token 全文,也不得
+`cat` 这个文件。** 密码只由 `wikipali-login` 接触,且必须由用户本人在真正的终端里跑。

+ 131 - 0
plugins/wikipali/skills/research/SKILL.md

@@ -0,0 +1,131 @@
+---
+name: research
+description: "Use this skill to research Pali Buddhist texts with WikiPali's corpus — finding where a term occurs across the Tipiṭaka and its commentaries, reading the Pali source, comparing translations, and producing cited scholarly writing. Trigger whenever the user asks to look up a Pali word or concept, find passages about a topic in the canon, analyse how a term is used, compare mūla / aṭṭhakathā / ṭīkā, check a translation against the Pali, or write a paper or summary grounded in Pali sources. Do not use for writing data into WikiPali (that is the write skill)."
+---
+
+# WikiPali 研究
+
+用 WikiPali 的语料做巴利文献研究:定位 → 取证 → 展开 → 交叉验证 → 成文。
+
+命令是 `wikipali <子命令>`(插件启用时已在 PATH 上)。检索与阅读全部只读,**不需要登录**。
+
+**坐标、引用格式、文献层次、译文来源判定见 `references/conventions.md`——那是所有
+skill 共用的规矩,必须遵守。** 端点细节见 `references/api-read.md`。
+
+## 铁律
+
+1. **每一条写进正文的引用,必须带得回坐标。** 手里没有坐标的内容,一个字都不许写进
+   产出。宁可说"未找到相关段落",也不要凭印象转述。
+2. **检索前必须先展开词形**(`wikipali forms`,或给 `search --lemma`)。直接拿词典形
+   去搜会**返回 0 条且不报错**。这是本工具最容易犯的错,因为它看起来像"搜过了,没有"。
+3. **0 条结果不等于"没有材料"。** 依次怀疑:词形没展开 → 词根选错 → 范围限太窄 →
+   才是真的没有。把怀疑过程说给用户听。
+4. **本文、义注、复注不能混。** 引用时必须标明层次(见 conventions.md)。
+5. **判断依据是巴利原文,译文只作佐证。** 机器生成的译文必须显式标注。
+6. **不要把整章往上下文里灌。** 先看体量再决定取多少。
+
+## 流程
+
+### 1. 展开词形(永远的第一步)
+
+```bash
+wikipali forms parivāsa
+```
+
+输出候选词根,每个带该词根在语料中出现过的全部词形及频次、黑体数。取可能性最高的
+那个,但**要看一眼其余候选**:若目标概念同时有名词与动词两条线(`parivāsa` /
+`parivāseti`),两条都要展开。
+
+拿不准选哪个候选时:
+
+```bash
+wikipali word parivāsa          # 释义 + 形态分析,确认词根选对了
+```
+
+### 2. 看分布,再决定范围
+
+```bash
+wikipali dist --lemma parivāsa
+```
+
+输出每部书的命中数、`--book` 值和 tags,并按 `mūla` / `aṭṭhakathā` / `ṭīkā` 汇总。
+这一步决定后面所有工作的范围:
+
+- 命中集中在律藏 → 后续加 `--tags vinaya` 收窄;
+- 本文命中少而义注命中多 → 这是个**注释书概念**,论文结构要相应调整;
+- 总量太大 → 先收窄再取证,不要硬取。
+
+⚠ `dist` 数的是**词次**,`search` 数的是**段落数**,两个数不相等。方法论里别写混。
+
+### 3. 取定义:前 50 条,靠排序
+
+```bash
+wikipali search --lemma parivāsa --limit 50
+```
+
+结果按黑体加权排序,**注释书里作为词条解释的段落会自然排在前面**。从前 50 条里挑出
+讲定义和执行流程的,用来写定义部分。
+
+命中总量特别大、前 50 条噪声明显时,可以加 `--bold` 只看黑体命中来收窄——那是收窄
+手段,不是默认做法,因为不加黑体的定义段落会被它漏掉。
+
+### 4. 取案例:全量检索
+
+```bash
+wikipali search --lemma parivāsa --tags vinaya --limit 200
+```
+
+每条给出坐标、章节路径和高亮片段。**先用片段做初筛**,判断该段落属不属于目标案例
+类型,不要一上来就把每段全文取回来。
+
+### 5. 取原文
+
+```bash
+wikipali get 216:35 216:36 216:41
+```
+
+缺省取巴利原文。`--channel <uid>` 可指定别的译本(可重复给多个)。
+
+### 6. 交叉验证
+
+用 `--channel` 取缅文逐词解析(nissaya)或各家译本,核对你基于巴利原文做出的判断。
+某坐标在某 channel 下没有内容时,如实说"该译本在此处无文本",**不要拿相邻段落或
+别的译本凑**。
+
+## 上下文预算
+
+| 操作 | 默认上限 | 超了怎么办 |
+|---|---|---|
+| `search` 摘要 | 一次不超过 50 条进上下文 | 用 `--tags` / `--book` 收窄,或分页逐批归纳 |
+| `get` 取段落 | 一次不超过 20 段 | 分批,每批处理完先记下结论再取下一批 |
+
+原则:**上下文里应该留下结论和坐标,而不是原文**。取回一批材料 → 归纳出结论并记下
+支撑坐标 → 再取下一批。不要把所有原文堆着等最后一起分析。
+
+`--width` 控制每条摘要的长度,`--json` 输出原始数据(需要自己处理时用)。
+
+## 按任务类型分档
+
+同一套检索,产出的详略要看用户要什么:
+
+| 用户要的 | 产出形态 |
+|---|---|
+| 快速查询("parivāsa 什么意思"、"哪几处提到 X") | 结论 + 坐标。**不报告检索方法**,别把查词变成论文 |
+| 综述 / 分析("X 在律藏里怎么用") | 结论 + 坐标 + 一句话交代范围 |
+| 论文 / 研究报告(用户明确说要写论文、要发表、要引用) | 完整方法论:展开了哪些词形(连同频次)、检索范围、总命中词次与段落数、实读段数、黑体与非黑体分布 |
+
+判断不了属于哪一档时按中间档走,并问用户要不要完整的检索方法说明。
+
+论文档的方法论不是修辞——"检索 parivāsa 的 13 个词形,得 281 段(449 词次),分布
+于 43 部书,其中律藏本文 159 词次、义注 28、复注 102"这样一句,是读者判断你的检索
+是否穷尽的唯一依据。
+
+## 常见错误
+
+| 现象 | 真正的原因 |
+|---|---|
+| 检索 0 条 | 多半是没展开词形,用了词典形 |
+| 结果全是义注、没有本文 | 正常——注释书解释术语的密度本来就高。用 `dist` 的层次汇总确认,别当成 bug |
+| 某段落取不到译文 | 该 channel 在该段落没有内容。如实报告 |
+| 想按短语检索 | 平台的词组检索目前不可用(服务端 500)。把短语拆成词,分别展开词形再检索 |
+| `get` 报 500 | 忘了 channel。`get` 缺省会带巴利原文的 channel,若你手动传了参数要确保 channel 在内 |

+ 107 - 0
plugins/wikipali/skills/write/SKILL.md

@@ -0,0 +1,107 @@
+---
+name: write
+description: "Use this skill to write sentences (translations, commentary) into the WikiPali sentence database over its HTTP API, from any project. Trigger whenever the user asks to upload, push, publish, sync, or save translated Pali sentences to WikiPali / 巴利文 / wikipali.org, or mentions writing to a WikiPali channel, or asks about the wikipali CLI, wikipali-login, or ~/.wikipali/credentials.json. Handles login, AI-model identity tokens, channel selection, access tokens, and batched writes with attribution as the AI model rather than the human operator. Do not use for reading WikiPali data or for unrelated Laravel/API work."
+metadata:
+  author: mint
+---
+
+# WikiPali 写入
+
+把句子写进 WikiPali 句子库,**署名为 AI 模型身份**(`editor_uid` = 模型 uid),而不是操作者本人。
+
+只依赖 Python 标准库,直接跑,不要建虚拟环境:
+
+- `wikipali-login` —— 唯一接触密码的程序,**必须由用户本人在真正的终端里执行**
+- `wikipali` —— 其余全部操作
+
+命令是 `wikipali <子命令>`(插件启用时已在 PATH 上),登录是独立的 `wikipali-login`。
+
+**坐标、引用格式、译文来源判定、凭据规矩见 `references/conventions.md`——那是所有 skill 共用的,必须遵守。** 端点细节见 `references/api-write.md`。
+
+## 铁律
+
+1. **永远不要向用户索要密码,也不要代跑 `wp_login.py`。** 需要登录时,请用户**另开一个真正的终端**执行
+   `wikipali-login`(若不在 PATH 上,把插件目录下 `bin/wikipali-login` 的完整路径写给他们)。
+   不要让他们用 Claude Code 的 `!` 前缀——那里没有交互式终端,密码提示无处输入;也不要建议把密码放进命令行参数或直接打在对话里。
+2. **写入前必须让用户确认。** `wp.py write` 默认会回显目标并等确认;只有用户已经明确同意本次写入时,才可以加 `-y`。
+3. **绝不打印 token 全文**(`~/.wikipali/credentials.json` 里的任何值)。脚本自己会打码,不要 `cat` 那个文件。
+4. **`count` 不等于提交条数就是有句子没写进去**,必须如实报告给用户,不要说「已全部写入」。
+5. **收到 401 不要自动重试**,按脚本的提示走。
+
+## 首次准备
+
+```bash
+wikipali whoami          # 先看缺什么
+```
+
+按缺什么补什么:
+
+```bash
+# 1) 登录(用户自己在另一个终端里跑,不要用 ! 前缀,也不要代跑)
+wikipali-login
+
+# 2) 建立模型身份并取 token;--name 必须是你自己的模型标识
+wikipali ensure-model --name claude-opus-5
+
+# 3) 看有哪些可写的 channel
+wikipali channels
+```
+
+`--name` 决定句子的作者署名,**不要冒用别的模型的名字**。同名记录已存在时会直接复用(幂等)。
+
+## 写入
+
+输入是一个 JSON 文件,两种形状都接受:
+
+```json
+{
+  "channel_uid": "可选,整批共用的 channel",
+  "sentences": [
+    { "book_id": 1, "paragraph": 10, "word_start": 0, "word_end": 12,
+      "content": "译文", "content_type": "markdown" }
+  ]
+}
+```
+
+或直接是句子数组(此时用 `--channel` 指定目标)。`content_type` 可省略,默认 `markdown`;`channel_uid` 可以逐句给,用于跨 channel 批量写。
+
+```bash
+wikipali write sentences.json --channel <uid或名字片段> --dry-run   # 先看回显
+wikipali write sentences.json --channel <uid或名字片段>            # 再真写
+```
+
+`write` 会自动完成:解析校验 → 确定 channel → 回显确认 → 按需签发/复用 access token → 每 50 条一批提交 → 核对 `count` 并报告漏写的句子。
+
+**写入是覆盖式的**:相同 `(book_id, paragraph, word_start, word_end, channel_uid)` 的已有句子会被替换。回显里那行警告要转达给用户。
+
+## 站点
+
+四个线上地址共享同一个数据库和密钥,凭据通用;`www` 是稳定版、`next` 是最新版代码,**不是**不同的数据环境。
+
+```bash
+wikipali endpoint            # 列出并标出当前
+wikipali endpoint next       # 改默认(唯一会写回凭据的方式)
+wikipali --api next write …  # 只影响这一次调用
+```
+
+新端点在稳定版上返回 404 是「代码版本还没到」,不是「资源不存在」。
+
+## 出问题时
+
+| 现象 | 处置 |
+|---|---|
+| 401 | 用户 token 失效 → 请用户重跑 `wp_login.py`;模型 token 失效或被撤销 → 重跑 `ensure-model` |
+| 403 | 不是 channel 的 owner/协作者,或不是模型 owner。指出缺哪项权限,别换个姿势重试 |
+| `count: 0`(签 access token) | 对该 channel 无编辑权。**中止写入**,不要继续 |
+| `count` 小于提交条数 | 逐条差集已由脚本列出,如实转达 |
+| 404(`ai-model-token` 等新端点) | 提示切到 `next` 或稍后再试 |
+
+凭据泄漏时撤销模型的全部 token:
+
+```bash
+wikipali revoke
+```
+
+## 更多
+
+端点字段、返回形状与各处陷阱见 `references/api-write.md`。若脚本行为与该文件对不上,多半是这份副本过期了——插件用户跑 `/plugin update`,手工安装的用户重新装一遍。